Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153e1b92bf | ||
|
|
fc5ae7403a | ||
|
|
13149eff08 | ||
|
|
9c53d9bf87 | ||
|
|
bc9625fece | ||
|
|
7e00162ef2 | ||
|
|
af38750e29 | ||
|
|
314f4850bc | ||
|
|
9ff2a83ba3 | ||
|
|
2ab466c570 | ||
|
|
184ba84600 | ||
|
|
99dddb1af4 | ||
|
|
48eca3f5af | ||
|
|
71192cc2ee | ||
|
|
29c7344540 | ||
|
|
6411d23744 | ||
|
|
1a74736115 | ||
|
|
7c11ecb3a7 | ||
|
|
fd7c833de0 | ||
|
|
7fec8b0d7e | ||
|
|
52622fadbb | ||
|
|
57255e0aec | ||
|
|
17ecfa132d | ||
|
|
d1365c3d7d | ||
|
|
c33891a4bc | ||
|
|
9a63f57147 | ||
|
|
839a62cb07 | ||
|
|
dc598e466e | ||
|
|
b698697256 | ||
|
|
f802d1524f | ||
|
|
0cb18f9e1a | ||
|
|
ba722487d8 | ||
|
|
eff2634b32 | ||
|
|
1470aefd42 | ||
|
|
b7fd87b09c | ||
|
|
ab82a1656d | ||
|
|
71387e94d8 | ||
|
|
503379079b | ||
|
|
1ae767087f | ||
|
|
cfd2b7b7aa | ||
|
|
2c42b4c585 | ||
|
|
d3a9ff539e | ||
|
|
58f01bd642 | ||
|
|
38806740e1 | ||
|
|
df583e73c2 | ||
|
|
e787d33e5a | ||
|
|
91db665428 | ||
|
|
94d155cff2 | ||
|
|
ad79075fd7 | ||
|
|
7baefe2f44 | ||
|
|
141a4c29bb | ||
|
|
b2992da370 | ||
|
|
fdee254020 | ||
|
|
c51489ac74 | ||
|
|
3cd394ec10 | ||
|
|
8374fea776 | ||
|
|
733ca891de | ||
|
|
490d121db3 | ||
|
|
45c5efffbd | ||
|
|
a24c929acf | ||
|
|
86a39f10d1 | ||
|
|
4658afdc20 | ||
|
|
ae6c2afb30 | ||
|
|
a3844a3535 | ||
|
|
b710075544 | ||
|
|
c4c9786050 | ||
|
|
b4cc81139a | ||
|
|
fb20eb9162 | ||
|
|
263987d2c9 | ||
|
|
0b30a35383 | ||
|
|
47df1fc602 | ||
|
|
d8375454b9 | ||
|
|
ad535501c4 | ||
|
|
159f5cbd00 | ||
|
|
2bc74d5378 | ||
|
|
eb513f563e | ||
|
|
09dc5e9846 | ||
|
|
cf35a87d85 | ||
|
|
9f25f619a8 | ||
|
|
7e989c730c | ||
|
|
0926e86956 | ||
|
|
75967730fd | ||
|
|
a3be3e354f | ||
|
|
58c52196f1 | ||
|
|
b7b49a60cf | ||
|
|
fa195483d6 | ||
|
|
2341f6ea3b | ||
|
|
ffe0f0730d | ||
|
|
23b512910e | ||
|
|
b1c624b104 | ||
|
|
fe35be6682 | ||
|
|
2d3eb29bd5 | ||
|
|
26f0ff62df | ||
|
|
5e145846bd | ||
|
|
1ae5f99bf0 | ||
|
|
984119c7ee | ||
|
|
f8f5eac109 | ||
|
|
4111d5fa48 | ||
|
|
2eca9056b9 | ||
|
|
60e96572ff | ||
|
|
52193933b2 | ||
|
|
7bcabdda38 | ||
|
|
d993941c4d | ||
|
|
b447bff9a6 | ||
|
|
73cb5ffba4 | ||
|
|
7d694229c1 | ||
|
|
cdb6c9a1a4 | ||
|
|
cc1d2b423f | ||
|
|
508e031143 | ||
|
|
5a093a9a04 | ||
|
|
074d647d19 | ||
|
|
6cb98f99c5 | ||
|
|
7d28681b23 | ||
|
|
859a8e933c | ||
|
|
a476d5986d | ||
|
|
31812bc2d9 | ||
|
|
30ba69eca7 | ||
|
|
cf1bc1c252 | ||
|
|
ee109ba67d | ||
|
|
9c6211e8e0 | ||
|
|
0729e4ab09 | ||
|
|
5cbe728631 | ||
|
|
920f4df213 | ||
|
|
c48eacd9af | ||
|
|
30e6deeeaa | ||
|
|
5bc76a3160 | ||
|
|
114925ebce | ||
|
|
5a80a0cc06 | ||
|
|
aebefac7e6 | ||
|
|
b2d0ee41f2 | ||
|
|
9c20250b0a | ||
|
|
b196836fca | ||
|
|
d9fbcc615a | ||
|
|
fb247fb33f | ||
|
|
61f4dbd896 | ||
|
|
2c86571818 | ||
|
|
1b2ec67726 | ||
|
|
845af854bd | ||
|
|
15b6a66d98 | ||
|
|
c95ba0764b | ||
|
|
42c0648ba7 | ||
|
|
0a6e55dcb7 | ||
|
|
99b77decff | ||
|
|
9e2ca4e586 | ||
|
|
2e8acfdeef | ||
|
|
630096e06d | ||
|
|
d92d892dc7 | ||
|
|
a8f41841bd | ||
|
|
76954b5a0a | ||
|
|
c57b184a09 | ||
|
|
20ca4e0739 | ||
|
|
a972ed5e2e | ||
|
|
2b15bc6ebb | ||
|
|
f7a482659c | ||
|
|
99527453a7 | ||
|
|
3408b4637c | ||
|
|
3f2899e97e | ||
|
|
562496cfaa | ||
|
|
8283f19d6b | ||
|
|
242909b542 | ||
|
|
a7b83ad5e0 | ||
|
|
ed66019d9a | ||
|
|
bc0009be6c | ||
|
|
c88f47eed4 | ||
|
|
59de048ced | ||
|
|
7987dfb819 | ||
|
|
1b101106e7 | ||
|
|
7b75955aec | ||
|
|
8f5467e6ca | ||
|
|
28764f92b9 | ||
|
|
777dfe4c62 | ||
|
|
0878a704d9 | ||
|
|
f880897542 | ||
|
|
b37472a954 | ||
|
|
68735a45dd | ||
|
|
e26deb9092 | ||
|
|
43d6ea82cd | ||
|
|
db1aa495ac | ||
|
|
ee62d9ae8d | ||
|
|
4001124cfa | ||
|
|
43a4d0d1d7 | ||
|
|
632b432b7c | ||
|
|
e778c7a59d | ||
|
|
d71cdecd35 | ||
|
|
4a82541ffd | ||
|
|
f29dff3386 | ||
|
|
718d21f6cb | ||
|
|
440550ded9 | ||
|
|
593fe57ea1 | ||
|
|
e8a320dac9 | ||
|
|
3cb43e5d3e | ||
|
|
f86bdba3c3 | ||
|
|
98c3940297 | ||
|
|
b9e789bbcf | ||
|
|
a108846731 | ||
|
|
0b4ce8d6e7 | ||
|
|
42df61b7dd | ||
|
|
6b46fa4cbc | ||
|
|
c0762eba18 | ||
|
|
036fb848e1 | ||
|
|
7198ae9025 | ||
|
|
d2822b06aa |
@@ -27,7 +27,7 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
||||
|
||||
### Linux and Mac
|
||||
|
||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
|
||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
|
||||
|
||||
### Settings
|
||||
|
||||
|
||||
@@ -66,4 +66,4 @@
|
||||
|
||||
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
|
||||
|
||||
I made this for myself and I want to share it with the great programming and audible/audiobook communiites which have been so generous with their time and help.
|
||||
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean" Version="0.4.7" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.7" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
@@ -10,8 +11,8 @@ namespace AaxDecrypter
|
||||
|
||||
protected AaxFile AaxFile;
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic) { }
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
@@ -109,10 +110,11 @@ namespace AaxDecrypter
|
||||
});
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
AaxFile?.Cancel();
|
||||
if (AaxFile != null)
|
||||
await AaxFile.CancelAsync();
|
||||
AaxFile?.Dispose();
|
||||
CloseInputFileStream();
|
||||
}
|
||||
|
||||
@@ -2,38 +2,68 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
private Func<MultiConvertFileProperties, string> multipartFileNameCallback { get; }
|
||||
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic,
|
||||
Func<MultiConvertFileProperties, string> multipartFileNameCallback = null)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
|
||||
["Step 3: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
this.multipartFileNameCallback = multipartFileNameCallback ?? MultiConvertFileProperties.DefaultMultipartFilename;
|
||||
}
|
||||
public override async Task<bool> RunAsync()
|
||||
{
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
|
||||
|
||||
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
|
||||
@@ -56,86 +86,104 @@ The book will be split into the following files:
|
||||
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
|
||||
|
||||
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
||||
*/
|
||||
private bool Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
*/
|
||||
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters.ToList();
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
|
||||
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
var runningTotal = TimeSpan.Zero;
|
||||
string title = "";
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
if (runningTotal == TimeSpan.Zero)
|
||||
title = chapters[i].Title;
|
||||
|
||||
runningTotal += chapters[i].Duration;
|
||||
runningTotal += chapters[i].Duration;
|
||||
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
if (runningTotal >= minChapterLength)
|
||||
{
|
||||
splitChapters.AddChapter(title, runningTotal);
|
||||
runningTotal = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
|
||||
ConversionResult result;
|
||||
ConversionResult result;
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
||||
result = ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
result = ConvertToMultiMp3(splitChapters);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
||||
result = await ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
result = await ConvertToMultiMp3(splitChapters);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
return result == ConversionResult.NoErrorsDetected;
|
||||
}
|
||||
return result == ConversionResult.NoErrorsDetected;
|
||||
}
|
||||
|
||||
private ConversionResult ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp4a(splitChapters, newSplitCallback =>
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.TrimOutputToChapterLength);
|
||||
}
|
||||
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp4aAsync
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
);
|
||||
}
|
||||
|
||||
private ConversionResult ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
|
||||
{
|
||||
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
|
||||
((NAudio.Lame.LameConfig)newSplitCallback.UserState).ID3.Track = chapterCount.ToString();
|
||||
}, DownloadOptions.LameConfig, DownloadOptions.TrimOutputToChapterLength);
|
||||
}
|
||||
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp3Async
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
);
|
||||
}
|
||||
|
||||
private void createOutputFileStream(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
var fileName = multipartFileNameCallback(new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title
|
||||
});
|
||||
fileName = FileUtility.GetValidFilename(fileName);
|
||||
|
||||
multiPartFilePaths.Add(fileName);
|
||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
|
||||
=> Callback(currentChapter, splitChapters, newSplitCallback);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
|
||||
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
|
||||
|
||||
OnFileCreated(fileName);
|
||||
}
|
||||
}
|
||||
multiPartFilePaths.Add(fileName);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
|
||||
var file = File.Open(fileName, FileMode.OpenOrCreate);
|
||||
OnFileCreated(fileName);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,98 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
Steps = new StepSequence
|
||||
{
|
||||
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
|
||||
public override async Task<bool> RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
|
||||
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
||||
["Step 3: Create Cue"] = Step_CreateCue,
|
||||
["Step 4: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
}
|
||||
//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;
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
//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;
|
||||
}
|
||||
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
//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;
|
||||
}
|
||||
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(OutputFileName);
|
||||
//Step 4
|
||||
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;
|
||||
}
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
var decryptionResult
|
||||
= DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? AaxFile.ConvertToMp4a(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
|
||||
: AaxFile.ConvertToMp3(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
DownloadOptions.ChapterInfo = AaxFile.Chapters;
|
||||
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(OutputFileName);
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
var decryptionResult
|
||||
= DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? await AaxFile.ConvertToMp4aAsync(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
|
||||
: await AaxFile.ConvertToMp3Async(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
DownloadOptions.ChapterInfo = AaxFile.Chapters;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
@@ -21,41 +21,40 @@ namespace AaxDecrypter
|
||||
public event EventHandler<string> FileCreated;
|
||||
|
||||
public bool IsCanceled { get; set; }
|
||||
|
||||
public string TempFilePath { get; }
|
||||
|
||||
protected string OutputFileName { get; private set; }
|
||||
protected DownloadOptions DownloadOptions { get; }
|
||||
protected IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).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;
|
||||
|
||||
protected abstract StepSequence Steps { get; }
|
||||
private NetworkFileStreamPersister nfsPersister;
|
||||
|
||||
private string jsonDownloadState { get; }
|
||||
public string TempFilePath { get; }
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
|
||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
||||
if (!Directory.Exists(outDir))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
if (!Directory.Exists(cacheDirectory))
|
||||
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
||||
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
|
||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||
|
||||
// delete file after validation is complete
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract void Cancel();
|
||||
public abstract Task CancelAsync();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
@@ -63,15 +62,7 @@ namespace AaxDecrypter
|
||||
OnRetrievedCoverArt(coverArt);
|
||||
}
|
||||
|
||||
public bool Run()
|
||||
{
|
||||
var (IsSuccess, Elapsed) = Steps.Run();
|
||||
|
||||
if (!IsSuccess)
|
||||
Serilog.Log.Logger.Error("Conversion failed");
|
||||
|
||||
return IsSuccess;
|
||||
}
|
||||
public abstract Task<bool> RunAsync();
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
@@ -79,10 +70,8 @@ namespace AaxDecrypter
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
|
||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
@@ -104,7 +93,7 @@ namespace AaxDecrypter
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
path = FileUtility.GetValidFilename(path);
|
||||
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
|
||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class DownloadOptions
|
||||
{
|
||||
public string DownloadUrl { get; }
|
||||
public string UserAgent { get; }
|
||||
public string AudibleKey { get; init; }
|
||||
public string AudibleIV { get; init; }
|
||||
public 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 ChapterInfo ChapterInfo { get; set; }
|
||||
public NAudio.Lame.LameConfig LameConfig { get; set; }
|
||||
public bool Downsample { get; set; }
|
||||
public bool MatchSourceBitrate { get; set; }
|
||||
|
||||
public DownloadOptions(string downloadUrl, string userAgent)
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Source/AaxDecrypter/IDownloadOptions.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using AAXClean;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
string AudibleKey { get; }
|
||||
string AudibleIV { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool TrimOutputToChapterLength { get; }
|
||||
bool RetainEncryptedFile { get; }
|
||||
bool StripUnabridged { get; }
|
||||
bool CreateCueSheet { get; }
|
||||
ChapterInfo ChapterInfo { get; set; }
|
||||
NAudio.Lame.LameConfig LameConfig { get; set; }
|
||||
bool Downsample { get; set; }
|
||||
bool MatchSourceBitrate { get; set; }
|
||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitleName(MultiConvertFileProperties props);
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,5 @@ namespace AaxDecrypter
|
||||
public int PartsTotal { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
|
||||
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
|
||||
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
|
||||
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,14 @@ namespace AaxDecrypter
|
||||
private void Update()
|
||||
{
|
||||
RequestHeaders = HttpRequest.Headers;
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
try
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -211,46 +218,55 @@ namespace AaxDecrypter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downlod <see cref="Uri"/> to <see cref="SaveFilePath"/>.
|
||||
/// Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.
|
||||
/// </summary>
|
||||
private void DownloadFile()
|
||||
{
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
do
|
||||
|
||||
try
|
||||
{
|
||||
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
||||
_writeFile.Write(buff, 0, bytesRead);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
|
||||
if (downloadPosition > nextFlush)
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
_writeFile.Flush();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
downloadedPiece.Set();
|
||||
}
|
||||
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
||||
_writeFile.Write(buff, 0, bytesRead);
|
||||
|
||||
} while (downloadPosition < ContentLength && !IsCancelled);
|
||||
downloadPosition += bytesRead;
|
||||
|
||||
_writeFile.Close();
|
||||
_networkStream.Close();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
if (downloadPosition > nextFlush)
|
||||
{
|
||||
_writeFile.Flush();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
downloadedPiece.Set();
|
||||
}
|
||||
|
||||
downloadedPiece.Set();
|
||||
downloadEnded.Set();
|
||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
||||
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
_writeFile.Close();
|
||||
_networkStream.Close();
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
|
||||
}
|
||||
finally
|
||||
{
|
||||
downloadedPiece.Set();
|
||||
downloadEnded.Set();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -393,7 +409,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
if (!hasBegunDownloading)
|
||||
BeginDownloading();
|
||||
|
||||
|
||||
var toRead = Math.Min(count, Length - Position);
|
||||
WaitToPosition(Position + toRead);
|
||||
return _readFile.Read(buffer, offset, count);
|
||||
@@ -418,14 +434,20 @@ namespace AaxDecrypter
|
||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void WaitToPosition(long requiredPosition)
|
||||
{
|
||||
while (requiredPosition > WritePosition && !IsCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
|
||||
while (WritePosition < requiredPosition
|
||||
&& hasBegunDownloading
|
||||
&& !IsCancelled
|
||||
&& !downloadEnded.WaitOne(0))
|
||||
{
|
||||
downloadedPiece.WaitOne(100);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
IsCancelled = true;
|
||||
|
||||
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
|
||||
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
|
||||
|
||||
_readFile.Close();
|
||||
_writeFile.Close();
|
||||
|
||||
@@ -1,33 +1,69 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
{
|
||||
protected override StepSequence Steps { get; }
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, DownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic) { }
|
||||
|
||||
public override async Task<bool> RunAsync()
|
||||
{
|
||||
Steps = new StepSequence
|
||||
try
|
||||
{
|
||||
Name = "Download Mp3 Audiobook",
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
|
||||
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
|
||||
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
|
||||
["Step 3: Create Cue"] = Step_CreateCue,
|
||||
["Step 4: Cleanup"] = Step_Cleanup,
|
||||
};
|
||||
//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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Cancel()
|
||||
|
||||
public override Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
CloseInputFileStream();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
@@ -65,8 +101,8 @@ namespace AaxDecrypter
|
||||
}
|
||||
|
||||
CloseInputFileStream();
|
||||
|
||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName);
|
||||
|
||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
|
||||
SetOutputFileName(realOutputFileName);
|
||||
OnFileCreated(realOutputFileName);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<Version>7.6.2.1</Version>
|
||||
<Version>8.1.2.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -15,6 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationFileManager;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
|
||||
@@ -36,7 +37,8 @@ namespace AppScaffolding
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
Migrations.migrate_to_v5_2_0__pre_config();
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
@@ -57,6 +59,7 @@ namespace AppScaffolding
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
Migrations.migrate_from_7_10_1(config);
|
||||
}
|
||||
|
||||
public static void PopulateMissingConfigValues(Configuration config)
|
||||
@@ -114,6 +117,9 @@ namespace AppScaffolding
|
||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
||||
config.DownloadEpisodes = true;
|
||||
|
||||
if (!config.Exists(nameof(config.ReplacementCharacters)))
|
||||
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
|
||||
|
||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
||||
|
||||
@@ -123,6 +129,9 @@ namespace AppScaffolding
|
||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
|
||||
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
|
||||
|
||||
if (!config.Exists(nameof(config.AutoScan)))
|
||||
config.AutoScan = true;
|
||||
|
||||
@@ -137,14 +146,19 @@ namespace AppScaffolding
|
||||
|
||||
if (!config.Exists(nameof(config.DownloadCoverArt)))
|
||||
config.DownloadCoverArt = true;
|
||||
|
||||
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
|
||||
config.AutoDownloadEpisodes = false;
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Run after migration</summary>
|
||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Configuration config)
|
||||
{
|
||||
ensureSerilogConfig(config);
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
|
||||
wireUpSystemEvents(config);
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
@@ -250,18 +264,21 @@ namespace AppScaffolding
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
#if DEBUG
|
||||
var mode = "Debug";
|
||||
#else
|
||||
var mode = "Release";
|
||||
#endif
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly.GetName().Name,
|
||||
Version = BuildVersion.ToString(),
|
||||
#if DEBUG
|
||||
Mode = "Debug",
|
||||
#else
|
||||
Mode = "Release",
|
||||
#endif
|
||||
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
@@ -282,22 +299,26 @@ namespace AppScaffolding
|
||||
});
|
||||
}
|
||||
|
||||
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
(bool, string, string, string) isFalse = (false, null, null, null);
|
||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
||||
}
|
||||
|
||||
public static UpgradeProperties GetLatestRelease()
|
||||
{
|
||||
// timed out
|
||||
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
if (latest is null)
|
||||
return isFalse;
|
||||
return null;
|
||||
|
||||
var latestVersionString = latest.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
||||
return isFalse;
|
||||
return null;
|
||||
|
||||
// we're up to date
|
||||
if (latestRelease <= BuildVersion)
|
||||
return isFalse;
|
||||
return null;
|
||||
|
||||
// we have an update
|
||||
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
|
||||
@@ -310,7 +331,7 @@ namespace AppScaffolding
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return (true, zipUrl, latest.HtmlUrl, zip.Name);
|
||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
|
||||
}
|
||||
private static Octokit.Release getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
@@ -341,41 +362,6 @@ namespace AppScaffolding
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
#region migrate to v5.2.0
|
||||
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
|
||||
public static void migrate_to_v5_2_0__pre_config()
|
||||
{
|
||||
{
|
||||
var settingsKey = "DownloadsInProgressEnum";
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
|
||||
{
|
||||
UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
|
||||
UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
|
||||
}
|
||||
|
||||
{ // appsettings.json
|
||||
var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
|
||||
if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
|
||||
UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static string translatePath(string path)
|
||||
=> path switch
|
||||
{
|
||||
"AppDir" => @".\LibationFiles",
|
||||
"MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
|
||||
"UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
|
||||
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
|
||||
_ => path
|
||||
};
|
||||
#endregion
|
||||
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
@@ -422,5 +408,74 @@ namespace AppScaffolding
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrate_from_7_10_1(Configuration config)
|
||||
{
|
||||
var lastNigrationThres = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
||||
|
||||
if (lastNigrationThres) return;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
|
||||
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
|
||||
//and those with improperly identified or missing series. This does not solve cases
|
||||
//where individual episodes are in the db with a valid series link, but said series'
|
||||
//parents have not been imported into the database. For those cases, Libation will
|
||||
//attempt fixup by retrieving parents from the catalog endpoint
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
//This migration removes books and series with SERIES_ prefix that were created
|
||||
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
|
||||
string removeHackSeries = "delete " +
|
||||
"from series " +
|
||||
"where AudibleSeriesId like 'SERIES%'";
|
||||
|
||||
string removeHackBooks = "delete " +
|
||||
"from books " +
|
||||
"where AudibleProductId like 'SERIES%'";
|
||||
|
||||
//Detect series parents that were added to the database as books with ContentType.Episode,
|
||||
//and change them to ContentType.Parent
|
||||
string updateContentType =
|
||||
"UPDATE books " +
|
||||
"SET contenttype = 4 " +
|
||||
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
|
||||
"FROM books " +
|
||||
"INNER JOIN series " +
|
||||
"ON ( books.audibleproductid = " +
|
||||
"series.audibleseriesid) " +
|
||||
"WHERE books.contenttype = 2)";
|
||||
|
||||
//Then detect series parents that were added to the database as books with ContentType.Parent
|
||||
//but are missing a series link, and add the link (don't know how this happened)
|
||||
string addMissingSeriesLink =
|
||||
"INSERT INTO seriesbook " +
|
||||
"SELECT series.seriesid, " +
|
||||
"books.bookid, " +
|
||||
"'- 1' " +
|
||||
"FROM books " +
|
||||
"LEFT OUTER JOIN seriesbook " +
|
||||
"ON books.bookid = seriesbook.bookid " +
|
||||
"INNER JOIN series " +
|
||||
"ON books.audibleproductid = series.audibleseriesid " +
|
||||
"WHERE books.contenttype = 4 " +
|
||||
"AND seriesbook.seriesid IS NULL";
|
||||
|
||||
context.Database.ExecuteSqlRaw(removeHackSeries);
|
||||
context.Database.ExecuteSqlRaw(removeHackBooks);
|
||||
context.Database.ExecuteSqlRaw(updateContentType);
|
||||
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
|
||||
|
||||
LibraryCommands.SaveContext(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
|
||||
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,13 @@ namespace AppScaffolding
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
internal static class UNSAFE_MigrationHelper
|
||||
public static class UNSAFE_MigrationHelper
|
||||
{
|
||||
public static string SettingsDirectory
|
||||
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
|
||||
? null
|
||||
: value;
|
||||
|
||||
#region appsettings.json
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||
|
||||
@@ -87,19 +92,11 @@ namespace AppScaffolding
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Settings.json
|
||||
public const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
private const string SETTINGS_JSON = "Settings.json";
|
||||
|
||||
public static string SettingsJsonPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var success = APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value);
|
||||
return !success || value is null ? null : Path.Combine(value, SETTINGS_JSON);
|
||||
}
|
||||
}
|
||||
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
|
||||
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
|
||||
|
||||
public static bool Settings_TryGet(string key, out string value)
|
||||
@@ -267,5 +264,10 @@ namespace AppScaffolding
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region LibationContext.db
|
||||
public const string LIBATION_CONTEXT = "LibationContext.db";
|
||||
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
|
||||
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
6
Source/AppScaffolding/UpgradeProperties.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
|
||||
}
|
||||
@@ -12,10 +12,10 @@ namespace ApplicationServices
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,17 @@ namespace ApplicationServices
|
||||
ScanEnd += (_, __) => Scanning = false;
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return new();
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
@@ -83,6 +90,7 @@ namespace ApplicationServices
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +108,8 @@ namespace ApplicationServices
|
||||
{
|
||||
if (Scanning)
|
||||
return (0, 0);
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
@@ -118,6 +126,22 @@ namespace ApplicationServices
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
|
||||
|
||||
Log.Logger.Information("Begin scan for orphaned episode parents");
|
||||
var newParents = await findAndAddMissingParents(accounts);
|
||||
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
|
||||
|
||||
if (newParents >= 0)
|
||||
{
|
||||
//If any episodes are still orphaned, their series have been
|
||||
//removed from the catalog and we'll never be able to find them.
|
||||
|
||||
//only do this if findAndAddMissingParents returned >= 0. If it
|
||||
//returned < 0, an error happened and there's still a chance that
|
||||
//a future successful run will find missing parents.
|
||||
removedOrphanedEpisodes();
|
||||
}
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||
var newCount = await importIntoDbAsync(importItems);
|
||||
@@ -199,8 +223,8 @@ namespace ApplicationServices
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryBookImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
int qtyChanges = saveChanges(context);
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
int qtyChanges = SaveContext(context);
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
|
||||
// this is any changes at all to the database, not just new books
|
||||
@@ -211,7 +235,85 @@ namespace ApplicationServices
|
||||
return newCount;
|
||||
}
|
||||
|
||||
private static int saveChanges(LibationContext context)
|
||||
static void removedOrphanedEpisodes()
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
try
|
||||
{
|
||||
var orphanedEpisodes =
|
||||
context
|
||||
.GetLibrary_Flat_NoTracking(includeParents: true)
|
||||
.FindOrphanedEpisodes();
|
||||
|
||||
context.LibraryBooks.RemoveRange(orphanedEpisodes);
|
||||
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to remove orphaned episodes from the database");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<int> findAndAddMissingParents(Account[] accounts)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
|
||||
|
||||
try
|
||||
{
|
||||
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
|
||||
|
||||
if (!orphanedEpisodes.Any())
|
||||
return -1;
|
||||
|
||||
var orphanedSeries =
|
||||
orphanedEpisodes
|
||||
.SelectMany(lb => lb.Book.SeriesLink)
|
||||
.DistinctBy(s => s.Series.AudibleSeriesId)
|
||||
.ToList();
|
||||
|
||||
// The Catalog endpoint does not require authentication.
|
||||
var api = new ApiUnauthenticated(accounts[0].Locale);
|
||||
|
||||
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
|
||||
var items = await api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
List<ImportItem> newParentsImportItems = new();
|
||||
foreach (var sp in orphanedSeries)
|
||||
{
|
||||
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
|
||||
|
||||
if (seriesItem.Relationships is null)
|
||||
continue;
|
||||
|
||||
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
|
||||
|
||||
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
|
||||
seriesItem.Series = new AudibleApi.Common.Series[]
|
||||
{
|
||||
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
|
||||
};
|
||||
|
||||
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
|
||||
}
|
||||
|
||||
var newCoutn = new LibraryBookImporter(context)
|
||||
.Import(newParentsImportItems);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return newCoutn;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to scan for orphaned episode parents.");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static int SaveContext(LibationContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -219,7 +321,7 @@ namespace ApplicationServices
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
||||
{
|
||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culpret is the "WithExceptionDetails" serilog extension
|
||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
|
||||
|
||||
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
||||
|
||||
@@ -253,11 +355,7 @@ namespace ApplicationServices
|
||||
#endregion
|
||||
|
||||
// call this whenever books are added or removed from library
|
||||
private static void finalizeLibrarySizeChange()
|
||||
{
|
||||
SearchEngineCommands.FullReIndex();
|
||||
LibrarySizeChanged?.Invoke(null, null);
|
||||
}
|
||||
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
||||
|
||||
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
||||
public static event EventHandler LibrarySizeChanged;
|
||||
@@ -265,9 +363,47 @@ namespace ApplicationServices
|
||||
/// <summary>
|
||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler BookUserDefinedItemCommitted;
|
||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||
{
|
||||
book.UserDefinedItem.BookStatus = bookStatus;
|
||||
return UpdateUserDefinedItem(book);
|
||||
}
|
||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||
{
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
||||
return UpdateUserDefinedItem(book);
|
||||
}
|
||||
public static int UpdateBook(
|
||||
this Book book,
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null)
|
||||
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
|
||||
public static int UpdateBooks(
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
params Book[] books)
|
||||
{
|
||||
foreach (var book in books)
|
||||
{
|
||||
// blank tags are expected. null tags are not
|
||||
if (tags is not null && book.UserDefinedItem.Tags != tags)
|
||||
book.UserDefinedItem.Tags = tags;
|
||||
|
||||
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
|
||||
book.UserDefinedItem.BookStatus = bookStatus.Value;
|
||||
|
||||
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
|
||||
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
|
||||
}
|
||||
|
||||
return UpdateUserDefinedItem(books);
|
||||
}
|
||||
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
|
||||
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
|
||||
{
|
||||
@@ -283,23 +419,8 @@ namespace ApplicationServices
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges == 0)
|
||||
return 0;
|
||||
|
||||
// semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||
// I did not benchmark before choosing the number here
|
||||
if (qtyChanges > 15)
|
||||
SearchEngineCommands.FullReIndex();
|
||||
else
|
||||
{
|
||||
foreach (var book in books)
|
||||
{
|
||||
SearchEngineCommands.UpdateLiberatedStatus(book);
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
}
|
||||
}
|
||||
|
||||
BookUserDefinedItemCommitted?.Invoke(null, null);
|
||||
if (qtyChanges > 0)
|
||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
@@ -322,7 +443,14 @@ namespace ApplicationServices
|
||||
|
||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
||||
{
|
||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||
public bool HasPendingBooks => PendingBooks > 0;
|
||||
|
||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
||||
}
|
||||
public static LibraryStats GetCounts()
|
||||
{
|
||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
@@ -7,51 +9,99 @@ namespace ApplicationServices
|
||||
{
|
||||
public static class SearchEngineCommands
|
||||
{
|
||||
public static void FullReIndex(SearchEngine engine = null)
|
||||
{
|
||||
engine ??= new SearchEngine();
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
engine.CreateNewIndex(library);
|
||||
}
|
||||
|
||||
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
|
||||
#region Search
|
||||
public static SearchResultSet Search(string searchString) => performSafeQuery(e =>
|
||||
e.Search(searchString)
|
||||
);
|
||||
|
||||
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
private static T performSafeQuery<T>(Func<SearchEngine, T> func)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static event EventHandler SearchEngineUpdated;
|
||||
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
public static void UpdateBooks(IEnumerable<Book> books)
|
||||
{
|
||||
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||
// I did not benchmark before choosing the number here
|
||||
if (books.Count() > 15)
|
||||
FullReIndex();
|
||||
else
|
||||
{
|
||||
foreach (var book in books)
|
||||
{
|
||||
UpdateLiberatedStatus(book);
|
||||
UpdateBookTags(book);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(e =>
|
||||
fullReIndex(e)
|
||||
);
|
||||
|
||||
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
|
||||
internal static void UpdateLiberatedStatus(Book book) => performSafeCommand(e =>
|
||||
e.UpdateLiberatedStatus(book)
|
||||
);
|
||||
|
||||
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
|
||||
internal static void UpdateBookTags(Book book) => performSafeCommand(e =>
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
||||
);
|
||||
|
||||
private static void performSafeCommand(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
try
|
||||
{
|
||||
action(engine);
|
||||
update(action);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
FullReIndex(engine);
|
||||
action(engine);
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
}
|
||||
|
||||
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
|
||||
private static void update(Action<SearchEngine> action)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
if (action is null)
|
||||
return;
|
||||
|
||||
// support nesting incl recursion
|
||||
var prevIsUpdating = isUpdating;
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
isUpdating = true;
|
||||
|
||||
action(new SearchEngine());
|
||||
|
||||
if (!prevIsUpdating)
|
||||
SearchEngineUpdated?.Invoke(null, null);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
finally
|
||||
{
|
||||
FullReIndex(engine);
|
||||
return func(engine);
|
||||
isUpdating = prevIsUpdating;
|
||||
}
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine)
|
||||
{
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
engine.CreateNewIndex(library);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
@@ -118,31 +122,38 @@ namespace AudibleUtilities
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
||||
{
|
||||
var items = new List<Item>();
|
||||
#if DEBUG
|
||||
//// this will not work for multi accounts
|
||||
//var library_json = "library.json";
|
||||
//library_json = System.IO.Path.GetFullPath(library_json);
|
||||
//if (System.IO.File.Exists(library_json))
|
||||
//{
|
||||
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
||||
//}
|
||||
#endif
|
||||
|
||||
Serilog.Log.Logger.Debug("Begin initial library scan");
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
if (!items.Any())
|
||||
items = await Api.GetAllLibraryItemsAsync(libraryOptions);
|
||||
List<Task<List<Item>>> getChildEpisodesTasks = new();
|
||||
|
||||
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
|
||||
int count = 0, maxConcurrentEpisodeScans = 5;
|
||||
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
|
||||
|
||||
await manageEpisodesAsync(items, importEpisodes);
|
||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
|
||||
{
|
||||
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
||||
{
|
||||
//Get child episodes asynchronously and await all at the end
|
||||
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
|
||||
}
|
||||
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
||||
items.Add(item);
|
||||
|
||||
Serilog.Log.Logger.Debug("Episode scan complete");
|
||||
count++;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
|
||||
|
||||
//await and add all episodes from all parents
|
||||
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
|
||||
items.AddRange(epList);
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
||||
|
||||
#if DEBUG
|
||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||
#endif
|
||||
|
||||
var validators = new List<IValidator>();
|
||||
validators.AddRange(getValidators());
|
||||
foreach (var v in validators)
|
||||
@@ -156,56 +167,75 @@ namespace AudibleUtilities
|
||||
}
|
||||
|
||||
#region episodes and podcasts
|
||||
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
|
||||
|
||||
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
|
||||
{
|
||||
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
|
||||
await concurrencySemaphore.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// get parents
|
||||
var parents = items.Where(i => i.IsEpisodes).ToList();
|
||||
#if DEBUG
|
||||
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText("parents.json", parentsDebug);
|
||||
#endif
|
||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
||||
|
||||
if (!parents.Any())
|
||||
return;
|
||||
List<Item> children;
|
||||
|
||||
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
|
||||
|
||||
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
|
||||
items.RemoveAll(i => i.IsEpisodes);
|
||||
|
||||
if (importEpisodes)
|
||||
if (parent.IsEpisodes)
|
||||
{
|
||||
// add children
|
||||
var children = await getEpisodesAsync(parents);
|
||||
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
|
||||
items.AddRange(children);
|
||||
//The 'parent' is a single episode that was added to the library.
|
||||
//Get the episode's parent and add it to the database.
|
||||
|
||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
|
||||
|
||||
children = new() { parent };
|
||||
|
||||
var parentAsins = parent.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||
.Select(p => p.Asin);
|
||||
|
||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
||||
if (numSeriesParents != 1)
|
||||
{
|
||||
//There should only ever be 1 top-level parent per episode. If not, log
|
||||
//and throw so we can figure out what to do about those special cases.
|
||||
JsonSerializerSettings Settings = new()
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
Converters =
|
||||
{
|
||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
||||
},
|
||||
};
|
||||
var ex = new ApplicationException($"Found {numSeriesParents} parents for {parent.Asin}");
|
||||
Serilog.Log.Logger.Error(ex, $"Episode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
|
||||
throw ex;
|
||||
}
|
||||
|
||||
var realParent = seriesParents.Single(p => p.IsSeriesParent);
|
||||
realParent.PurchaseDate = parent.PurchaseDate;
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
|
||||
parent = realParent;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
|
||||
{
|
||||
var results = new List<Item>();
|
||||
|
||||
foreach (var parent in parents)
|
||||
{
|
||||
var children = await getEpisodeChildrenAsync(parent);
|
||||
|
||||
// actual individual episode, not the parent of a series.
|
||||
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
|
||||
if (!children.Any())
|
||||
else
|
||||
{
|
||||
results.Add(parent);
|
||||
continue;
|
||||
children = await getEpisodeChildrenAsync(parent);
|
||||
if (!children.Any())
|
||||
return new();
|
||||
}
|
||||
|
||||
//A series parent will always have exactly 1 Series
|
||||
parent.Series = new Series[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = "-1",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
@@ -217,25 +247,22 @@ namespace AudibleUtilities
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin).Sort.ToString(),
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
// overload (read: abuse) IsEpisodes flag
|
||||
child.Relationships = new Relationship[]
|
||||
{
|
||||
new Relationship
|
||||
{
|
||||
RelationshipToProduct = RelationshipToProduct.Child,
|
||||
RelationshipType = RelationshipType.Episode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
results.AddRange(children);
|
||||
}
|
||||
children.Add(parent);
|
||||
|
||||
return results;
|
||||
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
|
||||
|
||||
return children;
|
||||
}
|
||||
finally
|
||||
{
|
||||
concurrencySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
|
||||
@@ -261,8 +288,8 @@ namespace AudibleUtilities
|
||||
{
|
||||
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
#if DEBUG
|
||||
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
|
||||
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -277,7 +304,7 @@ namespace AudibleUtilities
|
||||
throw;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
|
||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
|
||||
// the service returned no results. probably indicates an error. stop running batches
|
||||
if (!childrenBatch.Any())
|
||||
break;
|
||||
@@ -295,7 +322,7 @@ namespace AudibleUtilities
|
||||
if (childrenIds.Count != results.Count)
|
||||
{
|
||||
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
|
||||
Serilog.Log.Logger.Error(ex, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
|
||||
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="2.8.3.1" />
|
||||
<PackageReference Include="AudibleApi" Version="4.2.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
199
Source/AudibleUtilities/Mkb79Auth.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
public partial class Mkb79Auth : IIdentityMaintainer
|
||||
{
|
||||
[JsonProperty("website_cookies")]
|
||||
private JObject _websiteCookies { get; set; }
|
||||
|
||||
[JsonProperty("adp_token")]
|
||||
public string AdpToken { get; private set; }
|
||||
|
||||
[JsonProperty("access_token")]
|
||||
public string AccessToken { get; private set; }
|
||||
|
||||
[JsonProperty("refresh_token")]
|
||||
public string RefreshToken { get; private set; }
|
||||
|
||||
[JsonProperty("device_private_key")]
|
||||
public string DevicePrivateKey { get; private set; }
|
||||
|
||||
[JsonProperty("store_authentication_cookie")]
|
||||
private JObject _storeAuthenticationCookie { get; set; }
|
||||
|
||||
[JsonProperty("device_info")]
|
||||
public DeviceInfo DeviceInfo { get; private set; }
|
||||
|
||||
[JsonProperty("customer_info")]
|
||||
public CustomerInfo CustomerInfo { get; private set; }
|
||||
|
||||
[JsonProperty("expires")]
|
||||
private double _expires { get; set; }
|
||||
|
||||
[JsonProperty("locale_code")]
|
||||
public string LocaleCode { get; private set; }
|
||||
|
||||
[JsonProperty("activation_bytes")]
|
||||
public string ActivationBytes { get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Dictionary<string, string> WebsiteCookies
|
||||
{
|
||||
get => _websiteCookies.ToObject<Dictionary<string, string>>();
|
||||
private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string StoreAuthenticationCookie
|
||||
{
|
||||
get => _storeAuthenticationCookie.ToObject<Dictionary<string, string>>()["cookie"];
|
||||
private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary<string, string>() { { "cookie", value } }, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime AccessTokenExpires
|
||||
{
|
||||
get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime;
|
||||
private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d;
|
||||
}
|
||||
|
||||
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
||||
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
|
||||
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
|
||||
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
||||
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
||||
|
||||
public Task<AccessToken> GetAccessTokenAsync()
|
||||
=> Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires));
|
||||
|
||||
public Task<AdpToken> GetAdpTokenAsync()
|
||||
=> Task.FromResult(new AdpToken(AdpToken));
|
||||
|
||||
public Task<PrivateKey> GetPrivateKeyAsync()
|
||||
=> Task.FromResult(new PrivateKey(DevicePrivateKey));
|
||||
}
|
||||
|
||||
public partial class CustomerInfo
|
||||
{
|
||||
[JsonProperty("account_pool")]
|
||||
public string AccountPool { get; set; }
|
||||
|
||||
[JsonProperty("user_id")]
|
||||
public string UserId { get; set; }
|
||||
|
||||
[JsonProperty("home_region")]
|
||||
public string HomeRegion { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("given_name")]
|
||||
public string GivenName { get; set; }
|
||||
}
|
||||
|
||||
public partial class DeviceInfo
|
||||
{
|
||||
[JsonProperty("device_name")]
|
||||
public string DeviceName { get; set; }
|
||||
|
||||
[JsonProperty("device_serial_number")]
|
||||
public string DeviceSerialNumber { get; set; }
|
||||
|
||||
[JsonProperty("device_type")]
|
||||
public string DeviceType { get; set; }
|
||||
}
|
||||
|
||||
public partial class Mkb79Auth
|
||||
{
|
||||
public static Mkb79Auth FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
|
||||
|
||||
public string ToJson()
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented);
|
||||
|
||||
public async Task<Account> ToAccountAsync()
|
||||
{
|
||||
var refreshToken = new RefreshToken(RefreshToken);
|
||||
|
||||
var authorize = new Authorize(Locale);
|
||||
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
|
||||
AccessToken = newToken.TokenValue;
|
||||
AccessTokenExpires = newToken.Expires;
|
||||
|
||||
var api = new Api(this);
|
||||
var email = await api.GetEmailAsync();
|
||||
var account = new Account(email)
|
||||
{
|
||||
DecryptKey = ActivationBytes,
|
||||
AccountName = $"{email} - {Locale.Name}",
|
||||
IdentityTokens = new Identity(Locale)
|
||||
};
|
||||
|
||||
account.IdentityTokens.Update(
|
||||
await GetPrivateKeyAsync(),
|
||||
await GetAdpTokenAsync(),
|
||||
await GetAccessTokenAsync(),
|
||||
refreshToken,
|
||||
WebsiteCookies.Select(c => new KeyValuePair<string, string>(c.Key, c.Value)),
|
||||
DeviceSerialNumber,
|
||||
DeviceType,
|
||||
AmazonAccountId,
|
||||
DeviceInfo.DeviceName,
|
||||
StoreAuthenticationCookie);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public static Mkb79Auth FromAccount(Account account)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue,
|
||||
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
|
||||
AdpToken = account.IdentityTokens.AdpToken.Value,
|
||||
CustomerInfo = new CustomerInfo
|
||||
{
|
||||
AccountPool = "Amazon",
|
||||
GivenName = string.Empty,
|
||||
HomeRegion = "NA",
|
||||
Name = string.Empty,
|
||||
UserId = account.IdentityTokens.AmazonAccountId
|
||||
},
|
||||
DeviceInfo = new DeviceInfo
|
||||
{
|
||||
DeviceName = account.IdentityTokens.DeviceName,
|
||||
DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber,
|
||||
DeviceType = account.IdentityTokens.DeviceType,
|
||||
},
|
||||
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
||||
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
||||
LocaleCode = account.Locale.CountryCode,
|
||||
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
||||
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
||||
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
|
||||
};
|
||||
}
|
||||
|
||||
public static class Serialize
|
||||
{
|
||||
public static string ToJson(this Mkb79Auth self)
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
internal static class Converter
|
||||
{
|
||||
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.5">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.6">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -29,7 +29,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<None Update="migrate.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -16,8 +16,14 @@ namespace DataLayer
|
||||
}
|
||||
}
|
||||
|
||||
// enum will be easier than bool to extend later
|
||||
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
|
||||
// enum will be easier than bool to extend later.
|
||||
public enum ContentType
|
||||
{
|
||||
Unknown = 0,
|
||||
Product = 1,
|
||||
Episode = 2,
|
||||
Parent = 4,
|
||||
}
|
||||
|
||||
public class Book
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace DataLayer
|
||||
public float StoryRating { get; private set; }
|
||||
|
||||
private Rating() { }
|
||||
internal Rating(float overallRating, float performanceRating, float storyRating)
|
||||
public Rating(float overallRating, float performanceRating, float storyRating)
|
||||
{
|
||||
OverallRating = overallRating;
|
||||
PerformanceRating = performanceRating;
|
||||
|
||||
@@ -35,5 +35,17 @@ namespace DataLayer
|
||||
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
|
||||
|
||||
public static bool IsProduct(this Book book)
|
||||
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
|
||||
|
||||
public static bool IsEpisodeChild(this Book book)
|
||||
=> book.ContentType is ContentType.Episode;
|
||||
|
||||
public static bool IsEpisodeParent(this Book book)
|
||||
=> book.ContentType is ContentType.Parent;
|
||||
public static bool HasLiberated(this Book book)
|
||||
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
|
||||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ namespace DataLayer
|
||||
// .GetLibrary()
|
||||
// .ToList();
|
||||
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false)
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibrary()
|
||||
.AsEnumerable()
|
||||
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
|
||||
.ToList();
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
@@ -40,5 +42,51 @@ namespace DataLayer
|
||||
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
||||
|
||||
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
|
||||
|
||||
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||
=> libraryBooks
|
||||
.Where(lb => lb.Book.IsEpisodeChild())
|
||||
.ExceptBy(
|
||||
libraryBooks
|
||||
.ParentedEpisodes()
|
||||
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
|
||||
|
||||
#nullable enable
|
||||
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> libraryBooks, LibraryBook seriesEpisode)
|
||||
{
|
||||
if (seriesEpisode.Book.SeriesLink is null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
//Parent books will always have exactly 1 SeriesBook due to how
|
||||
//they are imported in ApiExtended.getChildEpisodesAsync()
|
||||
return libraryBooks.FirstOrDefault(
|
||||
lb =>
|
||||
lb.Book.IsEpisodeParent() &&
|
||||
seriesEpisode.Book.SeriesLink.Any(
|
||||
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
|
||||
=> bookList
|
||||
.Where(
|
||||
lb =>
|
||||
lb.Book.IsEpisodeChild() &&
|
||||
lb.Book.SeriesLink?
|
||||
.Any(
|
||||
s =>
|
||||
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
|
||||
) == true
|
||||
).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"// this connection string is ONLY used for DataLayer's Migrations. this appsettings.json file is NOT used at all by application; it is overwritten": "",
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
|
||||
}
|
||||
}
|
||||
5
Source/DataLayer/migrate.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ namespace DtoImporterService
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
|
||||
var contentType = GetContentType(item);
|
||||
|
||||
// absence of authors is very rare, but possible
|
||||
if (!item.Authors?.Any() ?? true)
|
||||
@@ -101,7 +101,8 @@ namespace DtoImporterService
|
||||
// absence of categories is also possible
|
||||
|
||||
// CATEGORY HACK: only use the 1st 2 categories
|
||||
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
|
||||
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
|
||||
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
|
||||
var lastCategory
|
||||
= item.Categories.Length == 0 ? ""
|
||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
||||
@@ -184,5 +185,15 @@ namespace DtoImporterService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DataLayer.ContentType GetContentType(Item item)
|
||||
{
|
||||
if (item.IsEpisodes)
|
||||
return DataLayer.ContentType.Episode;
|
||||
else if (item.IsSeriesParent)
|
||||
return DataLayer.ContentType.Parent;
|
||||
else
|
||||
return DataLayer.ContentType.Product;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
using System;
|
||||
using LibationFileManager;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class AudioDecodable : Processable
|
||||
{
|
||||
public event EventHandler<Action<byte[]>> RequestCoverArt;
|
||||
public delegate byte[] RequestCoverArtHandler(object sender, EventArgs eventArgs);
|
||||
public event RequestCoverArtHandler RequestCoverArt;
|
||||
public event EventHandler<string> TitleDiscovered;
|
||||
public event EventHandler<string> AuthorsDiscovered;
|
||||
public event EventHandler<string> NarratorsDiscovered;
|
||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||
public abstract void Cancel();
|
||||
public abstract Task CancelAsync();
|
||||
|
||||
protected LameConfig GetLameOptions(Configuration config)
|
||||
{
|
||||
LameConfig lameConfig = new();
|
||||
lameConfig.Mode = MPEGMode.Mono;
|
||||
|
||||
if (config.LameTargetBitrate)
|
||||
{
|
||||
if (config.LameConstantBitrate)
|
||||
lameConfig.BitRate = config.LameBitrate;
|
||||
else
|
||||
{
|
||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||
lameConfig.VBR = VBRMode.ABR;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lameConfig.VBR = VBRMode.Default;
|
||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
return lameConfig;
|
||||
}
|
||||
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
||||
protected void OnTitleDiscovered(object _, string title)
|
||||
{
|
||||
@@ -32,10 +60,10 @@ namespace FileLiberator
|
||||
NarratorsDiscovered?.Invoke(this, narrators);
|
||||
}
|
||||
|
||||
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
|
||||
protected byte[] OnRequestCoverArt()
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
|
||||
RequestCoverArt?.Invoke(this, setCoverArtDel);
|
||||
return RequestCoverArt?.Invoke(this, new());
|
||||
}
|
||||
|
||||
protected void OnCoverImageDiscovered(byte[] coverImage)
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class AudioFileStorageExt
|
||||
{
|
||||
private class MultipartRenamer
|
||||
{
|
||||
private LibraryBook libraryBook { get; }
|
||||
public static class AudioFileStorageExt
|
||||
{
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// File path for where to move files into.
|
||||
/// Path: directory nested inside of Books directory
|
||||
/// File name: n/a
|
||||
/// </summary>
|
||||
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
{
|
||||
if (libraryBook.Book.IsEpisodeChild() && Configuration.Instance.SavePodcastsToParentFolder)
|
||||
{
|
||||
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
|
||||
if (series is not null)
|
||||
{
|
||||
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
||||
|
||||
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
|
||||
if (seriesParent is not null)
|
||||
{
|
||||
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
|
||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
|
||||
}
|
||||
return Templates.Folder.GetFilename(libraryBook.ToDto());
|
||||
}
|
||||
|
||||
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> new MultipartRenamer(libraryBook).MultipartFilename;
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// Path: in progress directory.
|
||||
/// File name: final file name.
|
||||
/// </summary>
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
||||
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// File path for where to move files into.
|
||||
/// Path: directory nested inside of Books directory
|
||||
/// File name: n/a
|
||||
/// </summary>
|
||||
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
|
||||
=> Templates.Folder.GetFilename(libraryBook.ToDto());
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// DownloadDecryptBook:
|
||||
/// Path: in progress directory.
|
||||
/// File name: final file name.
|
||||
/// </summary>
|
||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||
}
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
@@ -12,90 +12,93 @@ using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
public override string Name => "Convert to Mp3";
|
||||
private Mp4File m4bBook;
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
public override string Name => "Convert to Mp3";
|
||||
private Mp4File m4bBook;
|
||||
|
||||
private long fileSize;
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
private long fileSize;
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
|
||||
private bool cancelled = false;
|
||||
public override void Cancel()
|
||||
{
|
||||
m4bBook?.Cancel();
|
||||
cancelled = true;
|
||||
}
|
||||
public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask;
|
||||
|
||||
public static bool ValidateMp3(LibraryBook libraryBook)
|
||||
public static bool ValidateMp3(LibraryBook libraryBook)
|
||||
{
|
||||
var path = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
|
||||
}
|
||||
var paths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
|
||||
return paths.Any(path => path?.ToString()?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path)));
|
||||
}
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => ValidateMp3(libraryBook);
|
||||
public override bool Validate(LibraryBook libraryBook) => ValidateMp3(libraryBook);
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
|
||||
OnStreamingBegin($"Begin converting {libraryBook} to mp3");
|
||||
try
|
||||
{
|
||||
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
|
||||
|
||||
try
|
||||
{
|
||||
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
|
||||
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
foreach (var m4bPath in m4bPaths)
|
||||
{
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
||||
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
|
||||
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File));
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
var lameConfig = GetLameOptions(Configuration.Instance);
|
||||
var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
if (result == ConversionResult.Failed)
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
return new StatusHandler { "Conversion failed" };
|
||||
}
|
||||
else if (result == ConversionResult.Cancelled)
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
return new StatusHandler { "Cancelled" };
|
||||
}
|
||||
|
||||
if (result == ConversionResult.Failed)
|
||||
return new StatusHandler { "Conversion failed" };
|
||||
else if (result == ConversionResult.Cancelled)
|
||||
return new StatusHandler { "Cancelled" };
|
||||
else
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
}
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(fileSize * progressPercent),
|
||||
TotalBytesToReceive = fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(fileSize * progressPercent),
|
||||
TotalBytesToReceive = fileSize
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using ApplicationServices;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
@@ -13,252 +14,254 @@ using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||
|
||||
public override void Cancel() => abDownloader?.Cancel();
|
||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var entries = new List<FilePathCache.CacheEntry>();
|
||||
// these only work so minimally b/c CacheEntry is a record.
|
||||
// in case of parallel decrypts, only capture the ones for this book id.
|
||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Add(e);
|
||||
}
|
||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Remove(e);
|
||||
}
|
||||
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
|
||||
success = await downloadAudiobookAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
{
|
||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||
FileUtility.SaferDelete(tmpFile.Path);
|
||||
|
||||
return abDownloader?.IsCanceled == true ?
|
||||
new StatusHandler { "Cancelled" } :
|
||||
new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
|
||||
// moves new files from temp dir to final dest.
|
||||
// This could take a few seconds if moving hundreds of files.
|
||||
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
|
||||
// decrypt failed
|
||||
if (!movedAudioFile)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
if (Configuration.Instance.DownloadCoverArt)
|
||||
DownloadCoverArt(libraryBook);
|
||||
|
||||
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
|
||||
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnStreamingBegin($"Begin decrypting {libraryBook}");
|
||||
|
||||
try
|
||||
{
|
||||
var config = Configuration.Instance;
|
||||
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
var audiobookDlLic = BuildDownloadOptions(config, contentLic);
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, audiobookDlLic.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic);
|
||||
else
|
||||
{
|
||||
AaxcDownloadConvertBase converter
|
||||
= config.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
|
||||
outFileName, cacheDir, audiobookDlLic,
|
||||
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook))
|
||||
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic);
|
||||
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await Task.Run(abDownloader.Run);
|
||||
|
||||
return success;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
|
||||
}
|
||||
}
|
||||
|
||||
private static DownloadOptions BuildDownloadOptions(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.
|
||||
|
||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
||||
|
||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
|
||||
var audiobookDlLic = new DownloadOptions
|
||||
(
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
)
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
|
||||
long startMs = audiobookDlLic.TrimOutputToChapterLength ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
|
||||
audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
|
||||
|
||||
for (int i = 0; i < contentLic.ContentMetadata.ChapterInfo.Chapters.Length; i++)
|
||||
{
|
||||
var chapter = contentLic.ContentMetadata.ChapterInfo.Chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
if (i == 0)
|
||||
chapLenMs -= startMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == contentLic.ContentMetadata.ChapterInfo.Chapters.Length - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
audiobookDlLic.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
}
|
||||
|
||||
audiobookDlLic.LameConfig = new();
|
||||
audiobookDlLic.LameConfig.Mode = NAudio.Lame.MPEGMode.Mono;
|
||||
|
||||
if (config.LameTargetBitrate)
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var entries = new List<FilePathCache.CacheEntry>();
|
||||
// these only work so minimally b/c CacheEntry is a record.
|
||||
// in case of parallel decrypts, only capture the ones for this book id.
|
||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (config.LameConstantBitrate)
|
||||
audiobookDlLic.LameConfig.BitRate = config.LameBitrate;
|
||||
else
|
||||
{
|
||||
audiobookDlLic.LameConfig.ABRRateKbps = config.LameBitrate;
|
||||
audiobookDlLic.LameConfig.VBR = NAudio.Lame.VBRMode.ABR;
|
||||
audiobookDlLic.LameConfig.WriteVBRTag = true;
|
||||
}
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Add(e);
|
||||
}
|
||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Remove(e);
|
||||
}
|
||||
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
|
||||
success = await downloadAudiobookAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
{
|
||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||
FileUtility.SaferDelete(tmpFile.Path);
|
||||
|
||||
return abDownloader?.IsCanceled == true ?
|
||||
new StatusHandler { "Cancelled" } :
|
||||
new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
|
||||
// moves new files from temp dir to final dest.
|
||||
// This could take a few seconds if moving hundreds of files.
|
||||
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
|
||||
// decrypt failed
|
||||
if (!movedAudioFile)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
if (Configuration.Instance.DownloadCoverArt)
|
||||
DownloadCoverArt(libraryBook);
|
||||
|
||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var config = Configuration.Instance;
|
||||
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||
else
|
||||
{
|
||||
audiobookDlLic.LameConfig.VBR = NAudio.Lame.VBRMode.Default;
|
||||
audiobookDlLic.LameConfig.VBRQuality = config.LameVBRQuality;
|
||||
audiobookDlLic.LameConfig.WriteVBRTag = true;
|
||||
}
|
||||
AaxcDownloadConvertBase converter
|
||||
= config.SplitFilesByChapter ?
|
||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||
|
||||
return audiobookDlLic;
|
||||
}
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||
|
||||
private static void downloadValidation(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
// REAL WORK DONE HERE
|
||||
var success = await abDownloader.RunAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||
{
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
OnRequestCoverArt(abDownloader.SetCoverArt);
|
||||
}
|
||||
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.
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
||||
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
|
||||
var dlOptions = new DownloadOptions
|
||||
(
|
||||
libraryBook,
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
)
|
||||
{
|
||||
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,
|
||||
LameConfig = GetLameOptions(config)
|
||||
};
|
||||
|
||||
var chapters = getChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||
|
||||
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
||||
{
|
||||
long startMs = dlOptions.TrimOutputToChapterLength ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
|
||||
dlOptions.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
var chapter = chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
if (i == 0)
|
||||
chapLenMs -= startMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
}
|
||||
|
||||
return dlOptions;
|
||||
}
|
||||
|
||||
private List<AudibleApi.Common.Chapter> getChapters(IEnumerable<AudibleApi.Common.Chapter> chapters)
|
||||
{
|
||||
List<AudibleApi.Common.Chapter> chaps = new();
|
||||
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is not null)
|
||||
{
|
||||
var firstSub = new AudibleApi.Common.Chapter
|
||||
{
|
||||
Title = $"{c.Title}: {c.Chapters[0].Title}",
|
||||
StartOffsetMs = c.StartOffsetMs,
|
||||
StartOffsetSec = c.StartOffsetSec,
|
||||
LengthMs = c.LengthMs + c.Chapters[0].LengthMs
|
||||
};
|
||||
|
||||
chaps.Add(firstSub);
|
||||
|
||||
var children = getChapters(c.Chapters[1..]);
|
||||
|
||||
foreach (var child in children)
|
||||
child.Title = string.IsNullOrEmpty(c.Title) ? child.Title : $"{c.Title}: {child.Title}";
|
||||
|
||||
chaps.AddRange(children);
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
|
||||
private static void downloadValidation(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||
{
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||
}
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
|
||||
if (getFirstAudio() == default)
|
||||
return false;
|
||||
@@ -267,10 +270,10 @@ namespace FileLiberator
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
|
||||
// propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
@@ -283,33 +286,33 @@ namespace FileLiberator
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DownloadCoverArt(LibraryBook libraryBook)
|
||||
private void DownloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
try
|
||||
{
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
|
||||
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
|
||||
(libraryBook.Book.PictureId, PictureSize.Native) :
|
||||
(libraryBook.Book.PictureLarge, PictureSize.Native);
|
||||
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
|
||||
(libraryBook.Book.PictureId, PictureSize.Native) :
|
||||
(libraryBook.Book.PictureLarge, PictureSize.Native);
|
||||
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
|
||||
|
||||
if (picBytes.Length > 0)
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be
|
||||
//considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||
}
|
||||
}
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
|
||||
|
||||
if (picBytes.Length > 0)
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be
|
||||
//considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
// currently only used to download the .zip flies for upgrade
|
||||
public class DownloadFile : Streamable
|
||||
{
|
||||
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
|
||||
try
|
||||
{
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated("Upgrade", actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Source/FileLiberator/DownloadOptions.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using AaxDecrypter;
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using FileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadOptions : IDownloadOptions
|
||||
{
|
||||
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 ChapterInfo ChapterInfo { get; set; }
|
||||
public NAudio.Lame.LameConfig LameConfig { get; set; }
|
||||
public bool Downsample { get; set; }
|
||||
public bool MatchSourceBitrate { get; set; }
|
||||
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
||||
|
||||
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
||||
|
||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
||||
{
|
||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
|
||||
LibraryBookDto = LibraryBook.ToDto();
|
||||
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
@@ -29,8 +30,7 @@ namespace FileLiberator
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
|
||||
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
|
||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -57,27 +57,18 @@ namespace FileLiberator
|
||||
|
||||
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
{
|
||||
OnStreamingBegin(proposedDownloadFilePath);
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
try
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
var client = new HttpClient();
|
||||
|
||||
var client = new HttpClient();
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
|
||||
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
|
||||
OnStatusUpdate(actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnStreamingCompleted(proposedDownloadFilePath);
|
||||
}
|
||||
OnStatusUpdate(actualDownloadedFilePath);
|
||||
return actualDownloadedFilePath;
|
||||
}
|
||||
|
||||
private static StatusHandler verifyDownload(string actualDownloadedFilePath)
|
||||
|
||||
@@ -5,17 +5,22 @@ using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class Processable : Streamable
|
||||
public abstract class Processable
|
||||
{
|
||||
public abstract string Name { get; }
|
||||
public event EventHandler<LibraryBook> Begin;
|
||||
|
||||
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
|
||||
public event EventHandler<string> StatusUpdate;
|
||||
/// <summary>Fired when a file is successfully saved to disk</summary>
|
||||
public event EventHandler<(string id, string path)> FileCreated;
|
||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
||||
|
||||
public event EventHandler<LibraryBook> Completed;
|
||||
|
||||
@@ -29,7 +34,7 @@ namespace FileLiberator
|
||||
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
|
||||
=> library.Where(libraryBook =>
|
||||
Validate(libraryBook)
|
||||
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes)
|
||||
&& (!libraryBook.Book.IsEpisodeChild() || Configuration.Instance.DownloadEpisodes)
|
||||
);
|
||||
|
||||
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
|
||||
@@ -69,6 +74,23 @@ namespace FileLiberator
|
||||
StatusUpdate?.Invoke(this, statusUpdate);
|
||||
}
|
||||
|
||||
protected void OnFileCreated(LibraryBook libraryBook, string path)
|
||||
{
|
||||
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), libraryBook.Book.AudibleProductId, path });
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, path);
|
||||
FileCreated?.Invoke(this, (libraryBook.Book.AudibleProductId, path));
|
||||
}
|
||||
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress)
|
||||
=> OnStreamingProgressChanged(null, progress);
|
||||
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
|
||||
=> StreamingProgressChanged?.Invoke(this, progress);
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
|
||||
=> OnStreamingTimeRemaining(null, timeRemaining);
|
||||
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
|
||||
=> StreamingTimeRemaining?.Invoke(this, timeRemaining);
|
||||
|
||||
protected void OnCompleted(LibraryBook libraryBook)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
using Dinah.Core.Net.Http;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public abstract class Streamable
|
||||
{
|
||||
public event EventHandler<string> StreamingBegin;
|
||||
public event EventHandler<DownloadProgress> StreamingProgressChanged;
|
||||
public event EventHandler<TimeSpan> StreamingTimeRemaining;
|
||||
public event EventHandler<string> StreamingCompleted;
|
||||
/// <summary>Fired when a file is successfully saved to disk</summary>
|
||||
public event EventHandler<(string id, string path)> FileCreated;
|
||||
|
||||
protected void OnStreamingBegin(string filePath)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = filePath });
|
||||
StreamingBegin?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnStreamingProgressChanged(DownloadProgress progress) => OnStreamingProgressChanged(null, progress);
|
||||
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
|
||||
{
|
||||
StreamingProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
|
||||
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining) => OnStreamingTimeRemaining(null, timeRemaining);
|
||||
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
|
||||
{
|
||||
StreamingTimeRemaining?.Invoke(this, timeRemaining);
|
||||
}
|
||||
|
||||
protected void OnStreamingCompleted(string filePath)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = filePath });
|
||||
StreamingCompleted?.Invoke(this, filePath);
|
||||
}
|
||||
|
||||
protected void OnFileCreated(DataLayer.LibraryBook libraryBook, string path) => OnFileCreated(libraryBook.Book.AudibleProductId, path);
|
||||
protected void OnFileCreated(string id, string path)
|
||||
{
|
||||
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), id, path });
|
||||
LibationFileManager.FilePathCache.Insert(id, path);
|
||||
FileCreated?.Invoke(this, (id, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace FileManager
|
||||
/// </summary>
|
||||
public class BackgroundFileSystem
|
||||
{
|
||||
public string RootDirectory { get; private set; }
|
||||
public LongPath RootDirectory { get; private set; }
|
||||
public string SearchPattern { get; private set; }
|
||||
public SearchOption SearchOption { get; private set; }
|
||||
|
||||
@@ -21,9 +21,9 @@ namespace FileManager
|
||||
private Task backgroundScanner { get; set; }
|
||||
|
||||
private object fsCacheLocker { get; } = new();
|
||||
private List<string> fsCache { get; } = new();
|
||||
private List<LongPath> fsCache { get; } = new();
|
||||
|
||||
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||
public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions)
|
||||
{
|
||||
RootDirectory = rootDirectory;
|
||||
SearchPattern = searchPattern;
|
||||
@@ -32,12 +32,18 @@ namespace FileManager
|
||||
Init();
|
||||
}
|
||||
|
||||
public string FindFile(System.Text.RegularExpressions.Regex regex)
|
||||
public LongPath FindFile(System.Text.RegularExpressions.Regex regex)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
||||
}
|
||||
|
||||
public List<LongPath> FindFiles(System.Text.RegularExpressions.Regex regex)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
return fsCache.Where(s => regex.IsMatch(s)).ToList();
|
||||
}
|
||||
|
||||
public void RefreshFiles()
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
@@ -73,8 +79,13 @@ namespace FileManager
|
||||
//Stop raising events
|
||||
fileSystemWatcher?.Dispose();
|
||||
|
||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||
directoryChangesEvents?.CompleteAdding();
|
||||
try
|
||||
{
|
||||
//Calling CompleteAdding() will cause background scanner to terminate.
|
||||
directoryChangesEvents?.CompleteAdding();
|
||||
}
|
||||
// if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:(
|
||||
catch (ObjectDisposedException) { }
|
||||
|
||||
//Wait for background scanner to terminate before reinitializing.
|
||||
backgroundScanner?.Wait();
|
||||
@@ -124,28 +135,33 @@ namespace FileManager
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePath(string path)
|
||||
private void RemovePath(LongPath path)
|
||||
{
|
||||
var pathsToRemove = fsCache.Where(p => p.StartsWith(path)).ToArray();
|
||||
path = path.LongPathName;
|
||||
var pathsToRemove = fsCache.Where(p => ((string)p).StartsWith(path)).ToArray();
|
||||
|
||||
foreach (var p in pathsToRemove)
|
||||
fsCache.Remove(p);
|
||||
}
|
||||
|
||||
private void AddPath(string path)
|
||||
private void AddPath(LongPath path)
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
path = path.LongPathName;
|
||||
if (!File.Exists(path) && !Directory.Exists(path))
|
||||
return;
|
||||
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
||||
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
|
||||
else
|
||||
AddUniqueFile(path);
|
||||
}
|
||||
private void AddUniqueFiles(IEnumerable<string> newFiles)
|
||||
|
||||
private void AddUniqueFiles(IEnumerable<LongPath> newFiles)
|
||||
{
|
||||
foreach (var file in newFiles)
|
||||
AddUniqueFile(file);
|
||||
}
|
||||
private void AddUniqueFile(string newFile)
|
||||
|
||||
private void AddUniqueFile(LongPath newFile)
|
||||
{
|
||||
if (!fsCache.Contains(newFile))
|
||||
fsCache.Add(newFile);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="4.4.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="4.4.1.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,64 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using System.Text;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileNamingTemplate
|
||||
{
|
||||
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: <name></summary>
|
||||
public string Template { get; }
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileNamingTemplate : NamingTemplate
|
||||
{
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileNamingTemplate(string template) : base(template) { }
|
||||
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileNamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
/// <summary>Generate a valid path for this file or directory</summary>
|
||||
public LongPath GetFilePath(ReplacementCharacters replacements, bool returnFirstExisting = false)
|
||||
{
|
||||
string fileName =
|
||||
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
|
||||
FileUtility.RemoveLastCharacter(Template) :
|
||||
Template;
|
||||
|
||||
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary>
|
||||
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
|
||||
List<string> pathParts = new();
|
||||
|
||||
/// <summary>Convenience method</summary>
|
||||
public void AddParameterReplacement(string key, object value)
|
||||
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
|
||||
=> ParameterReplacements.Add(key, value);
|
||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
|
||||
|
||||
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
|
||||
public int? ParameterMaxSize { get; set; } = 50;
|
||||
while (!string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
var file = Path.GetFileName(fileName);
|
||||
|
||||
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
|
||||
public string IllegalCharacterReplacements { get; set; }
|
||||
if (Path.IsPathRooted(Template) && file == string.Empty)
|
||||
{
|
||||
pathParts.Add(fileName);
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
file = replaceFileName(file, paramReplacements);
|
||||
fileName = Path.GetDirectoryName(fileName);
|
||||
pathParts.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Generate a valid path for this file or directory</summary>
|
||||
public string GetFilePath(bool returnFirstExisting = false)
|
||||
{
|
||||
var filename = Template;
|
||||
pathParts.Reverse();
|
||||
|
||||
foreach (var r in ParameterReplacements)
|
||||
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
|
||||
return FileUtility.GetValidFilename(Path.Join(pathParts.ToArray()), replacements, returnFirstExisting);
|
||||
}
|
||||
|
||||
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements, returnFirstExisting);
|
||||
}
|
||||
private string replaceFileName(string filename, Dictionary<string,string> paramReplacements)
|
||||
{
|
||||
List<StringBuilder> filenameParts = new();
|
||||
//Build the filename in parts, replacing replacement parameters with
|
||||
//their values, and storing the parts in a list.
|
||||
while (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
int openIndex = filename.IndexOf('<');
|
||||
int closeIndex = filename.IndexOf('>');
|
||||
|
||||
private static string formatKey(string key)
|
||||
=> key
|
||||
.Replace("<", "")
|
||||
.Replace(">", "");
|
||||
if (openIndex == 0 && closeIndex > 0)
|
||||
{
|
||||
var key = filename[..(closeIndex + 1)];
|
||||
|
||||
private string formatValue(object value)
|
||||
{
|
||||
if (value is null)
|
||||
return "";
|
||||
if (paramReplacements.ContainsKey(key))
|
||||
filenameParts.Add(new StringBuilder(paramReplacements[key]));
|
||||
else
|
||||
filenameParts.Add(new StringBuilder(key));
|
||||
|
||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||
// Esp important for file templates.
|
||||
var val = value
|
||||
.ToString()
|
||||
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
|
||||
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
|
||||
return
|
||||
ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
|
||||
? val.Truncate(ParameterMaxSize.Value)
|
||||
: val;
|
||||
}
|
||||
}
|
||||
filename = filename[(closeIndex + 1)..];
|
||||
}
|
||||
else if (openIndex > 0 && closeIndex > openIndex)
|
||||
{
|
||||
var other = filename[..openIndex];
|
||||
filenameParts.Add(new StringBuilder(other));
|
||||
filename = filename[openIndex..];
|
||||
}
|
||||
else
|
||||
{
|
||||
filenameParts.Add(new StringBuilder(filename));
|
||||
filename = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
//Remove 1 character from the end of the longest filename part until
|
||||
//the total filename is less than max filename length
|
||||
while (filenameParts.Sum(p => p.Length) > LongPath.MaxFilenameLength)
|
||||
{
|
||||
int maxLength = filenameParts.Max(p => p.Length);
|
||||
var maxEntry = filenameParts.First(p => p.Length == maxLength);
|
||||
|
||||
maxEntry.Remove(maxLength - 1, 1);
|
||||
}
|
||||
return string.Join("", filenameParts);
|
||||
}
|
||||
|
||||
private string formatValue(object value, ReplacementCharacters replacements)
|
||||
{
|
||||
if (value is null)
|
||||
return "";
|
||||
|
||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||
// Esp important for file templates.
|
||||
return replacements.ReplaceInvalidFilenameChars(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,264 +9,230 @@ using Polly.Retry;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public static class FileUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// "txt" => ".txt"
|
||||
/// <br />".txt" => ".txt"
|
||||
/// <br />null or whitespace => ""
|
||||
/// </summary>
|
||||
public static string GetStandardizedExtension(string extension)
|
||||
=> string.IsNullOrWhiteSpace(extension)
|
||||
? (extension ?? "")?.Trim()
|
||||
: '.' + extension.Trim().Trim('.');
|
||||
public static class FileUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// "txt" => ".txt"
|
||||
/// <br />".txt" => ".txt"
|
||||
/// <br />null or whitespace => ""
|
||||
/// </summary>
|
||||
public static string GetStandardizedExtension(string extension)
|
||||
=> string.IsNullOrWhiteSpace(extension)
|
||||
? (extension ?? "")?.Trim()
|
||||
: '.' + extension.Trim().Trim('.');
|
||||
|
||||
/// <summary>
|
||||
/// Return position with correct number of leading zeros.
|
||||
/// <br />- 2 of 9 => "2"
|
||||
/// <br />- 2 of 90 => "02"
|
||||
/// <br />- 2 of 900 => "002"
|
||||
/// </summary>
|
||||
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
|
||||
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
|
||||
public static string GetSequenceFormatted(int position, int total)
|
||||
{
|
||||
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
|
||||
ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
|
||||
if (position > total)
|
||||
throw new ArgumentException($"{position} may not be greater than {total}");
|
||||
/// <summary>
|
||||
/// Return position with correct number of leading zeros.
|
||||
/// <br />- 2 of 9 => "2"
|
||||
/// <br />- 2 of 90 => "02"
|
||||
/// <br />- 2 of 900 => "002"
|
||||
/// </summary>
|
||||
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
|
||||
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
|
||||
public static string GetSequenceFormatted(int position, int total)
|
||||
{
|
||||
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
|
||||
ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
|
||||
if (position > total)
|
||||
throw new ArgumentException($"{position} may not be greater than {total}");
|
||||
|
||||
return position.ToString().PadLeft(total.ToString().Length, '0');
|
||||
}
|
||||
return position.ToString().PadLeft(total.ToString().Length, '0');
|
||||
}
|
||||
|
||||
private const int MAX_FILENAME_LENGTH = 255;
|
||||
private const int MAX_DIRECTORY_LENGTH = 247;
|
||||
|
||||
/// <summary>
|
||||
/// Ensure valid file name path:
|
||||
/// <br/>- remove invalid chars
|
||||
/// <br/>- ensure uniqueness
|
||||
/// <br/>- enforce max file length
|
||||
/// </summary>
|
||||
public static string GetValidFilename(string path, string illegalCharacterReplacements = "", bool returnFirstExisting = false)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
/// <summary>
|
||||
/// Ensure valid file name path:
|
||||
/// <br/>- remove invalid chars
|
||||
/// <br/>- ensure uniqueness
|
||||
/// <br/>- enforce max file length
|
||||
/// </summary>
|
||||
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, bool returnFirstExisting = false)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
// remove invalid chars
|
||||
path = GetSafePath(path, illegalCharacterReplacements);
|
||||
// remove invalid chars
|
||||
path = GetSafePath(path, replacements);
|
||||
|
||||
// ensure uniqueness and check lengths
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
dir = dir.Truncate(MAX_DIRECTORY_LENGTH);
|
||||
// ensure uniqueness and check lengths
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
dir = dir?.Truncate(LongPath.MaxDirectoryLength) ?? string.Empty;
|
||||
|
||||
var filename = Path.GetFileNameWithoutExtension(path);
|
||||
var fileStem = Path.Combine(dir, filename);
|
||||
var extension = Path.GetExtension(path);
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
var filename = Path.GetFileNameWithoutExtension(path).Truncate(LongPath.MaxFilenameLength - extension.Length);
|
||||
var fileStem = Path.Combine(dir, filename);
|
||||
|
||||
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
|
||||
|
||||
fullfilename = removeInvalidWhitespace(fullfilename);
|
||||
var fullfilename = fileStem.Truncate(LongPath.MaxPathLength - extension.Length) + extension;
|
||||
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename) && !returnFirstExisting)
|
||||
{
|
||||
var increm = $" ({++i})";
|
||||
fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension;
|
||||
}
|
||||
fullfilename = removeInvalidWhitespace(fullfilename);
|
||||
|
||||
return fullfilename;
|
||||
}
|
||||
var i = 0;
|
||||
while (File.Exists(fullfilename) && !returnFirstExisting)
|
||||
{
|
||||
var increm = $" ({++i})";
|
||||
fullfilename = fileStem.Truncate(LongPath.MaxPathLength - increm.Length - extension.Length) + increm + extension;
|
||||
}
|
||||
|
||||
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
/// <summary>Use with file name, not full path. Valid path charaters which are invalid file name characters will be replaced: ':', '\\', '/'</summary>
|
||||
public static string GetSafeFileName(string str, string illegalCharacterReplacements = "")
|
||||
=> string.Join(illegalCharacterReplacements ?? "", str.Split(Path.GetInvalidFileNameChars()));
|
||||
/// <summary>Use with full path, not file name. Valid path characters which are invalid file name characters will be retained: '\\', '/'</summary>
|
||||
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
|
||||
/// <summary>Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/'</summary>
|
||||
public static string GetSafePath(string path, string illegalCharacterReplacements = "")
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||
var pathNoPrefix = path.PathWithoutPrefix;
|
||||
|
||||
path = replaceInvalidChars(path, illegalCharacterReplacements);
|
||||
path = standardizeSlashes(path);
|
||||
path = replaceColons(path, illegalCharacterReplacements);
|
||||
path = removeDoubleSlashes(path);
|
||||
pathNoPrefix = replacements.ReplaceInvalidPathChars(pathNoPrefix);
|
||||
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
|
||||
|
||||
return path;
|
||||
}
|
||||
return pathNoPrefix;
|
||||
}
|
||||
|
||||
private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] {
|
||||
'*', '?',
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
||||
// In live code, Path.GetInvalidPathChars() does not include them
|
||||
'"', '<', '>'
|
||||
}).ToArray();
|
||||
private static string replaceInvalidChars(string path, string illegalCharacterReplacements)
|
||||
=> string.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars));
|
||||
private static string removeDoubleSlashes(string path)
|
||||
{
|
||||
if (path.Length < 2)
|
||||
return path;
|
||||
|
||||
private static string standardizeSlashes(string path)
|
||||
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
|
||||
|
||||
private static string replaceColons(string path, string illegalCharacterReplacements)
|
||||
{
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < path.Length; i++)
|
||||
{
|
||||
var c = path[i];
|
||||
if (i >= 2 && c == ':')
|
||||
builder.Append(illegalCharacterReplacements);
|
||||
else
|
||||
builder.Append(c);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
var remainder = path[1..];
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (remainder.Contains(dblSeparator))
|
||||
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
|
||||
private static string removeDoubleSlashes(string path)
|
||||
{
|
||||
if (path.Length < 2)
|
||||
return path;
|
||||
return path[0] + remainder;
|
||||
}
|
||||
|
||||
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
|
||||
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
|
||||
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
|
||||
|
||||
var remainder = path[1..];
|
||||
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (remainder.Contains(dblSeparator))
|
||||
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
|
||||
/// <summary>no part of the path may begin or end in whitespace</summary>
|
||||
private static string removeInvalidWhitespace(string fullfilename)
|
||||
{
|
||||
// no whitespace at beginning or end
|
||||
// replace whitespace around path slashes
|
||||
// regex (with space added for clarity)
|
||||
// \s* \\ \s* => \
|
||||
// no ending dots. beginning dots are valid
|
||||
|
||||
return path[0] + remainder;
|
||||
}
|
||||
// regex is easier by ending with separator
|
||||
fullfilename += Path.DirectorySeparatorChar;
|
||||
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
|
||||
// take separator back off
|
||||
fullfilename = RemoveLastCharacter(fullfilename);
|
||||
|
||||
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
|
||||
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
|
||||
fullfilename = removeDoubleSlashes(fullfilename);
|
||||
return fullfilename;
|
||||
}
|
||||
|
||||
/// <summary>no part of the path may begin or end in whitespace</summary>
|
||||
private static string removeInvalidWhitespace(string fullfilename)
|
||||
{
|
||||
// no whitespace at beginning or end
|
||||
// replace whitespace around path slashes
|
||||
// regex (with space added for clarity)
|
||||
// \s* \\ \s* => \
|
||||
// no ending dots. beginning dots are valid
|
||||
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
||||
|
||||
// regex is easier by ending with separator
|
||||
fullfilename += Path.DirectorySeparatorChar;
|
||||
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
|
||||
// take seperator back off
|
||||
fullfilename = RemoveLastCharacter(fullfilename);
|
||||
/// <summary>
|
||||
/// Move file.
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||
/// <br/>- Perform <see cref="SaferMove"/>
|
||||
/// <br/>- Return valid path
|
||||
/// </summary>
|
||||
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements)
|
||||
{
|
||||
destination = GetValidFilename(destination, replacements);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
fullfilename = removeDoubleSlashes(fullfilename);
|
||||
return fullfilename;
|
||||
}
|
||||
private static int maxRetryAttempts { get; } = 3;
|
||||
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
|
||||
private static RetryPolicy retryPolicy { get; } =
|
||||
Policy
|
||||
.Handle<Exception>()
|
||||
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
|
||||
|
||||
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
||||
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferDelete(LongPath source)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move file.
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||
/// <br/>- Perform <see cref="SaferMove"/>
|
||||
/// <br/>- Return valid path
|
||||
/// </summary>
|
||||
public static string SaferMoveToValidPath(string source, string destination)
|
||||
{
|
||||
destination = GetValidFilename(destination);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
}
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
private static int maxRetryAttempts { get; } = 3;
|
||||
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
|
||||
private static RetryPolicy retryPolicy { get; } =
|
||||
Policy
|
||||
.Handle<Exception>()
|
||||
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
|
||||
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferMove(LongPath source, LongPath destination)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferDelete(string source)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
SaferDelete(destination);
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
|
||||
File.Delete(source);
|
||||
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
var dir = Path.GetDirectoryName(destination);
|
||||
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
|
||||
public static void SaferMove(string source, string destination)
|
||||
=> retryPolicy.Execute(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
|
||||
return;
|
||||
}
|
||||
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
SaferDelete(destination);
|
||||
/// <summary>
|
||||
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
var foundFiles = Enumerable.Empty<LongPath>();
|
||||
|
||||
var dir = Path.GetDirectoryName(destination);
|
||||
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
||||
Directory.CreateDirectory(dir);
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEnumerable <LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
|
||||
// Add files in subdirectories recursively to the list
|
||||
foreach (string dir in subDirs)
|
||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
|
||||
File.Move(source, destination);
|
||||
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
|
||||
throw;
|
||||
}
|
||||
});
|
||||
try
|
||||
{
|
||||
// Add files from the current directory
|
||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
/// <summary>
|
||||
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<string> SaferEnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
var foundFiles = Enumerable.Empty<string>();
|
||||
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEnumerable<string> subDirs = Directory.EnumerateDirectories(path);
|
||||
// Add files in subdirectories recursively to the list
|
||||
foreach (string dir in subDirs)
|
||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Add files from the current directory
|
||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
}
|
||||
return foundFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
120
Source/FileManager/LongPath.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class LongPath
|
||||
{
|
||||
//https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
|
||||
|
||||
public const int MaxDirectoryLength = MaxPathLength - 13;
|
||||
public const int MaxPathLength = short.MaxValue;
|
||||
public const int MaxFilenameLength = 255;
|
||||
|
||||
private const int MAX_PATH = 260;
|
||||
private const string LONG_PATH_PREFIX = "\\\\?\\";
|
||||
|
||||
public string Path { get; init; }
|
||||
public override string ToString() => Path;
|
||||
|
||||
public static implicit operator LongPath(string path)
|
||||
{
|
||||
if (path is null) return null;
|
||||
|
||||
//File I/O functions in the Windows API convert "/" to "\" as part of converting
|
||||
//the name to an NT-style name, except when using the "\\?\" prefix
|
||||
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
|
||||
|
||||
if (path.StartsWith(LONG_PATH_PREFIX))
|
||||
return new LongPath { Path = path };
|
||||
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith("UNC\\"))
|
||||
return new LongPath { Path = LONG_PATH_PREFIX + path };
|
||||
else if (path.StartsWith("\\\\"))
|
||||
//The "\\?\" prefix can also be used with paths constructed according to the
|
||||
//universal naming convention (UNC). To specify such a path using UNC, use
|
||||
//the "\\?\UNC\" prefix.
|
||||
return new LongPath { Path = LONG_PATH_PREFIX + "UNC\\" + path.Substring(2) };
|
||||
else
|
||||
{
|
||||
//These prefixes are not used as part of the path itself. They indicate that
|
||||
//the path should be passed to the system with minimal modification, which
|
||||
//means that you cannot use forward slashes to represent path separators, or
|
||||
//a period to represent the current directory, or double dots to represent the
|
||||
//parent directory. Because you cannot use the "\\?\" prefix with a relative
|
||||
//path, relative paths are always limited to a total of MAX_PATH characters.
|
||||
if (path.Length > MAX_PATH)
|
||||
throw new System.IO.PathTooLongException();
|
||||
return new LongPath { Path = path };
|
||||
}
|
||||
}
|
||||
|
||||
public static implicit operator string(LongPath path) => path?.Path;
|
||||
|
||||
[JsonIgnore]
|
||||
public string ShortPathName
|
||||
{
|
||||
get
|
||||
{
|
||||
//Short Path names are useful for navigating to the file in windows explorer,
|
||||
//which will not recognize paths longer than MAX_PATH. Short path names are not
|
||||
//always enabled on every volume. So to check if a volume enables short path
|
||||
//names (aka 8dot3 names), run the following command from an elevated command
|
||||
//prompt:
|
||||
//
|
||||
// fsutil 8dot3name query c:
|
||||
//
|
||||
//It will say:
|
||||
//
|
||||
// "Based on the above settings, 8dot3 name creation is [enabled/disabled] on c:"
|
||||
//
|
||||
//To enable short names on a volume on the system, run the following command
|
||||
//from an elevated command prompt:
|
||||
//
|
||||
// fsutil 8dot3name set c: 0
|
||||
//
|
||||
//or for all volumes on the system:
|
||||
//
|
||||
// fsutil 8dot3name set 0
|
||||
//
|
||||
//Note that after enabling 8dot3 names on a volume, they will only be available
|
||||
//for newly-created entries in ther file system. Existing entries made while
|
||||
//8dot3 names were disabled will not be reachable by short paths.
|
||||
|
||||
if (Path is null) return null;
|
||||
|
||||
StringBuilder shortPathBuffer = new(MaxPathLength);
|
||||
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
|
||||
return shortPathBuffer.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string LongPathName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Path is null) return null;
|
||||
|
||||
StringBuilder longPathBuffer = new(MaxPathLength);
|
||||
GetLongPathName(Path, longPathBuffer, MaxPathLength);
|
||||
return longPathBuffer.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string PathWithoutPrefix
|
||||
=> Path?.StartsWith(LONG_PATH_PREFIX) == true ?
|
||||
Path.Remove(0, LONG_PATH_PREFIX.Length) :
|
||||
Path;
|
||||
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer);
|
||||
}
|
||||
}
|
||||
20
Source/FileManager/MetadataNamingTemplate.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class MetadataNamingTemplate : NamingTemplate
|
||||
{
|
||||
public MetadataNamingTemplate(string template) : base(template) { }
|
||||
|
||||
public string GetTagContents()
|
||||
{
|
||||
var tagValue = Template;
|
||||
|
||||
foreach (var r in ParameterReplacements)
|
||||
tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? "");
|
||||
|
||||
return tagValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Source/FileManager/NamingTemplate.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class NamingTemplate
|
||||
{
|
||||
/// <summary>Proposed full name. May contain optional html-styled template tags. Eg: <name></summary>
|
||||
public string Template { get; }
|
||||
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
|
||||
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary>
|
||||
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
|
||||
|
||||
/// <summary>Convenience method</summary>
|
||||
public void AddParameterReplacement(string key, object value)
|
||||
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
|
||||
=> ParameterReplacements.Add(key, value);
|
||||
|
||||
protected static string formatKey(string key)
|
||||
=> key
|
||||
.Replace("<", "")
|
||||
.Replace(">", "");
|
||||
}
|
||||
}
|
||||
271
Source/FileManager/ReplacementCharacters.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
public class Replacement
|
||||
{
|
||||
public const int FIXED_COUNT = 6;
|
||||
|
||||
internal const char QUOTE_MARK = '"';
|
||||
[JsonIgnore] public bool Mandatory { get; internal set; }
|
||||
[JsonProperty] public char CharacterToReplace { get; private set; }
|
||||
[JsonProperty] public string ReplacementString { get; private set; }
|
||||
[JsonProperty] public string Description { get; private set; }
|
||||
public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})";
|
||||
|
||||
public Replacement(char charToReplace, string replacementString, string description)
|
||||
{
|
||||
CharacterToReplace = charToReplace;
|
||||
ReplacementString = replacementString;
|
||||
Description = description;
|
||||
}
|
||||
private Replacement(char charToReplace, string replacementString, string description, bool mandatory)
|
||||
: this(charToReplace, replacementString, description)
|
||||
{
|
||||
Mandatory = mandatory;
|
||||
}
|
||||
|
||||
public void Update(char charToReplace, string replacementString, string description)
|
||||
{
|
||||
ReplacementString = replacementString;
|
||||
|
||||
if (!Mandatory)
|
||||
{
|
||||
CharacterToReplace = charToReplace;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
|
||||
public static Replacement OtherInvalid(string replacement) => new(default, replacement, "All other invalid characters", true);
|
||||
public static Replacement FilenameForwardSlash(string replacement) => new('/', replacement, "Forward Slash (Filename Only)", true);
|
||||
public static Replacement FilenameBackSlash(string replacement) => new('\\', replacement, "Back Slash (Filename Only)", true);
|
||||
public static Replacement OpenQuote(string replacement) => new('"', replacement, "Open Quote", true);
|
||||
public static Replacement CloseQuote(string replacement) => new('"', replacement, "Close Quote", true);
|
||||
public static Replacement OtherQuote(string replacement) => new('"', replacement, "Other Quote", true);
|
||||
public static Replacement Colon(string replacement) => new(':', replacement, "Colon");
|
||||
public static Replacement Asterisk(string replacement) => new('*', replacement, "Asterisk");
|
||||
public static Replacement QuestionMark(string replacement) => new('?', replacement, "Question Mark");
|
||||
public static Replacement OpenAngleBracket(string replacement) => new('<', replacement, "Open Angle Bracket");
|
||||
public static Replacement CloseAngleBracket(string replacement) => new('>', replacement, "Close Angle Bracket");
|
||||
public static Replacement Pipe(string replacement) => new('|', replacement, "Vertical Line");
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ReplacementCharactersConverter))]
|
||||
public class ReplacementCharacters
|
||||
{
|
||||
public static readonly ReplacementCharacters Default = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
{
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("∕"),
|
||||
Replacement.FilenameBackSlash(""),
|
||||
Replacement.OpenQuote("“"),
|
||||
Replacement.CloseQuote("”"),
|
||||
Replacement.OtherQuote("""),
|
||||
Replacement.OpenAngleBracket("<"),
|
||||
Replacement.CloseAngleBracket(">"),
|
||||
Replacement.Colon("꞉"),
|
||||
Replacement.Asterisk("✱"),
|
||||
Replacement.QuestionMark("?"),
|
||||
Replacement.Pipe("⏐"),
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ReplacementCharacters LoFiDefault = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
{
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("_"),
|
||||
Replacement.OpenQuote("'"),
|
||||
Replacement.CloseQuote("'"),
|
||||
Replacement.OtherQuote("'"),
|
||||
Replacement.OpenAngleBracket("{"),
|
||||
Replacement.CloseAngleBracket("}"),
|
||||
Replacement.Colon("-"),
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ReplacementCharacters Barebones = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
{
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("_"),
|
||||
Replacement.OpenQuote("_"),
|
||||
Replacement.CloseQuote("_"),
|
||||
Replacement.OtherQuote("_"),
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly char[] invalidChars = Path.GetInvalidPathChars().Union(new[] {
|
||||
'*', '?', ':',
|
||||
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
||||
// In live code, Path.GetInvalidPathChars() does not include them
|
||||
'"', '<', '>'
|
||||
}).ToArray();
|
||||
|
||||
public IReadOnlyList<Replacement> Replacements { get; init; }
|
||||
private string DefaultReplacement => Replacements[0].ReplacementString;
|
||||
private Replacement ForwardSlash => Replacements[1];
|
||||
private Replacement BackSlash => Replacements[2];
|
||||
private string OpenQuote => Replacements[3].ReplacementString;
|
||||
private string CloseQuote => Replacements[4].ReplacementString;
|
||||
private string OtherQuote => Replacements[5].ReplacementString;
|
||||
|
||||
private string GetFilenameCharReplacement(char toReplace, char preceding, char succeding)
|
||||
{
|
||||
if (toReplace == ForwardSlash.CharacterToReplace)
|
||||
return ForwardSlash.ReplacementString;
|
||||
else if (toReplace == BackSlash.CharacterToReplace)
|
||||
return BackSlash.ReplacementString;
|
||||
else return GetPathCharReplacement(toReplace, preceding, succeding);
|
||||
}
|
||||
private string GetPathCharReplacement(char toReplace, char preceding, char succeding)
|
||||
{
|
||||
if (toReplace == Replacement.QUOTE_MARK)
|
||||
{
|
||||
if (preceding == default ||
|
||||
(preceding != default
|
||||
&& !char.IsLetter(preceding)
|
||||
&& !char.IsNumber(preceding)
|
||||
&& (char.IsLetter(succeding) || char.IsNumber(succeding))
|
||||
)
|
||||
)
|
||||
return OpenQuote;
|
||||
else if (succeding == default ||
|
||||
(succeding != default
|
||||
&& !char.IsLetter(succeding)
|
||||
&& !char.IsNumber(succeding)
|
||||
&& (char.IsLetter(preceding) || char.IsNumber(preceding))
|
||||
)
|
||||
)
|
||||
return CloseQuote;
|
||||
else
|
||||
return OtherQuote;
|
||||
}
|
||||
|
||||
for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++)
|
||||
{
|
||||
var r = Replacements[i];
|
||||
if (r.CharacterToReplace == toReplace)
|
||||
return r.ReplacementString;
|
||||
}
|
||||
return DefaultReplacement;
|
||||
}
|
||||
|
||||
|
||||
public static bool ContainsInvalid(string path)
|
||||
=> path.Any(c => invalidChars.Contains(c));
|
||||
|
||||
public string ReplaceInvalidFilenameChars(string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName)) return string.Empty;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < fileName.Length; i++)
|
||||
{
|
||||
var c = fileName[i];
|
||||
|
||||
if (invalidChars.Contains(c) || c == ForwardSlash.CharacterToReplace || c == BackSlash.CharacterToReplace)
|
||||
{
|
||||
char preceding = i > 0 ? fileName[i - 1] : default;
|
||||
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
|
||||
builder.Append(GetFilenameCharReplacement(c, preceding, succeeding));
|
||||
}
|
||||
else
|
||||
builder.Append(c);
|
||||
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public string ReplaceInvalidPathChars(string pathStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pathStr)) return string.Empty;
|
||||
|
||||
// replace all colons except within the first 2 chars
|
||||
var builder = new System.Text.StringBuilder();
|
||||
for (var i = 0; i < pathStr.Length; i++)
|
||||
{
|
||||
var c = pathStr[i];
|
||||
|
||||
if (!invalidChars.Contains(c) || (c == ':' && i == 1 && Path.IsPathRooted(pathStr)))
|
||||
builder.Append(c);
|
||||
else
|
||||
{
|
||||
char preceding = i > 0 ? pathStr[i - 1] : default;
|
||||
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
||||
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
||||
}
|
||||
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
#region JSON Converter
|
||||
internal class ReplacementCharactersConverter : JsonConverter
|
||||
{
|
||||
public override bool CanConvert(Type objectType)
|
||||
=> objectType == typeof(ReplacementCharacters);
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
var jObj = JObject.Load(reader);
|
||||
var replaceArr = jObj[nameof(Replacement)];
|
||||
IReadOnlyList<Replacement> dict = replaceArr
|
||||
.ToObject<Replacement[]>().ToList();
|
||||
|
||||
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
||||
//If not, reset to default.
|
||||
|
||||
var default0 = Replacement.OtherInvalid("");
|
||||
var default1 = Replacement.FilenameForwardSlash("");
|
||||
var default2 = Replacement.FilenameBackSlash("");
|
||||
var default3 = Replacement.OpenQuote("");
|
||||
var default4 = Replacement.CloseQuote("");
|
||||
var default5 = Replacement.OtherQuote("");
|
||||
|
||||
if (dict.Count < Replacement.FIXED_COUNT ||
|
||||
dict[0].CharacterToReplace != default0.CharacterToReplace || dict[0].Description != default0.Description ||
|
||||
dict[1].CharacterToReplace != default1.CharacterToReplace || dict[1].Description != default1.Description ||
|
||||
dict[2].CharacterToReplace != default2.CharacterToReplace || dict[2].Description != default2.Description ||
|
||||
dict[3].CharacterToReplace != default3.CharacterToReplace || dict[3].Description != default3.Description ||
|
||||
dict[4].CharacterToReplace != default4.CharacterToReplace || dict[4].Description != default4.Description ||
|
||||
dict[5].CharacterToReplace != default5.CharacterToReplace || dict[5].Description != default5.Description ||
|
||||
dict.Any(r => ReplacementCharacters.ContainsInvalid(r.ReplacementString))
|
||||
)
|
||||
{
|
||||
dict = ReplacementCharacters.Default.Replacements;
|
||||
}
|
||||
//First FIXED_COUNT are mandatory
|
||||
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
||||
dict[i].Mandatory = true;
|
||||
|
||||
return new ReplacementCharacters { Replacements = dict };
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
ReplacementCharacters replacements = (ReplacementCharacters)value;
|
||||
|
||||
var propertyNames = replacements.Replacements
|
||||
.Select(c => JObject.FromObject(c)).ToList();
|
||||
|
||||
var prop = new JProperty(nameof(Replacement), new JArray(propertyNames));
|
||||
|
||||
var obj = new JObject();
|
||||
obj.AddFirst(prop);
|
||||
obj.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
18
Source/Hangover/Form1.CLI.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using AppScaffolding;
|
||||
|
||||
namespace Hangover
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private void Load_cliTab()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Source/Hangover/Form1.Database.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Hangover
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private string dbFile;
|
||||
|
||||
private void Load_databaseTab()
|
||||
{
|
||||
dbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
if (dbFile is null)
|
||||
{
|
||||
databaseFileLbl.Text = $"Database file not found";
|
||||
return;
|
||||
}
|
||||
|
||||
databaseFileLbl.Text = $"Database file: {UNSAFE_MigrationHelper.DatabaseFile ?? "not found"}";
|
||||
}
|
||||
|
||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
return;
|
||||
}
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
ensureBackup();
|
||||
|
||||
sqlResultsTb.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var sql = sqlTb.Text.Trim();
|
||||
|
||||
#region // explanation
|
||||
// Routing statements to non-query is a convenience.
|
||||
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
|
||||
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
|
||||
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
|
||||
// -- line 1 is a comment
|
||||
// delete from foo
|
||||
#endregion
|
||||
var lower = sql.ToLower();
|
||||
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
|
||||
nonQuery(sql);
|
||||
else
|
||||
query(sql);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sqlResultsTb.Text = $"{ex.Message}\r\n{ex.StackTrace}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleteUnneededBackups();
|
||||
}
|
||||
}
|
||||
|
||||
private string dbBackup;
|
||||
private DateTime dbFileLastModified;
|
||||
private void ensureBackup()
|
||||
{
|
||||
if (dbBackup is not null)
|
||||
return;
|
||||
|
||||
dbFileLastModified = File.GetLastWriteTimeUtc(dbFile);
|
||||
|
||||
dbBackup
|
||||
= Path.ChangeExtension(dbFile, "").TrimEnd('.')
|
||||
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
||||
+ Path.GetExtension(dbFile);
|
||||
File.Copy(dbFile, dbBackup);
|
||||
}
|
||||
|
||||
private void deleteUnneededBackups()
|
||||
{
|
||||
var newLastModified = File.GetLastWriteTimeUtc(dbFile);
|
||||
if (dbFileLastModified == newLastModified)
|
||||
{
|
||||
File.Delete(dbBackup);
|
||||
dbBackup = null;
|
||||
}
|
||||
}
|
||||
|
||||
void query(string sql)
|
||||
{
|
||||
// ef doesn't support truly generic queries. have to drop down to ado.net
|
||||
using var context = DbContexts.GetContext();
|
||||
using var conn = context.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
var reader = cmd.ExecuteReader();
|
||||
var results = 0;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var lines = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
results++;
|
||||
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
builder.Append(reader.GetValue(i) + "\t");
|
||||
builder.AppendLine();
|
||||
|
||||
lines++;
|
||||
if (lines % 10 == 0)
|
||||
{
|
||||
sqlResultsTb.AppendText(builder.ToString());
|
||||
builder.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
sqlResultsTb.AppendText(builder.ToString());
|
||||
builder.Clear();
|
||||
|
||||
if (results == 0)
|
||||
sqlResultsTb.Text = "[no results]";
|
||||
else
|
||||
{
|
||||
sqlResultsTb.AppendText($"\r\n{results} result");
|
||||
if (results != 1) sqlResultsTb.AppendText("s");
|
||||
}
|
||||
}
|
||||
|
||||
void nonQuery(string sql)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var results = context.Database.ExecuteSqlRaw(sql);
|
||||
|
||||
sqlResultsTb.AppendText($"{results} record");
|
||||
if (results != 1) sqlResultsTb.AppendText("s");
|
||||
sqlResultsTb.AppendText(" affected");
|
||||
}
|
||||
}
|
||||
}
|
||||
158
Source/Hangover/Form1.Designer.cs
generated
Normal file
@@ -0,0 +1,158 @@
|
||||
namespace Hangover
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
|
||||
this.tabControl1 = new System.Windows.Forms.TabControl();
|
||||
this.databaseTab = new System.Windows.Forms.TabPage();
|
||||
this.sqlExecuteBtn = new System.Windows.Forms.Button();
|
||||
this.sqlResultsTb = new System.Windows.Forms.TextBox();
|
||||
this.sqlTb = new System.Windows.Forms.TextBox();
|
||||
this.sqlLbl = new System.Windows.Forms.Label();
|
||||
this.databaseFileLbl = new System.Windows.Forms.Label();
|
||||
this.cliTab = new System.Windows.Forms.TabPage();
|
||||
this.tabControl1.SuspendLayout();
|
||||
this.databaseTab.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// tabControl1
|
||||
//
|
||||
this.tabControl1.Controls.Add(this.databaseTab);
|
||||
this.tabControl1.Controls.Add(this.cliTab);
|
||||
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
this.tabControl1.Name = "tabControl1";
|
||||
this.tabControl1.SelectedIndex = 0;
|
||||
this.tabControl1.Size = new System.Drawing.Size(800, 450);
|
||||
this.tabControl1.TabIndex = 0;
|
||||
//
|
||||
// databaseTab
|
||||
//
|
||||
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
|
||||
this.databaseTab.Controls.Add(this.sqlResultsTb);
|
||||
this.databaseTab.Controls.Add(this.sqlTb);
|
||||
this.databaseTab.Controls.Add(this.sqlLbl);
|
||||
this.databaseTab.Controls.Add(this.databaseFileLbl);
|
||||
this.databaseTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.databaseTab.Name = "databaseTab";
|
||||
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.databaseTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.databaseTab.TabIndex = 0;
|
||||
this.databaseTab.Text = "Database";
|
||||
this.databaseTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// sqlExecuteBtn
|
||||
//
|
||||
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
|
||||
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
|
||||
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.sqlExecuteBtn.TabIndex = 3;
|
||||
this.sqlExecuteBtn.Text = "Execute";
|
||||
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
|
||||
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
|
||||
//
|
||||
// sqlResultsTb
|
||||
//
|
||||
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
|
||||
this.sqlResultsTb.Multiline = true;
|
||||
this.sqlResultsTb.Name = "sqlResultsTb";
|
||||
this.sqlResultsTb.ReadOnly = true;
|
||||
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
|
||||
this.sqlResultsTb.TabIndex = 4;
|
||||
//
|
||||
// sqlTb
|
||||
//
|
||||
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlTb.Location = new System.Drawing.Point(8, 48);
|
||||
this.sqlTb.Multiline = true;
|
||||
this.sqlTb.Name = "sqlTb";
|
||||
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlTb.Size = new System.Drawing.Size(778, 99);
|
||||
this.sqlTb.TabIndex = 2;
|
||||
//
|
||||
// sqlLbl
|
||||
//
|
||||
this.sqlLbl.AutoSize = true;
|
||||
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
|
||||
this.sqlLbl.Name = "sqlLbl";
|
||||
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
|
||||
this.sqlLbl.TabIndex = 1;
|
||||
this.sqlLbl.Text = "SQL (database command)";
|
||||
//
|
||||
// databaseFileLbl
|
||||
//
|
||||
this.databaseFileLbl.AutoSize = true;
|
||||
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
|
||||
this.databaseFileLbl.Name = "databaseFileLbl";
|
||||
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
|
||||
this.databaseFileLbl.TabIndex = 0;
|
||||
this.databaseFileLbl.Text = "Database file: ";
|
||||
//
|
||||
// cliTab
|
||||
//
|
||||
this.cliTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.cliTab.Name = "cliTab";
|
||||
this.cliTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.cliTab.TabIndex = 1;
|
||||
this.cliTab.Text = "Command Line Interface";
|
||||
this.cliTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
this.Controls.Add(this.tabControl1);
|
||||
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
this.Name = "Form1";
|
||||
this.Text = "Hangover: Libation debug and recovery tool";
|
||||
this.tabControl1.ResumeLayout(false);
|
||||
this.databaseTab.ResumeLayout(false);
|
||||
this.databaseTab.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private TabControl tabControl1;
|
||||
private TabPage databaseTab;
|
||||
private Label databaseFileLbl;
|
||||
private TextBox sqlResultsTb;
|
||||
private TextBox sqlTb;
|
||||
private Label sqlLbl;
|
||||
private Button sqlExecuteBtn;
|
||||
private TabPage cliTab;
|
||||
}
|
||||
}
|
||||
16
Source/Hangover/Form1.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Hangover
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
||||
|
||||
Load_databaseTab();
|
||||
Load_cliTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
2328
Source/Hangover/Form1.resx
Normal file
46
Source/Hangover/Hangover.csproj
Normal file
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
When LibationWinForms and Hangover output to the same dir, Hangover must build before LibationWinForms
|
||||
|
||||
VS > rt-clk solution > Properties
|
||||
left: Project Dependencies
|
||||
top: Projects: LibationWinForms
|
||||
bottom: manually check Hangover
|
||||
|
||||
edit debug and release output paths
|
||||
-->
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\LibationWinForms\bin\Release</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Form1.*.cs">
|
||||
<DependentUpon>Form1.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
17
Source/Hangover/Program.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Hangover
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
// To customize application configuration such as set high DPI settings or default font,
|
||||
// see https://aka.ms/applicationconfiguration.
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 498 B |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
Source/Hangover/hangover.ico
Normal file
|
After Width: | Height: | Size: 133 KiB |
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,847 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Downloads, decrypts and repackages content from Hoopla
|
||||
|
||||
.DESCRIPTION
|
||||
Uses a HooplaDigital.com account to download DRM-free copies of ebooks, comics,
|
||||
and/or audiobooks available on the platform. Content that is not already borrowed
|
||||
on the account will be borrowed if slots are available. Content that is not borrowed
|
||||
cannot be downloaded.
|
||||
|
||||
* E-Books are downloaded to epub files (most) or cbz (rare, picture books).
|
||||
* Comic books are downloaded to cbz files.
|
||||
* Audiobooks are downloaded to m4a files. (single file, and very little metadata available
|
||||
from Hoopla, such as chapters)
|
||||
|
||||
.PARAMETER Credential
|
||||
Credential to use for logging into Hoopla site.
|
||||
(Cannot be used with Username and Password parameters)
|
||||
|
||||
.PARAMETER Username
|
||||
Username to use for logging into Hoopla site.
|
||||
(Cannot be used with Credential parameter)
|
||||
|
||||
.PARAMETER Password
|
||||
Password to use for logging into Hoopla site.
|
||||
(Cannot be used with Credential parameter)
|
||||
|
||||
.PARAMETER TitleId
|
||||
Specifies one or more title IDs of content to download.
|
||||
|
||||
.PARAMETER OutputFolder
|
||||
Sets the output folder for downloaded content. Defaults to current directory.
|
||||
|
||||
.PARAMETER PatronId
|
||||
Override default patron id for Hoopla. (This is rarely required as most user accounts are only tied
|
||||
to a single patron).
|
||||
|
||||
.PARAMETER EpubZipBin
|
||||
Specifies path to epubzip binary. Else look for one beside script, or in system path.
|
||||
|
||||
.PARAMETER FfmpegBin
|
||||
Specifies path to ffmpeg binary. Else look for one beside script, or in system path.
|
||||
|
||||
.PARAMETER KeepDecryptedData
|
||||
If set, don't delete the intermediary data after decryption, before final output file.
|
||||
For ebooks, this is xml, images, and the manifest. For comics, it is images. For audiobooks,
|
||||
it is mp4 ts files. This is typically only useful for development or troubleshooting.
|
||||
|
||||
.PARAMETER KeepEncryptedData
|
||||
If set, don't delete the encrypted data as downloaded from Hoopla's servers. This is typically
|
||||
only useful for development or troubleshooting.
|
||||
|
||||
.PARAMETER AllBorrowed
|
||||
This parameter is deprecated. If TitleId is not set, it is implied that all borrowed titles will
|
||||
be downloaded.
|
||||
|
||||
.PARAMETER AudioBookForceSingleFile
|
||||
If set, leave audiobook as single file, as if chapter data is not present.
|
||||
|
||||
.EXAMPLE
|
||||
.\Invoke-HooplaDownload.ps1 123456
|
||||
Downloads Hoopla content with title id 123456
|
||||
|
||||
.NOTES
|
||||
Author: kabutops728 - My Anonamouse
|
||||
Version: 2.9
|
||||
#>
|
||||
|
||||
[CmdletBinding(DefaultParameterSetName='CredentialSingleTitle')]
|
||||
param(
|
||||
[int64[]]
|
||||
$TitleId,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='CredentialSingleTitle')]
|
||||
[Management.Automation.PSCredential]
|
||||
$Credential,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
|
||||
[string]
|
||||
$Username,
|
||||
|
||||
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
|
||||
[string]
|
||||
$Password,
|
||||
|
||||
[ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Container})]
|
||||
[string]$OutputFolder = $PSScriptRoot,
|
||||
|
||||
[int64]$PatronId,
|
||||
|
||||
[string]$EpubZipBin,
|
||||
|
||||
[string]$FfmpegBin,
|
||||
|
||||
[switch]$KeepDecryptedData,
|
||||
|
||||
[switch]$KeepEncryptedData,
|
||||
|
||||
[switch]$AudioBookForceSingleFile,
|
||||
|
||||
# Deprecated
|
||||
[switch]$AllBorrowed
|
||||
)
|
||||
|
||||
$USER_AGENT = 'Hoopla Android/4.27'
|
||||
|
||||
$HEADERS = @{
|
||||
'app' = 'ANDROID'
|
||||
'app-version' = '4.27.1'
|
||||
'device-module' = 'KFKAWI'
|
||||
'device-version' = ''
|
||||
'hoopla-verson' = '4.27.1'
|
||||
'kids-mode' = 'false'
|
||||
'os' = 'ANDROID'
|
||||
'os-version' = '6.0.1'
|
||||
'ws-api' = '2.1'
|
||||
'Host' = 'hoopla-ws.hoopladigital.com'
|
||||
}
|
||||
|
||||
$URL_HOOPLA_WS_BASE = 'https://hoopla-ws.hoopladigital.com'
|
||||
$URL_HOOPLA_LIC_BASE = 'https://hoopla-license2.hoopladigital.com'
|
||||
|
||||
$COMIC_IMAGE_EXTS = @('.jpg','.png','.jpeg','.gif','.bmp','.tif','.tiff')
|
||||
|
||||
enum HooplaKind
|
||||
{
|
||||
EBOOK = 5
|
||||
MUSIC = 6
|
||||
MOVIE = 7
|
||||
AUDIOBOOK = 8
|
||||
TELEVISION = 9
|
||||
COMIC = 10
|
||||
}
|
||||
|
||||
$SUPPORTED_KINDS = @([HooplaKind]::EBOOK, [HooplaKind]::COMIC, [HooplaKind]::AUDIOBOOK)
|
||||
|
||||
Function Connect-Hoopla
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][Management.Automation.PSCredential]$Credential
|
||||
)
|
||||
|
||||
$username = $Credential.UserName
|
||||
$password = $Credential.GetNetworkCredential().Password
|
||||
|
||||
$res = Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/tokens" -Method Post -Headers $HEADERS -UserAgent $USER_AGENT -Body @{username = $username; password = $password}
|
||||
|
||||
if ($res.tokenStatus -ne 'SUCCESS')
|
||||
{
|
||||
throw $res.message
|
||||
}
|
||||
|
||||
$res.token
|
||||
}
|
||||
|
||||
Function Get-HooplaUsers
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaTitleInfo
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$TitleId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/v2/titles/$TitleId" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaBorrowsRemaining
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrows-remaining" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-HooplaBorrowedTitles
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/borrowed-titles" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Invoke-HooplaBorrow
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserId,
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$TitleId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrowed-titles/$TitleId" -Method Post -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Invoke-HooplaZipDownload
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$CircId,
|
||||
[Parameter(Mandatory)][ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Leaf})][string]$OutFile
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
$res = Invoke-WebRequest -Uri "$URL_HOOPLA_WS_BASE/patrons/downloads/$CircId/url" -Method Get -Headers $h -UserAgent $USER_AGENT -UseBasicParsing
|
||||
|
||||
if ($PSVersionTable.PSVersion.Major -ge 6)
|
||||
{
|
||||
Invoke-WebRequest -Uri $res.Headers['Location'][0] -Method Get -UseBasicParsing -OutFile $OutFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke-WebRequest -Uri $res.Headers['Location'] -Method Get -UseBasicParsing -OutFile $OutFile
|
||||
}
|
||||
}
|
||||
|
||||
Function Get-HooplaKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$PatronId,
|
||||
[Parameter(Mandatory)][string]$Token,
|
||||
[Parameter(Mandatory)][int64]$CircId
|
||||
)
|
||||
|
||||
$h = $HEADERS.Clone()
|
||||
$h['Authorization'] = "Bearer $Token"
|
||||
$h['patron-id'] = $PatronId
|
||||
|
||||
Invoke-RestMethod -Uri "$URL_HOOPLA_LIC_BASE/downloads/$CircId/key" -Method Get -Headers $h -UserAgent $USER_AGENT
|
||||
}
|
||||
|
||||
Function Get-FileKeyKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][int64]$CircId,
|
||||
[Parameter(Mandatory)][DateTime]$Due,
|
||||
[Parameter(Mandatory)][int64]$PatronId
|
||||
)
|
||||
|
||||
$combined = '{0:yyyyMMddHHmmss}:{1}:{2}' -f $Due, $PatronId, $CircId
|
||||
|
||||
[Security.Cryptography.HashAlgorithm]::Create('SHA1').ComputeHash([Text.Encoding]::UTF8.GetBytes($combined)) | Select-Object -First 16
|
||||
}
|
||||
|
||||
Function Decrypt-FileKey
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][byte[]]$FileKeyEnc,
|
||||
[Parameter(Mandatory)][byte[]]$FileKeyKey
|
||||
)
|
||||
|
||||
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
|
||||
$aesManaged.Mode = [Security.Cryptography.CipherMode]::ECB
|
||||
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aesManaged.BlockSize = 128
|
||||
$aesManaged.KeySize = 128
|
||||
$aesManaged.Key = $FileKeyKey
|
||||
|
||||
$decryptor = $aesManaged.CreateDecryptor();
|
||||
|
||||
$unencryptedData = $decryptor.TransformFinalBlock($FileKeyEnc, 0, $FileKeyEnc.Length);
|
||||
$aesManaged.Dispose()
|
||||
|
||||
$unencryptedData
|
||||
}
|
||||
|
||||
Function Decrypt-File
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][byte[]]$FileKey,
|
||||
[Parameter(Mandatory)][string]$MediaKey,
|
||||
[Parameter(Mandatory)][string]$InputFileName,
|
||||
[Parameter(Mandatory)][string]$OutputFileName
|
||||
)
|
||||
|
||||
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
|
||||
$aesManaged.Mode = [Security.Cryptography.CipherMode]::CBC
|
||||
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
|
||||
$aesManaged.BlockSize = 128
|
||||
$aesManaged.KeySize = 256
|
||||
$aesManaged.Key = $FileKey
|
||||
$aesManaged.IV = [Text.Encoding]::UTF8.GetBytes($MediaKey) | Select-Object -First 16
|
||||
|
||||
|
||||
$fileStreamReader = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $InputFileName, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
|
||||
$fileStreamWriter = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $OutputFileName, ([IO.FileMode]::Create)
|
||||
|
||||
$FileStreamReader.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
|
||||
|
||||
$decryptor = $aesManaged.CreateDecryptor()
|
||||
$cryptoStream = New-Object -TypeName 'System.Security.Cryptography.CryptoStream' -ArgumentList $fileStreamWriter, $decryptor, ([Security.Cryptography.CryptoStreamMode]::Write)
|
||||
$fileStreamReader.CopyTo($cryptoStream)
|
||||
|
||||
$cryptoStream.FlushFinalBlock()
|
||||
$cryptoStream.Close()
|
||||
$fileStreamReader.Close()
|
||||
$fileStreamWriter.Close()
|
||||
|
||||
$aesManaged.Dispose()
|
||||
}
|
||||
|
||||
Function Test-Mp4
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('LiteralPath')]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $Path, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
|
||||
$fileReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList $fileStream -ErrorAction Stop
|
||||
$head = $fileReader.ReadBytes(8)
|
||||
|
||||
$fileReader.Dispose()
|
||||
$fileStream.Dispose()
|
||||
|
||||
return [Text.Encoding]::ASCII.GetString(($head | Select-Object -Skip 4)) -eq 'ftyp'
|
||||
}
|
||||
|
||||
Function Remove-InvalidFileNameChars
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory,Position=0,
|
||||
ValueFromPipeline=$true,
|
||||
ValueFromPipelineByPropertyName=$true)]
|
||||
[String]$Name
|
||||
)
|
||||
|
||||
$invalidChars = [IO.Path]::GetInvalidFileNameChars() -join ''
|
||||
$re = "[{0}]" -f [RegEx]::Escape($invalidChars)
|
||||
$Name -replace $re, '_'
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToEpub
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder
|
||||
)
|
||||
|
||||
$container = [xml](Get-Content -LiteralPath (Join-Path -Path $InputFolder -ChildPath 'META-INF\container.xml') -Raw)
|
||||
$rootFile = $container.container.rootfiles.rootfile | Select-Object -ExpandProperty Full-Path
|
||||
$contentFile = (Join-Path -Path $InputFolder -ChildPath $rootFile).Trim()
|
||||
$contentRoot = Get-Item -LiteralPath $contentFile | Select-Object -ExpandProperty Directory
|
||||
$content = [xml](Get-Content -LiteralPath $contentFile)
|
||||
|
||||
$fileList = $content.package.manifest.item | Select-Object -ExpandProperty href | ForEach-Object -Process { (Join-Path -Path $contentRoot -ChildPath ([Web.HttpUtility]::UrlDecode($_))).Trim() }
|
||||
$fileList += $contentFile
|
||||
$fileList = $fileList | Sort-Object -Unique
|
||||
|
||||
$title = $content.package.metadata.title | Select-Object -First 1
|
||||
if ($title.GetType() -ne [String])
|
||||
{
|
||||
$title = $content.package.metadata.title | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
|
||||
}
|
||||
|
||||
$author = $content.package.metadata.creator | Select-Object -First 1
|
||||
if ($author.GetType() -ne [String])
|
||||
{
|
||||
$author = $content.package.metadata.creator | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
|
||||
}
|
||||
|
||||
# Usually, content root is a subfolder of the input folder. But sometimes, they are the same. Make sure we declutter the input root if they differ, and always keep the mimetype file.
|
||||
$mimeTypeFile = Join-Path -Path $InputFolder -ChildPath 'mimetype'
|
||||
|
||||
$extra = @(Get-ChildItem -LiteralPath $contentRoot -File -Recurse | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) })
|
||||
$extra += Get-ChildItem -LiteralPath $InputFolder -File | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) }
|
||||
|
||||
$extra = $extra | Sort-Object -Property FullName -Unique
|
||||
$extra | Remove-Item
|
||||
|
||||
$containerXmlFolder = Join-Path -Path $contentRoot.FullName -ChildPath 'META-INF'
|
||||
$containerXmlPath = Join-Path -Path $containerXmlFolder -ChildPath 'container.xml'
|
||||
if (!(Test-Path -LiteralPath $containerXmlPath -PathType Leaf))
|
||||
{
|
||||
New-Item -Path $containerXmlFolder -ItemType Directory -Force | Out-Null
|
||||
$xml = @"
|
||||
<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
|
||||
"@
|
||||
$xml | Out-File -LiteralPath $containerXmlPath -Encoding ascii
|
||||
}
|
||||
|
||||
$finalFile = ('{0} - {1}.epub' -f $title, $author) | Remove-InvalidFileNameChars
|
||||
|
||||
Push-Location
|
||||
Set-Location -LiteralPath $InputFolder
|
||||
|
||||
$finalFileFullPath = (Join-Path -Path $OutFolder -ChildPath $finalFile)
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $EpubZipBin $finalFileFullPath
|
||||
}
|
||||
else
|
||||
{
|
||||
& $EpubZipBin $finalFileFullPath >$null 2>&1
|
||||
}
|
||||
Pop-Location
|
||||
|
||||
Get-Item -LiteralPath $finalFileFullPath
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToCbz
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder,
|
||||
[Parameter(Mandatory)][string]$Name
|
||||
)
|
||||
|
||||
$fileName = $Name | Remove-InvalidFileNameChars
|
||||
$tempOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.zip"
|
||||
$finalOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.cbz"
|
||||
|
||||
Compress-Archive -Path (
|
||||
Get-ChildItem -LiteralPath $InputFolder | Where-Object -FilterScript { $_.Extension -in $COMIC_IMAGE_EXTS } | Select-Object -ExpandProperty FullName
|
||||
) -CompressionLevel Fastest -DestinationPath $tempOutFile
|
||||
|
||||
Rename-Item -LiteralPath $tempOutFile -NewName $finalOutFile
|
||||
|
||||
Get-Item $finalOutFile
|
||||
}
|
||||
|
||||
Function Convert-HooplaDecryptedToM4a
|
||||
{
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InputFolder,
|
||||
[Parameter(Mandatory)][string]$OutFolder,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Title,
|
||||
[Parameter(Mandatory)][string]$Author,
|
||||
[Parameter(Mandatory)][int]$Year,
|
||||
[string]$Subtitle,
|
||||
[object]$ChapterData
|
||||
|
||||
)
|
||||
|
||||
if ($Author)
|
||||
{
|
||||
$baseFileName = ('{0} - {1}' -f $Name, $Author) | Remove-InvalidFileNameChars
|
||||
}
|
||||
else
|
||||
{
|
||||
$baseFileName = $Name | Remove-InvalidFileNameChars
|
||||
}
|
||||
|
||||
$finalOutFile = Join-Path -Path $OutFolder -ChildPath ('{0}.m4a' -f $baseFileName)
|
||||
$inFile = Get-ChildItem -LiteralPath $InputFolder -Filter '*.m3u8' | Select-Object -First 1 | Select-Object -ExpandProperty FullName
|
||||
|
||||
Push-Location
|
||||
Set-Location $InputFolder
|
||||
$ffArgs = @(
|
||||
'-y',
|
||||
'-i', $infile,
|
||||
'-metadata', ('title="{0}"' -f $Title),
|
||||
'-metadata', ('year="{0}"' -f $Year),
|
||||
'-metadata', ('author="{0}"' -f $Author),
|
||||
'-metadata', 'genre="Audiobook"'
|
||||
)
|
||||
|
||||
if ($Subtitle)
|
||||
{
|
||||
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
|
||||
}
|
||||
|
||||
$ffArgs += @(
|
||||
'-c:a', 'copy',
|
||||
$finalOutFile
|
||||
)
|
||||
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $FfmpegBin @ffArgs
|
||||
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile
|
||||
}
|
||||
else
|
||||
{
|
||||
& $FfmpegBin @ffArgs >$null 2>&1
|
||||
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile >$null 2>&1
|
||||
}
|
||||
|
||||
if ($ChapterData -and (!$AudioBookForceSingleFile))
|
||||
{
|
||||
$outDir = New-Item -Path (Join-Path -Path $OutFolder -ChildPath $baseFileName) -ItemType Directory
|
||||
$chapterCount = $ChapterData | Select-Object -ExpandProperty chapter | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum
|
||||
$ChapterData | ForEach-Object -Process {
|
||||
$ffArgs = @(
|
||||
'-y',
|
||||
'-i', $finalOutFile,
|
||||
'-ss', $_.start,
|
||||
'-t', $_.duration,
|
||||
'-metadata', ('title="{0}"' -f $_.title),
|
||||
'-metadata', ('album="{0}"' -f $Title),
|
||||
'-metadata', ('year="{0}"' -f $Year),
|
||||
'-metadata', ('author="{0}"' -f $Author),
|
||||
'-metadata', 'genre="Audiobook"'
|
||||
'-metadata', ('track={0}/{1}' -f $_.ordinal, $chapterCount)
|
||||
)
|
||||
|
||||
if ($Subtitle)
|
||||
{
|
||||
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
|
||||
}
|
||||
|
||||
$ffArgs += @(
|
||||
'-c', 'copy',
|
||||
(Join-Path -Path $outDir.FullName -ChildPath ('{0} - {1} - {2}.m4a' -f $baseFileName, $_.ordinal, ($_.title | Remove-InvalidFileNameChars)))
|
||||
)
|
||||
|
||||
if ($VerbosePreference -eq 'Continue')
|
||||
{
|
||||
& $FfmpegBin @ffArgs
|
||||
}
|
||||
else
|
||||
{
|
||||
& $FfmpegBin @ffArgs >$null 2>&1
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $finalOutFile
|
||||
$finalOutFile = $outDir
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
|
||||
Get-Item $finalOutFile
|
||||
}
|
||||
|
||||
if (!$Credential)
|
||||
{
|
||||
$ssPassword = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $Username, $ssPassword
|
||||
}
|
||||
|
||||
if ((!$AllBorrowed) -and ($null -eq $TitleId))
|
||||
{
|
||||
Write-Warning 'No -TitleId specified. All currently-borrowed titles will be downloaded.'
|
||||
$AllBorrowed = $true
|
||||
}
|
||||
|
||||
$AppExtension = ''
|
||||
if (($PSVersionTable.PSVersion -lt '6.0') -or $IsWindows)
|
||||
{
|
||||
$AppExtension = '.exe'
|
||||
}
|
||||
|
||||
$cmd = ''
|
||||
if ($EpubZipBin)
|
||||
{
|
||||
$cmd = Get-Command -Name $EpubZipBin -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "Epubzip binary specified was not found ($EpubZipBin). Will try to use alternate version if available."
|
||||
}
|
||||
}
|
||||
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "epubzip$AppExtension") -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name "epubzip$AppExtension" -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "Epubzip binary not found ($EpubZipBin). If you are downloading ebooks (rather than comics or audiobooks), you may wish to download the binary from https://github.com/dino-/epub-tools/releases, specify a different path with -EpubZipBin, or specify -KeepDecryptedData so that you can manually pack afterward."
|
||||
}
|
||||
}
|
||||
|
||||
$EpubZipBin = $cmd.Source
|
||||
}
|
||||
|
||||
Write-Verbose ('Using epubzip bin: "{0}"' -f $EpubZipBin)
|
||||
|
||||
$cmd = ''
|
||||
if ($FfmpegBin)
|
||||
{
|
||||
$cmd = Get-Command -Name $FfmpegBin -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "FFMpeg binary specified was not found ($FfmpegBin). Will try to use alternate version if available."
|
||||
}
|
||||
}
|
||||
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "ffmpeg$AppExtension") -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
$cmd = Get-Command -Name "ffmpeg$AppExtension" -ErrorAction SilentlyContinue
|
||||
if (!$cmd)
|
||||
{
|
||||
Write-Warning "FFmpeg binary not found. If you are downloading audiobooks (rather than ebooks or comics), you may wish to download the binary from https://ffmpeg.zeranoe.com/builds/, specify a different path with -FfmpegBin, or specify -KeepDecryptedData so that you can manually convert afterward."
|
||||
}
|
||||
}
|
||||
|
||||
$FfmpegBin = $cmd.Source
|
||||
}
|
||||
|
||||
Write-Verbose ('Using ffpmeg bin: "{0}"' -f $FfmpegBin)
|
||||
|
||||
if (!(Test-Path -LiteralPath $OutputFolder -PathType Container))
|
||||
{
|
||||
Write-Warning "Output folder doesn't exist. Creating."
|
||||
New-Item -Path $OutputFolder -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$OutputFolder = Get-Item -LiteralPath $OutputFolder | Select-Object -ExpandProperty $_.FullName
|
||||
|
||||
$token = Connect-Hoopla -Credential $Credential
|
||||
Write-Verbose "Logged in. Received token $($token -replace '\-.*', '-****-****-****-************')"
|
||||
|
||||
$users = Get-HooplaUsers $token
|
||||
Write-Verbose "Found $($users.patrons.Count) patrons"
|
||||
|
||||
$userId = $users.id
|
||||
if (!$PatronId)
|
||||
{
|
||||
if ($users.patrons.Count -eq 0)
|
||||
{
|
||||
throw "No patrons found on account. Account may not be correctly set up with library."
|
||||
}
|
||||
elseif ($users.patrons.Count -gt 1)
|
||||
{
|
||||
Write-Warning (
|
||||
"Multiple patrons found on account. Using first one, {0} ({1}). You can specify -PatronId to override" -f $users.patrons[0].id, $users.patrons[0].libraryName
|
||||
)
|
||||
}
|
||||
|
||||
$PatronId = $users.patrons[0].id
|
||||
Write-Verbose "Using PatronId $PatronId"
|
||||
}
|
||||
|
||||
$borrowedRaw = Get-HooplaBorrowedTitles -Token $token -UserId $userId -PatronId $PatronId
|
||||
$borrowed = $borrowedRaw | Where-Object -FilterScript { $_.kind.id -in $SUPPORTED_KINDS }
|
||||
Write-Verbose "Found $($borrowed.Count) ($($borrowedRaw.Count)) titles already borrowed"
|
||||
$toDownload = @()
|
||||
|
||||
if ($AllBorrowed)
|
||||
{
|
||||
$toDownload = $borrowed
|
||||
}
|
||||
else
|
||||
{
|
||||
$toDownload = $borrowed | Where-Object -FilterScript { $_.id -in $TitleId }
|
||||
|
||||
$allBorrowedTitles = $borrowed | Select-Object -ExpandProperty id
|
||||
$toBorrow = $TitleId | Where-Object -FilterScript { $_ -notin $allBorrowedTitles }
|
||||
|
||||
if ($toBorrow)
|
||||
{
|
||||
$borrowsRemainingData = Get-HooplaBorrowsRemaining -UserId $userId -PatronId $PatronId -Token $token
|
||||
Write-Host $borrowsRemainingData.borrowsRemainingMessage
|
||||
|
||||
$borrowsRemaining = $borrowsRemainingData.borrowsRemaining
|
||||
|
||||
$toBorrow | ForEach-Object -Process {
|
||||
Write-Host "Title $_ is not already borrowed or is not a supported kind. Looking up data about it."
|
||||
$titleInfo = Get-HooplaTitleInfo -PatronId $PatronId -Token $token -TitleId $_
|
||||
if ($titleInfo.kind.id -in $SUPPORTED_KINDS)
|
||||
{
|
||||
if ((--$borrowsRemaining) -le 0)
|
||||
{
|
||||
Write-Warning "Title $_ ($($titleInfo.Title)) not borrowed already, but we're out of remaining borrows allowed. Skipping..."
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host "Borrowing title $_ ($($titleInfo.Title))..."
|
||||
$res = Invoke-HooplaBorrow -UserId $userId -PatronId $PatronId -Token $token -TitleId $titleInfo.id
|
||||
Write-Host "Response: $($res.message)"
|
||||
$newToDownload = $res.titles | Where-Object -FilterScript { $_.id -eq $titleInfo.id }
|
||||
if ($newToDownload)
|
||||
{
|
||||
$toDownload += $newToDownload
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Failed to borrow title $_ ($($titleInfo.Title))..."
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Warning "Title $_ is not a supported kind ($($titleInfo.kind.name)). Skipping..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tempFolder = [IO.Path]::GetTempPath()
|
||||
|
||||
$now = Get-Date
|
||||
|
||||
$toDownload | ForEach-Object -Process {
|
||||
$info = $_
|
||||
$contentKind = [HooplaKind]$_.kind.id
|
||||
if ($_.contents.mediaType)
|
||||
{
|
||||
$contentKind = [HooplaKind]$_.contents.mediaType
|
||||
}
|
||||
$contents = $info.contents
|
||||
$circId = $contents.circId
|
||||
$mediaKey = $contents.mediaKey
|
||||
$dueUnix = [Math]::Truncate($info.contents.due / 1000)
|
||||
$due = (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)).AddSeconds($dueUnix)
|
||||
|
||||
$circFileName = (Join-Path -Path $tempFolder -ChildPath "$($circId).zip")
|
||||
|
||||
Invoke-HooplaZipDownload -PatronId $patronId -Token $token -CircId $circId -OutFile $circFileName
|
||||
$keyData = Get-HooplaKey -PatronId $patronId -Token $token -CircId $circId
|
||||
|
||||
$fileKeyKey = Get-FileKeyKey -CircId $circId -Due $due -PatronId $patronId
|
||||
$fileKey = Decrypt-FileKey -FileKeyEnc ([Convert]::FromBase64String($keyData."$mediaKey")) -FileKeyKey $fileKeyKey
|
||||
|
||||
$encDir = Join-Path -Path $tempFolder -ChildPath ('enc-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
|
||||
New-Item -Path $encDir -ItemType Directory | Out-Null
|
||||
Expand-Archive -LiteralPath $circFileName -DestinationPath $encDir
|
||||
|
||||
Remove-Item -LiteralPath $circFileName
|
||||
|
||||
$decDir = Join-Path -Path $tempFolder -ChildPath ('dec-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
|
||||
New-Item -Path $decDir -ItemType Directory | Out-Null
|
||||
|
||||
$activity = 'Decrypting Content ({0})' -f $_.title
|
||||
Write-Progress -Activity $activity -PercentComplete 0
|
||||
$zipFiles = Get-ChildItem $encDir -Recurse -File
|
||||
$decDone = 0
|
||||
$decTotal = $zipFiles.Count
|
||||
$zipFiles | ForEach-Object -Process {
|
||||
$outFile = $_.FullName.Replace($encDir, $decDir)
|
||||
$outDir = $_.DirectoryName.Replace($encDir, $decDir)
|
||||
|
||||
if (!(Test-Path -LiteralPath $outDir))
|
||||
{
|
||||
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
if (($contentKind -eq [HooplaKind]::AUDIOBOOK) -and ($_.Extension -eq '.m3u8'))
|
||||
{
|
||||
$lines = Get-Content -LiteralPath $_.FullName | Where-Object -FilterScript {$_ -notmatch '^#EXT-X-KEY'}
|
||||
# Out-File doesn't support utf8 w/o BOM
|
||||
[IO.File]::WriteAllLines($outFile, $lines)
|
||||
return
|
||||
}
|
||||
|
||||
if ($_.Length)
|
||||
{
|
||||
# Hack. Some ebooks contain audio files that download as unencrypted
|
||||
if (($_.Extension -eq '.m4a') -and (Test-Mp4 -LiteralPath $_.FullName))
|
||||
{
|
||||
Write-Verbose -Message ('Coping unencrypted {0}' -f $_.FullName)
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $outFile
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Verbose -Message ('Decrypting {0}' -f $_.FullName)
|
||||
Decrypt-File -FileKey $fileKey -MediaKey $mediaKey -InputFileName $_.FullName -OutputFileName $outFile
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Verbose -Message ('Writing empty file {0}' -f $_.FullName)
|
||||
'' | Out-File -LiteralPath $outFile
|
||||
}
|
||||
|
||||
Write-Progress -Activity $activity -PercentComplete ((++$decDone) / $decTotal * 100)
|
||||
}
|
||||
Write-Progress -Activity $activity -Completed
|
||||
|
||||
switch ($contentKind)
|
||||
{
|
||||
([HooplaKind]::EBOOK) {
|
||||
Convert-HooplaDecryptedToEpub -InputFolder $decDir -OutFolder $OutputFolder
|
||||
}
|
||||
|
||||
([HooplaKind]::COMIC) {
|
||||
$title = $contents.title
|
||||
$subtitle = $contents.subtitle
|
||||
$name = $title
|
||||
if ($subtitle) {
|
||||
$name += ", $subtitle"
|
||||
}
|
||||
Convert-HooplaDecryptedToCbz -InputFolder $decDir -OutFolder $OutputFolder -Name $name
|
||||
}
|
||||
|
||||
([HooplaKind]::AUDIOBOOK) {
|
||||
Convert-HooplaDecryptedToM4a -InputFolder $decDir -OutFolder $OutputFolder -Name $info.title -Title $info.title `
|
||||
-Year $info.year -Author $info.artist.name -Subtitle $contents.subtitle -ChapterData $contents.chapters
|
||||
}
|
||||
}
|
||||
|
||||
if (!$KeepDecryptedData)
|
||||
{
|
||||
Remove-Item -LiteralPath $decDir -Recurse
|
||||
}
|
||||
else
|
||||
{
|
||||
Write-Host ('Decrypted data for {0} ({1}) stored in {2}' -f $_.id, $_.title, $decDir)
|
||||
}
|
||||
|
||||
if (!$KeepEncryptedData)
|
||||
{
|
||||
Remove-Item -LiteralPath $encDir -Recurse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
From a Libation user about possibility of integrating Hoopla:
|
||||
|
||||
I have a powershell script. I didn't write it, and neither did the person that gave it to me. It works most of the time (98%). Some titles, it doesn't play well with, but does allow to keep the downloaded data, whether the decrypt was successful, or not, and then you can mess with the data, from there.
|
||||
|
||||
If you run the script with no parameters, then all the books in your library will download, and decrypt into the same directory as the script, into a folder named Completed.
|
||||
|
||||
If you run the script with the command:
|
||||
'.\HooplaDownloader.newer.ps1 -KeepDecryptedData'
|
||||
then it will, and will notify you, when complete, where it was stored.
|
||||
|
||||
There is a parameter to download a specific titleID#, whether it's in your library, or not, but I've not played with it that far, as the method to accomplish it still reserves it to your library, and then proceeds as normal. I can tell you, if it's a "trial and error concern", the title will not be removed from your library, after you run the script, whether it succeeds or not. So, if it fails, you can retry, or try the -KeepDecryptedData option. I received no documentation for it, which is why I'm telling you as much as I know about using it.
|
||||
|
||||
[ see HooplaDownloader.newer.ps1 ]
|
||||
@@ -1,9 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Hoopla
|
||||
{
|
||||
public class temp
|
||||
{
|
||||
// placeholder
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,11 @@ VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
|
||||
__TODO.txt = __TODO.txt
|
||||
REFERENCE.txt = REFERENCE.txt
|
||||
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
|
||||
_DB_NOTES.txt = _DB_NOTES.txt
|
||||
REFERENCE.txt = REFERENCE.txt
|
||||
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
|
||||
__TODO.txt = __TODO.txt
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 Domain Utilities (db aware)", "5 Domain Utilities (db aware)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}"
|
||||
@@ -38,6 +38,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "Lib
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {40C67036-C1A7-4FDF-AA83-8EC902E257F3}
|
||||
{428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
@@ -49,8 +50,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
|
||||
@@ -65,6 +64,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangover", "Hangover\Hangover.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -111,10 +112,6 @@ Global
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -143,6 +140,10 @@ Global
|
||||
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -158,7 +159,6 @@ Global
|
||||
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
|
||||
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
|
||||
{428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
|
||||
@@ -166,6 +166,7 @@ Global
|
||||
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{F2E04270-4551-41C4-99FF-E7125BED708C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
|
||||
<!--
|
||||
When LibationWinForms and LibationCli output to the same dir, LibationCli must build before LibationWinForms
|
||||
|
||||
VS > rt-clik solution > Project Build Order...
|
||||
Dependencies [tab]
|
||||
Projects: LibationWinForms
|
||||
manually check LibationCli
|
||||
|
||||
VS > rt-clk solution > Properties
|
||||
left: Project Dependencies
|
||||
top: Projects: LibationWinForms
|
||||
bottom: manually check LibationCli
|
||||
|
||||
edit debug and release output paths
|
||||
-->
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
|
||||
@@ -33,7 +35,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -9,7 +9,6 @@ using FileLiberator;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
// streamlined, non-Forms copy of ProcessorAutomationController
|
||||
public abstract class ProcessableOptionsBase : OptionsBase
|
||||
{
|
||||
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
|
||||
|
||||
@@ -27,7 +27,6 @@ namespace LibationCli
|
||||
// //
|
||||
//***********************************************//
|
||||
Setup.Initialize();
|
||||
Setup.SubscribeToDatabaseEvents();
|
||||
|
||||
var types = Setup.LoadVerbs();
|
||||
|
||||
|
||||
@@ -34,15 +34,15 @@ namespace LibationCli
|
||||
|
||||
private static void checkForUpdate()
|
||||
{
|
||||
var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease();
|
||||
if (!hasUpgrade)
|
||||
var upgradeProperties = LibationScaffolding.GetLatestRelease();
|
||||
if (upgradeProperties is null)
|
||||
return;
|
||||
|
||||
var origColor = Console.ForegroundColor;
|
||||
try
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}");
|
||||
Console.WriteLine($"UPDATE AVAILABLE @ {upgradeProperties.ZipUrl}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -50,11 +50,6 @@ namespace LibationCli
|
||||
}
|
||||
}
|
||||
|
||||
public static void SubscribeToDatabaseEvents()
|
||||
{
|
||||
DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book);
|
||||
}
|
||||
|
||||
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
|
||||
.GetTypes()
|
||||
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)
|
||||
|
||||
@@ -9,18 +9,19 @@ namespace LibationFileManager
|
||||
{
|
||||
public abstract class AudibleFileStorage
|
||||
{
|
||||
protected abstract string GetFilePathCustom(string productId);
|
||||
protected abstract LongPath GetFilePathCustom(string productId);
|
||||
protected abstract List<LongPath> GetFilePathsCustom(string productId);
|
||||
|
||||
#region static
|
||||
public static string DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
|
||||
public static string DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
|
||||
public static LongPath DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
|
||||
public static LongPath DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
|
||||
|
||||
private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage();
|
||||
public static bool AaxcExists(string productId) => AAXC.Exists(productId);
|
||||
|
||||
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
|
||||
|
||||
public static string BooksDirectory
|
||||
public static LongPath BooksDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
@@ -43,7 +44,7 @@ namespace LibationFileManager
|
||||
regexTemplate = $@"{{0}}.*?\.({extAggr})$";
|
||||
}
|
||||
|
||||
protected string GetFilePath(string productId)
|
||||
protected LongPath GetFilePath(string productId)
|
||||
{
|
||||
// primary lookup
|
||||
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
|
||||
@@ -58,6 +59,9 @@ namespace LibationFileManager
|
||||
return firstOrNull;
|
||||
}
|
||||
|
||||
public List<LongPath> GetPaths(string productId)
|
||||
=> GetFilePathsCustom(productId);
|
||||
|
||||
protected Regex GetBookSearchRegex(string productId)
|
||||
{
|
||||
var pattern = string.Format(regexTemplate, productId);
|
||||
@@ -70,12 +74,15 @@ namespace LibationFileManager
|
||||
{
|
||||
internal AaxcFileStorage() : base(FileType.AAXC) { }
|
||||
|
||||
protected override string GetFilePathCustom(string productId)
|
||||
protected override LongPath GetFilePathCustom(string productId)
|
||||
=> GetFilePathsCustom(productId).FirstOrDefault();
|
||||
|
||||
protected override List<LongPath> GetFilePathsCustom(string productId)
|
||||
{
|
||||
var regex = GetBookSearchRegex(productId);
|
||||
return FileUtility
|
||||
.SaferEnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
|
||||
.FirstOrDefault(s => regex.IsMatch(s));
|
||||
.Where(s => regex.IsMatch(s)).ToList();
|
||||
}
|
||||
|
||||
public bool Exists(string productId) => GetFilePath(productId) is not null;
|
||||
@@ -88,7 +95,11 @@ namespace LibationFileManager
|
||||
|
||||
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
|
||||
private static object bookDirectoryFilesLocker { get; } = new();
|
||||
protected override string GetFilePathCustom(string productId)
|
||||
|
||||
protected override LongPath GetFilePathCustom(string productId)
|
||||
=> GetFilePathsCustom(productId).FirstOrDefault();
|
||||
|
||||
protected override List<LongPath> GetFilePathsCustom(string productId)
|
||||
{
|
||||
// If user changed the BooksDirectory: reinitialize
|
||||
lock (bookDirectoryFilesLocker)
|
||||
@@ -96,11 +107,12 @@ namespace LibationFileManager
|
||||
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
|
||||
|
||||
var regex = GetBookSearchRegex(productId);
|
||||
return BookDirectoryFiles.FindFile(regex);
|
||||
return BookDirectoryFiles.FindFiles(regex);
|
||||
}
|
||||
|
||||
public void Refresh() => BookDirectoryFiles.RefreshFiles();
|
||||
|
||||
public string GetPath(string productId) => GetFilePath(productId);
|
||||
}
|
||||
public LongPath GetPath(string productId) => GetFilePath(productId);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,492 +14,528 @@ using Serilog.Events;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public class Configuration
|
||||
{
|
||||
public bool LibationSettingsAreValid
|
||||
=> File.Exists(APPSETTINGS_JSON)
|
||||
&& SettingsFileIsValid(SettingsFilePath);
|
||||
public class Configuration
|
||||
{
|
||||
public bool LibationSettingsAreValid
|
||||
=> File.Exists(APPSETTINGS_JSON)
|
||||
&& SettingsFileIsValid(SettingsFilePath);
|
||||
|
||||
public static bool SettingsFileIsValid(string settingsFile)
|
||||
{
|
||||
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
|
||||
return false;
|
||||
public static bool SettingsFileIsValid(string settingsFile)
|
||||
{
|
||||
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
|
||||
return false;
|
||||
|
||||
var pDic = new PersistentDictionary(settingsFile, isReadOnly: true);
|
||||
var pDic = new PersistentDictionary(settingsFile, isReadOnly: true);
|
||||
|
||||
var booksDir = pDic.GetString(nameof(Books));
|
||||
if (booksDir is null || !Directory.Exists(booksDir))
|
||||
return false;
|
||||
var booksDir = pDic.GetString(nameof(Books));
|
||||
if (booksDir is null || !Directory.Exists(booksDir))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pDic.GetString(nameof(InProgress))))
|
||||
return false;
|
||||
if (string.IsNullOrWhiteSpace(pDic.GetString(nameof(InProgress))))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#region persistent configuration settings/values
|
||||
|
||||
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
|
||||
|
||||
// default setting and directory creation occur in class responsible for files.
|
||||
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
|
||||
// exceptions: appsettings.json, LibationFiles dir, Settings.json
|
||||
|
||||
private PersistentDictionary persistentDictionary;
|
||||
|
||||
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
|
||||
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
|
||||
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
|
||||
|
||||
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
|
||||
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
|
||||
{
|
||||
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
|
||||
if (settingWasChanged)
|
||||
configuration?.Reload();
|
||||
}
|
||||
|
||||
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
|
||||
|
||||
public static string GetDescription(string propertyName)
|
||||
{
|
||||
var attribute = typeof(Configuration)
|
||||
.GetProperty(propertyName)
|
||||
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
|
||||
.SingleOrDefault()
|
||||
as DescriptionAttribute;
|
||||
|
||||
return attribute?.Description;
|
||||
}
|
||||
|
||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
||||
|
||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||
public string Books
|
||||
{
|
||||
get => persistentDictionary.GetString(nameof(Books));
|
||||
set => persistentDictionary.SetString(nameof(Books), value);
|
||||
}
|
||||
|
||||
// temp/working dir(s) should be outside of dropbox
|
||||
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
|
||||
public string InProgress
|
||||
{
|
||||
get => persistentDictionary.GetString(nameof(InProgress));
|
||||
set => persistentDictionary.SetString(nameof(InProgress), value);
|
||||
}
|
||||
|
||||
[Description("Allow Libation to fix up audiobook metadata")]
|
||||
public bool AllowLibationFixup
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
|
||||
set => persistentDictionary.SetNonString(nameof(AllowLibationFixup), value);
|
||||
}
|
||||
|
||||
[Description("Create a cue sheet (.cue)")]
|
||||
public bool CreateCueSheet
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(CreateCueSheet));
|
||||
set => persistentDictionary.SetNonString(nameof(CreateCueSheet), value);
|
||||
}
|
||||
|
||||
[Description("Retain the Aax file after successfully decrypting")]
|
||||
public bool RetainAaxFile
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(RetainAaxFile));
|
||||
set => persistentDictionary.SetNonString(nameof(RetainAaxFile), value);
|
||||
}
|
||||
|
||||
[Description("Split my books into multiple files by chapter")]
|
||||
public bool SplitFilesByChapter
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(SplitFilesByChapter));
|
||||
set => persistentDictionary.SetNonString(nameof(SplitFilesByChapter), value);
|
||||
}
|
||||
|
||||
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
|
||||
public bool StripUnabridged
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(StripUnabridged));
|
||||
set => persistentDictionary.SetNonString(nameof(StripUnabridged), value);
|
||||
}
|
||||
|
||||
[Description("Allow Libation to remove audible branding from the start\r\nand end of audiobooks. (e.g. \"This is Audible\")")]
|
||||
public bool StripAudibleBrandAudio
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(StripAudibleBrandAudio));
|
||||
set => persistentDictionary.SetNonString(nameof(StripAudibleBrandAudio), value);
|
||||
}
|
||||
|
||||
[Description("Decrypt to lossy format?")]
|
||||
public bool DecryptToLossy
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
|
||||
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
|
||||
}
|
||||
|
||||
[Description("Lame encoder target. true = Bitrate, false = Quality")]
|
||||
public bool LameTargetBitrate
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameTargetBitrate));
|
||||
set => persistentDictionary.SetNonString(nameof(LameTargetBitrate), value);
|
||||
}
|
||||
|
||||
[Description("Lame encoder downsamples to mono")]
|
||||
public bool LameDownsampleMono
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameDownsampleMono));
|
||||
set => persistentDictionary.SetNonString(nameof(LameDownsampleMono), value);
|
||||
}
|
||||
|
||||
[Description("Lame target bitrate [16,320]")]
|
||||
public int LameBitrate
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(LameBitrate));
|
||||
set => persistentDictionary.SetNonString(nameof(LameBitrate), value);
|
||||
}
|
||||
|
||||
[Description("Restrict encoder to constant bitrate?")]
|
||||
public bool LameConstantBitrate
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameConstantBitrate));
|
||||
set => persistentDictionary.SetNonString(nameof(LameConstantBitrate), value);
|
||||
}
|
||||
|
||||
[Description("Match the source bitrate?")]
|
||||
public bool LameMatchSourceBR
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameMatchSourceBR));
|
||||
set => persistentDictionary.SetNonString(nameof(LameMatchSourceBR), value);
|
||||
}
|
||||
|
||||
[Description("Lame target VBR quality [10,100]")]
|
||||
public int LameVBRQuality
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(LameVBRQuality));
|
||||
set => persistentDictionary.SetNonString(nameof(LameVBRQuality), value);
|
||||
}
|
||||
|
||||
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
|
||||
public Dictionary<string, bool> GridColumnsVisibilities
|
||||
{
|
||||
get => persistentDictionary.GetNonString<Dictionary<string, bool>>(nameof(GridColumnsVisibilities));
|
||||
set => persistentDictionary.SetNonString(nameof(GridColumnsVisibilities), value);
|
||||
}
|
||||
|
||||
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
|
||||
public Dictionary<string, int> GridColumnsDisplayIndices
|
||||
{
|
||||
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsDisplayIndices));
|
||||
set => persistentDictionary.SetNonString(nameof(GridColumnsDisplayIndices), value);
|
||||
}
|
||||
|
||||
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
|
||||
public Dictionary<string, int> GridColumnsWidths
|
||||
{
|
||||
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsWidths));
|
||||
set => persistentDictionary.SetNonString(nameof(GridColumnsWidths), value);
|
||||
}
|
||||
|
||||
[Description("Save cover image alongside audiobook?")]
|
||||
public bool DownloadCoverArt
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DownloadCoverArt));
|
||||
set => persistentDictionary.SetNonString(nameof(DownloadCoverArt), value);
|
||||
}
|
||||
|
||||
public enum BadBookAction
|
||||
{
|
||||
[Description("Ask each time what action to take.")]
|
||||
Ask = 0,
|
||||
[Description("Stop processing books.")]
|
||||
Abort = 1,
|
||||
[Description("Retry book later. Skip for now. Continue processing books.")]
|
||||
Retry = 2,
|
||||
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
|
||||
Ignore = 3
|
||||
}
|
||||
|
||||
[Description("When liberating books and there is an error, Libation should:")]
|
||||
public BadBookAction BadBook
|
||||
{
|
||||
get
|
||||
{
|
||||
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
|
||||
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
|
||||
}
|
||||
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
|
||||
}
|
||||
|
||||
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
|
||||
public bool ShowImportedStats
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
|
||||
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
|
||||
}
|
||||
|
||||
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
|
||||
public bool ImportEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(ImportEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(ImportEpisodes), value);
|
||||
}
|
||||
|
||||
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
|
||||
public bool DownloadEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value);
|
||||
return true;
|
||||
}
|
||||
|
||||
public event EventHandler AutoScanChanged;
|
||||
#region persistent configuration settings/values
|
||||
|
||||
[Description("Automatically run periodic scans in the background?")]
|
||||
public bool AutoScan
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(AutoScan));
|
||||
set
|
||||
{
|
||||
if (AutoScan != value)
|
||||
{
|
||||
persistentDictionary.SetNonString(nameof(AutoScan), value);
|
||||
AutoScanChanged?.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
|
||||
|
||||
#region templates: custom file naming
|
||||
// default setting and directory creation occur in class responsible for files.
|
||||
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
|
||||
// exceptions: appsettings.json, LibationFiles dir, Settings.json
|
||||
|
||||
[Description("How to format the folders in which files will be saved")]
|
||||
private PersistentDictionary persistentDictionary;
|
||||
|
||||
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
|
||||
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
|
||||
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
|
||||
|
||||
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
|
||||
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
|
||||
{
|
||||
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
|
||||
if (settingWasChanged)
|
||||
configuration?.Reload();
|
||||
}
|
||||
|
||||
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
|
||||
|
||||
public static string GetDescription(string propertyName)
|
||||
{
|
||||
var attribute = typeof(Configuration)
|
||||
.GetProperty(propertyName)
|
||||
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
|
||||
.SingleOrDefault()
|
||||
as DescriptionAttribute;
|
||||
|
||||
return attribute?.Description;
|
||||
}
|
||||
|
||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
||||
|
||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||
public string Books
|
||||
{
|
||||
get => persistentDictionary.GetString(nameof(Books));
|
||||
set => persistentDictionary.SetString(nameof(Books), value);
|
||||
}
|
||||
|
||||
// temp/working dir(s) should be outside of dropbox
|
||||
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
|
||||
public string InProgress
|
||||
{
|
||||
get => persistentDictionary.GetString(nameof(InProgress));
|
||||
set => persistentDictionary.SetString(nameof(InProgress), value);
|
||||
}
|
||||
|
||||
[Description("Allow Libation to fix up audiobook metadata")]
|
||||
public bool AllowLibationFixup
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
|
||||
set => persistentDictionary.SetNonString(nameof(AllowLibationFixup), value);
|
||||
}
|
||||
|
||||
[Description("Create a cue sheet (.cue)")]
|
||||
public bool CreateCueSheet
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(CreateCueSheet));
|
||||
set => persistentDictionary.SetNonString(nameof(CreateCueSheet), value);
|
||||
}
|
||||
|
||||
[Description("Retain the Aax file after successfully decrypting")]
|
||||
public bool RetainAaxFile
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(RetainAaxFile));
|
||||
set => persistentDictionary.SetNonString(nameof(RetainAaxFile), value);
|
||||
}
|
||||
|
||||
[Description("Split my books into multiple files by chapter")]
|
||||
public bool SplitFilesByChapter
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(SplitFilesByChapter));
|
||||
set => persistentDictionary.SetNonString(nameof(SplitFilesByChapter), value);
|
||||
}
|
||||
|
||||
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
|
||||
public bool StripUnabridged
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(StripUnabridged));
|
||||
set => persistentDictionary.SetNonString(nameof(StripUnabridged), value);
|
||||
}
|
||||
|
||||
[Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")]
|
||||
public bool StripAudibleBrandAudio
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(StripAudibleBrandAudio));
|
||||
set => persistentDictionary.SetNonString(nameof(StripAudibleBrandAudio), value);
|
||||
}
|
||||
|
||||
[Description("Decrypt to lossy format?")]
|
||||
public bool DecryptToLossy
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
|
||||
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
|
||||
}
|
||||
|
||||
[Description("Lame encoder target. true = Bitrate, false = Quality")]
|
||||
public bool LameTargetBitrate
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameTargetBitrate));
|
||||
set => persistentDictionary.SetNonString(nameof(LameTargetBitrate), value);
|
||||
}
|
||||
|
||||
[Description("Lame encoder downsamples to mono")]
|
||||
public bool LameDownsampleMono
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameDownsampleMono));
|
||||
set => persistentDictionary.SetNonString(nameof(LameDownsampleMono), value);
|
||||
}
|
||||
|
||||
[Description("Lame target bitrate [16,320]")]
|
||||
public int LameBitrate
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(LameBitrate));
|
||||
set => persistentDictionary.SetNonString(nameof(LameBitrate), value);
|
||||
}
|
||||
|
||||
[Description("Restrict encoder to constant bitrate?")]
|
||||
public bool LameConstantBitrate
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameConstantBitrate));
|
||||
set => persistentDictionary.SetNonString(nameof(LameConstantBitrate), value);
|
||||
}
|
||||
|
||||
[Description("Match the source bitrate?")]
|
||||
public bool LameMatchSourceBR
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(LameMatchSourceBR));
|
||||
set => persistentDictionary.SetNonString(nameof(LameMatchSourceBR), value);
|
||||
}
|
||||
|
||||
[Description("Lame target VBR quality [10,100]")]
|
||||
public int LameVBRQuality
|
||||
{
|
||||
get => persistentDictionary.GetNonString<int>(nameof(LameVBRQuality));
|
||||
set => persistentDictionary.SetNonString(nameof(LameVBRQuality), value);
|
||||
}
|
||||
|
||||
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
|
||||
public Dictionary<string, bool> GridColumnsVisibilities
|
||||
{
|
||||
get => persistentDictionary.GetNonString<Dictionary<string, bool>>(nameof(GridColumnsVisibilities));
|
||||
set => persistentDictionary.SetNonString(nameof(GridColumnsVisibilities), value);
|
||||
}
|
||||
|
||||
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
|
||||
public Dictionary<string, int> GridColumnsDisplayIndices
|
||||
{
|
||||
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsDisplayIndices));
|
||||
set => persistentDictionary.SetNonString(nameof(GridColumnsDisplayIndices), value);
|
||||
}
|
||||
|
||||
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
|
||||
public Dictionary<string, int> GridColumnsWidths
|
||||
{
|
||||
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsWidths));
|
||||
set => persistentDictionary.SetNonString(nameof(GridColumnsWidths), value);
|
||||
}
|
||||
|
||||
[Description("Save cover image alongside audiobook?")]
|
||||
public bool DownloadCoverArt
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DownloadCoverArt));
|
||||
set => persistentDictionary.SetNonString(nameof(DownloadCoverArt), value);
|
||||
}
|
||||
|
||||
public enum BadBookAction
|
||||
{
|
||||
[Description("Ask each time what action to take.")]
|
||||
Ask = 0,
|
||||
[Description("Stop processing books.")]
|
||||
Abort = 1,
|
||||
[Description("Retry book later. Skip for now. Continue processing books.")]
|
||||
Retry = 2,
|
||||
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
|
||||
Ignore = 3
|
||||
}
|
||||
|
||||
[Description("When liberating books and there is an error, Libation should:")]
|
||||
public BadBookAction BadBook
|
||||
{
|
||||
get
|
||||
{
|
||||
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
|
||||
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
|
||||
}
|
||||
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
|
||||
}
|
||||
|
||||
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
|
||||
public bool ShowImportedStats
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
|
||||
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
|
||||
}
|
||||
|
||||
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
|
||||
public bool ImportEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(ImportEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(ImportEpisodes), value);
|
||||
}
|
||||
|
||||
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
|
||||
public bool DownloadEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value);
|
||||
}
|
||||
|
||||
public event EventHandler AutoScanChanged;
|
||||
|
||||
[Description("Automatically run periodic scans in the background?")]
|
||||
public bool AutoScan
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(AutoScan));
|
||||
set
|
||||
{
|
||||
if (AutoScan != value)
|
||||
{
|
||||
persistentDictionary.SetNonString(nameof(AutoScan), value);
|
||||
AutoScanChanged?.Invoke(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Description("Auto download episodes? After scan, download new books in 'checked' accounts.")]
|
||||
public bool AutoDownloadEpisodes
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(AutoDownloadEpisodes));
|
||||
set => persistentDictionary.SetNonString(nameof(AutoDownloadEpisodes), value);
|
||||
}
|
||||
|
||||
[Description("Save all podcast episodes in a series to the series parent folder?")]
|
||||
public bool SavePodcastsToParentFolder
|
||||
{
|
||||
get => persistentDictionary.GetNonString<bool>(nameof(SavePodcastsToParentFolder));
|
||||
set => persistentDictionary.SetNonString(nameof(SavePodcastsToParentFolder), value);
|
||||
}
|
||||
|
||||
#region templates: custom file naming
|
||||
|
||||
[Description("Edit how illegal filename characters are replaced")]
|
||||
public ReplacementCharacters ReplacementCharacters
|
||||
{
|
||||
get => persistentDictionary.GetNonString<ReplacementCharacters>(nameof(ReplacementCharacters));
|
||||
set => persistentDictionary.SetNonString(nameof(ReplacementCharacters), value);
|
||||
}
|
||||
|
||||
[Description("How to format the folders in which files will be saved")]
|
||||
public string FolderTemplate
|
||||
{
|
||||
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
|
||||
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
|
||||
}
|
||||
{
|
||||
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
|
||||
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
|
||||
}
|
||||
|
||||
[Description("How to format the saved pdf and audio files")]
|
||||
[Description("How to format the saved pdf and audio files")]
|
||||
public string FileTemplate
|
||||
{
|
||||
get => getTemplate(nameof(FileTemplate), Templates.File);
|
||||
set => setTemplate(nameof(FileTemplate), Templates.File, value);
|
||||
}
|
||||
{
|
||||
get => getTemplate(nameof(FileTemplate), Templates.File);
|
||||
set => setTemplate(nameof(FileTemplate), Templates.File, value);
|
||||
}
|
||||
|
||||
[Description("How to format the saved audio files when split by chapters")]
|
||||
public string ChapterFileTemplate
|
||||
{
|
||||
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
|
||||
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
|
||||
}
|
||||
[Description("How to format the saved audio files when split by chapters")]
|
||||
public string ChapterFileTemplate
|
||||
{
|
||||
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
|
||||
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
|
||||
}
|
||||
|
||||
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
|
||||
private void setTemplate(string settingName, Templates templ, string newValue)
|
||||
{
|
||||
var template = newValue?.Trim();
|
||||
if (templ.IsValid(template))
|
||||
persistentDictionary.SetString(settingName, template);
|
||||
}
|
||||
#endregion
|
||||
[Description("How to format the file's Tile stored in metadata")]
|
||||
public string ChapterTitleTemplate
|
||||
{
|
||||
get => getTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle);
|
||||
set => setTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
|
||||
private void setTemplate(string settingName, Templates templ, string newValue)
|
||||
{
|
||||
var template = newValue?.Trim();
|
||||
if (templ.IsValid(template))
|
||||
persistentDictionary.SetString(settingName, template);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region known directories
|
||||
public static string AppDir_Relative => $@".\{LIBATION_FILES_KEY}";
|
||||
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES_KEY));
|
||||
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
|
||||
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
|
||||
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
|
||||
#endregion
|
||||
|
||||
public enum KnownDirectories
|
||||
{
|
||||
None = 0,
|
||||
#region known directories
|
||||
public static string AppDir_Relative => $@".\{LIBATION_FILES_KEY}";
|
||||
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES_KEY));
|
||||
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
|
||||
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
|
||||
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
|
||||
|
||||
[Description("My Users folder")]
|
||||
UserProfile = 1,
|
||||
public enum KnownDirectories
|
||||
{
|
||||
None = 0,
|
||||
|
||||
[Description("The same folder that Libation is running from")]
|
||||
AppDir = 2,
|
||||
[Description("My Users folder")]
|
||||
UserProfile = 1,
|
||||
|
||||
[Description("Windows temporary folder")]
|
||||
WinTemp = 3,
|
||||
[Description("The same folder that Libation is running from")]
|
||||
AppDir = 2,
|
||||
|
||||
[Description("My Documents")]
|
||||
MyDocs = 4,
|
||||
[Description("Windows temporary folder")]
|
||||
WinTemp = 3,
|
||||
|
||||
[Description("Your settings folder (aka: Libation Files)")]
|
||||
LibationFiles = 5
|
||||
}
|
||||
// use func calls so we always get the latest value of LibationFiles
|
||||
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
|
||||
{
|
||||
(KnownDirectories.None, () => null),
|
||||
(KnownDirectories.UserProfile, () => UserProfile),
|
||||
(KnownDirectories.AppDir, () => AppDir_Relative),
|
||||
(KnownDirectories.WinTemp, () => WinTemp),
|
||||
(KnownDirectories.MyDocs, () => MyDocs),
|
||||
// this is important to not let very early calls try to accidentally load LibationFiles too early.
|
||||
// also, keep this at bottom of this list
|
||||
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
|
||||
};
|
||||
public static string GetKnownDirectoryPath(KnownDirectories directory)
|
||||
{
|
||||
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
|
||||
return dirFunc == default ? null : dirFunc.getPathFunc();
|
||||
}
|
||||
public static KnownDirectories GetKnownDirectory(string directory)
|
||||
{
|
||||
// especially important so a very early call doesn't match null => LibationFiles
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return KnownDirectories.None;
|
||||
[Description("My Documents")]
|
||||
MyDocs = 4,
|
||||
|
||||
// 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile.
|
||||
// since it's a list, order matters and non-LibationFiles will be returned first
|
||||
var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory);
|
||||
return dirFunc == default ? KnownDirectories.None : dirFunc.directory;
|
||||
}
|
||||
#endregion
|
||||
[Description("Your settings folder (aka: Libation Files)")]
|
||||
LibationFiles = 5
|
||||
}
|
||||
// use func calls so we always get the latest value of LibationFiles
|
||||
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
|
||||
{
|
||||
(KnownDirectories.None, () => null),
|
||||
(KnownDirectories.UserProfile, () => UserProfile),
|
||||
(KnownDirectories.AppDir, () => AppDir_Relative),
|
||||
(KnownDirectories.WinTemp, () => WinTemp),
|
||||
(KnownDirectories.MyDocs, () => MyDocs),
|
||||
// this is important to not let very early calls try to accidentally load LibationFiles too early.
|
||||
// also, keep this at bottom of this list
|
||||
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
|
||||
};
|
||||
public static string GetKnownDirectoryPath(KnownDirectories directory)
|
||||
{
|
||||
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
|
||||
return dirFunc == default ? null : dirFunc.getPathFunc();
|
||||
}
|
||||
public static KnownDirectories GetKnownDirectory(string directory)
|
||||
{
|
||||
// especially important so a very early call doesn't match null => LibationFiles
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return KnownDirectories.None;
|
||||
|
||||
#region logging
|
||||
private IConfigurationRoot configuration;
|
||||
// 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile.
|
||||
// since it's a list, order matters and non-LibationFiles will be returned first
|
||||
var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory);
|
||||
return dirFunc == default ? KnownDirectories.None : dirFunc.directory;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void ConfigureLogging()
|
||||
{
|
||||
configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration)
|
||||
.CreateLogger();
|
||||
}
|
||||
#region logging
|
||||
private IConfigurationRoot configuration;
|
||||
|
||||
[Description("The importance of a log event")]
|
||||
public LogEventLevel LogLevel
|
||||
{
|
||||
get
|
||||
{
|
||||
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
|
||||
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
|
||||
}
|
||||
set
|
||||
{
|
||||
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
|
||||
if (!valueWasChanged)
|
||||
{
|
||||
Log.Logger.Debug("LogLevel.set attempt. No change");
|
||||
return;
|
||||
}
|
||||
public void ConfigureLogging()
|
||||
{
|
||||
configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration)
|
||||
.CreateLogger();
|
||||
}
|
||||
|
||||
configuration.Reload();
|
||||
[Description("The importance of a log event")]
|
||||
public LogEventLevel LogLevel
|
||||
{
|
||||
get
|
||||
{
|
||||
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
|
||||
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
|
||||
}
|
||||
set
|
||||
{
|
||||
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
|
||||
if (!valueWasChanged)
|
||||
{
|
||||
Log.Logger.Debug("LogLevel.set attempt. No change");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new
|
||||
{
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled()
|
||||
});
|
||||
}
|
||||
}
|
||||
configuration.Reload();
|
||||
|
||||
Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new
|
||||
{
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled()
|
||||
});
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region singleton stuff
|
||||
public static Configuration Instance { get; } = new Configuration();
|
||||
private Configuration() { }
|
||||
#endregion
|
||||
private Configuration() { }
|
||||
#endregion
|
||||
|
||||
#region LibationFiles
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||
private const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
#region LibationFiles
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
|
||||
private const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
|
||||
[Description("Location for storage of program-created files")]
|
||||
public string LibationFiles
|
||||
{
|
||||
get
|
||||
{
|
||||
if (libationFilesPathCache is not null)
|
||||
return libationFilesPathCache;
|
||||
[Description("Location for storage of program-created files")]
|
||||
public string LibationFiles
|
||||
{
|
||||
get
|
||||
{
|
||||
if (libationFilesPathCache is not null)
|
||||
return libationFilesPathCache;
|
||||
|
||||
// FIRST: must write here before SettingsFilePath in next step reads cache
|
||||
libationFilesPathCache = getLiberationFilesSettingFromJson();
|
||||
// FIRST: must write here before SettingsFilePath in next step reads cache
|
||||
libationFilesPathCache = getLibationFilesSettingFromJson();
|
||||
|
||||
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
|
||||
persistentDictionary = new PersistentDictionary(SettingsFilePath);
|
||||
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
|
||||
persistentDictionary = new PersistentDictionary(SettingsFilePath);
|
||||
|
||||
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
|
||||
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
|
||||
var logPath = Path.Combine(LibationFiles, "Log.log");
|
||||
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
|
||||
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
|
||||
var logPath = Path.Combine(LibationFiles, "Log.log");
|
||||
|
||||
// BAD: Serilog.WriteTo[1].Args
|
||||
// "[1]" assumes ordinal position
|
||||
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
|
||||
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
|
||||
// BAD: Serilog.WriteTo[1].Args
|
||||
// "[1]" assumes ordinal position
|
||||
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
|
||||
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
|
||||
|
||||
SetWithJsonPath(jsonpath, "path", logPath, true);
|
||||
SetWithJsonPath(jsonpath, "path", logPath, true);
|
||||
|
||||
return libationFilesPathCache;
|
||||
}
|
||||
}
|
||||
return libationFilesPathCache;
|
||||
}
|
||||
}
|
||||
|
||||
private static string libationFilesPathCache;
|
||||
private static string libationFilesPathCache { get; set; }
|
||||
|
||||
private string getLiberationFilesSettingFromJson()
|
||||
{
|
||||
string startingContents = null;
|
||||
try
|
||||
{
|
||||
if (File.Exists(APPSETTINGS_JSON))
|
||||
{
|
||||
startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var startingJObj = JObject.Parse(startingContents);
|
||||
private string getLibationFilesSettingFromJson()
|
||||
{
|
||||
string startingContents = null;
|
||||
try
|
||||
{
|
||||
if (File.Exists(APPSETTINGS_JSON))
|
||||
{
|
||||
startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var startingJObj = JObject.Parse(startingContents);
|
||||
|
||||
if (startingJObj.ContainsKey(LIBATION_FILES_KEY))
|
||||
{
|
||||
var startingValue = startingJObj[LIBATION_FILES_KEY].Value<string>();
|
||||
if (!string.IsNullOrWhiteSpace(startingValue))
|
||||
return startingValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (startingJObj.ContainsKey(LIBATION_FILES_KEY))
|
||||
{
|
||||
var startingValue = startingJObj[LIBATION_FILES_KEY].Value<string>();
|
||||
if (!string.IsNullOrWhiteSpace(startingValue))
|
||||
return startingValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// not found. write to file. read from file
|
||||
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented);
|
||||
if (startingContents != endingContents)
|
||||
{
|
||||
File.WriteAllText(APPSETTINGS_JSON, endingContents);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
// not found. write to file. read from file
|
||||
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile.ToString() } }.ToString(Formatting.Indented);
|
||||
if (startingContents != endingContents)
|
||||
{
|
||||
File.WriteAllText(APPSETTINGS_JSON, endingContents);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
|
||||
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
|
||||
// verify from live file. no try/catch. want failures to be visible
|
||||
var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON));
|
||||
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
|
||||
return valueFinal;
|
||||
}
|
||||
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
|
||||
// verify from live file. no try/catch. want failures to be visible
|
||||
var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON));
|
||||
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
|
||||
return valueFinal;
|
||||
}
|
||||
|
||||
public void SetLibationFiles(string directory)
|
||||
{
|
||||
libationFilesPathCache = null;
|
||||
public void SetLibationFiles(string directory)
|
||||
{
|
||||
libationFilesPathCache = null;
|
||||
|
||||
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var jObj = JObject.Parse(startingContents);
|
||||
// ensure exists
|
||||
if (!File.Exists(APPSETTINGS_JSON))
|
||||
{
|
||||
// getter creates new file, loads PersistentDictionary
|
||||
var _ = LibationFiles;
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
|
||||
jObj[LIBATION_FILES_KEY] = directory;
|
||||
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
||||
var jObj = JObject.Parse(startingContents);
|
||||
|
||||
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
|
||||
if (startingContents == endingContents)
|
||||
return;
|
||||
jObj[LIBATION_FILES_KEY] = directory;
|
||||
|
||||
// now it's set in the file again but no settings have moved yet
|
||||
File.WriteAllText(APPSETTINGS_JSON, endingContents);
|
||||
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
|
||||
if (startingContents == endingContents)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
// now it's set in the file again but no settings have moved yet
|
||||
File.WriteAllText(APPSETTINGS_JSON, endingContents);
|
||||
|
||||
try
|
||||
{
|
||||
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dinah.Core.Collections.Immutable;
|
||||
using FileManager;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public static class FilePathCache
|
||||
{
|
||||
public record CacheEntry(string Id, FileType FileType, string Path);
|
||||
public record CacheEntry(string Id, FileType FileType, LongPath Path);
|
||||
|
||||
private const string FILENAME = "FileLocations.json";
|
||||
|
||||
@@ -18,7 +19,7 @@ namespace LibationFileManager
|
||||
|
||||
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
|
||||
|
||||
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
|
||||
private static LongPath jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
|
||||
|
||||
static FilePathCache()
|
||||
{
|
||||
@@ -44,12 +45,12 @@ namespace LibationFileManager
|
||||
|
||||
public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null;
|
||||
|
||||
public static List<(FileType fileType, string path)> GetFiles(string id)
|
||||
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
|
||||
=> getEntries(entry => entry.Id == id)
|
||||
.Select(entry => (entry.FileType, entry.Path))
|
||||
.ToList();
|
||||
|
||||
public static string GetFirstPath(string id, FileType type)
|
||||
public static LongPath GetFirstPath(string id, FileType type)
|
||||
=> getEntries(entry => entry.Id == id && entry.FileType == type)
|
||||
?.FirstOrDefault()
|
||||
?.Path;
|
||||
@@ -62,7 +63,7 @@ namespace LibationFileManager
|
||||
|
||||
remove(entries.Where(e => !File.Exists(e.Path)).ToList());
|
||||
|
||||
return entries;
|
||||
return cache.Where(predicate).ToList();
|
||||
}
|
||||
|
||||
private static void remove(List<CacheEntry> entries)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="8.1.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="8.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,277 +8,332 @@ using FileManager;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public abstract class Templates
|
||||
{
|
||||
protected static string[] Valid => Array.Empty<string>();
|
||||
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
|
||||
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
|
||||
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
|
||||
public abstract class Templates
|
||||
{
|
||||
protected static string[] Valid => Array.Empty<string>();
|
||||
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
|
||||
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
|
||||
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
|
||||
|
||||
public const string WARNING_EMPTY = "Template is empty.";
|
||||
public const string WARNING_WHITE_SPACE = "Template is white space.";
|
||||
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
|
||||
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
|
||||
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
|
||||
public const string WARNING_EMPTY = "Template is empty.";
|
||||
public const string WARNING_WHITE_SPACE = "Template is white space.";
|
||||
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
|
||||
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
|
||||
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
|
||||
|
||||
public static FolderTemplate Folder { get; } = new FolderTemplate();
|
||||
public static FileTemplate File { get; } = new FileTemplate();
|
||||
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
|
||||
public static FolderTemplate Folder { get; } = new FolderTemplate();
|
||||
public static FileTemplate File { get; } = new FileTemplate();
|
||||
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
|
||||
public static ChapterTitleTemplate ChapterTitle { get; } = new ChapterTitleTemplate();
|
||||
|
||||
public abstract string Name { get; }
|
||||
public abstract string Description { get; }
|
||||
public abstract string DefaultTemplate { get; }
|
||||
protected abstract bool IsChapterized { get; }
|
||||
public abstract string Name { get; }
|
||||
public abstract string Description { get; }
|
||||
public abstract string DefaultTemplate { get; }
|
||||
protected abstract bool IsChapterized { get; }
|
||||
|
||||
protected Templates() { }
|
||||
protected Templates() { }
|
||||
|
||||
#region validation
|
||||
internal string GetValid(string configValue)
|
||||
{
|
||||
var value = configValue?.Trim();
|
||||
return IsValid(value) ? value : DefaultTemplate;
|
||||
}
|
||||
{
|
||||
var value = configValue?.Trim();
|
||||
return IsValid(value) ? value : DefaultTemplate;
|
||||
}
|
||||
|
||||
public abstract IEnumerable<string> GetErrors(string template);
|
||||
public bool IsValid(string template) => !GetErrors(template).Any();
|
||||
public abstract IEnumerable<string> GetErrors(string template);
|
||||
public bool IsValid(string template) => !GetErrors(template).Any();
|
||||
|
||||
public abstract IEnumerable<string> GetWarnings(string template);
|
||||
public bool HasWarnings(string template) => GetWarnings(template).Any();
|
||||
public abstract IEnumerable<string> GetWarnings(string template);
|
||||
public bool HasWarnings(string template) => GetWarnings(template).Any();
|
||||
|
||||
protected static string[] GetFileErrors(string template)
|
||||
{
|
||||
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
|
||||
protected static string[] GetFileErrors(string template)
|
||||
{
|
||||
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
|
||||
|
||||
// null is invalid. whitespace is valid but not recommended
|
||||
if (template is null)
|
||||
return new[] { ERROR_NULL_IS_INVALID };
|
||||
// null is invalid. whitespace is valid but not recommended
|
||||
if (template is null)
|
||||
return new[] { ERROR_NULL_IS_INVALID };
|
||||
|
||||
if (template.Contains(':')
|
||||
|| template.Contains(Path.DirectorySeparatorChar)
|
||||
|| template.Contains(Path.AltDirectorySeparatorChar)
|
||||
)
|
||||
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
|
||||
if (ReplacementCharacters.ContainsInvalid(template.Replace("<","").Replace(">","")))
|
||||
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
|
||||
|
||||
return Valid;
|
||||
}
|
||||
return Valid;
|
||||
}
|
||||
|
||||
protected IEnumerable<string> GetStandardWarnings(string template)
|
||||
{
|
||||
var warnings = GetErrors(template).ToList();
|
||||
if (template is null)
|
||||
return warnings;
|
||||
protected IEnumerable<string> GetStandardWarnings(string template)
|
||||
{
|
||||
var warnings = GetErrors(template).ToList();
|
||||
if (template is null)
|
||||
return warnings;
|
||||
|
||||
if (string.IsNullOrEmpty(template))
|
||||
warnings.Add(WARNING_EMPTY);
|
||||
else if (string.IsNullOrWhiteSpace(template))
|
||||
warnings.Add(WARNING_WHITE_SPACE);
|
||||
if (string.IsNullOrEmpty(template))
|
||||
warnings.Add(WARNING_EMPTY);
|
||||
else if (string.IsNullOrWhiteSpace(template))
|
||||
warnings.Add(WARNING_WHITE_SPACE);
|
||||
|
||||
if (TagCount(template) == 0)
|
||||
warnings.Add(WARNING_NO_TAGS);
|
||||
if (TagCount(template) == 0)
|
||||
warnings.Add(WARNING_NO_TAGS);
|
||||
|
||||
if (!IsChapterized && ContainsChapterOnlyTags(template))
|
||||
warnings.Add(WARNING_HAS_CHAPTER_TAGS);
|
||||
if (!IsChapterized && ContainsChapterOnlyTags(template))
|
||||
warnings.Add(WARNING_HAS_CHAPTER_TAGS);
|
||||
|
||||
return warnings;
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
internal int TagCount(string template)
|
||||
=> GetTemplateTags()
|
||||
// for <id><id> == 1, use:
|
||||
// .Count(t => template.Contains($"<{t.TagName}>"))
|
||||
// .Sum() impl: <id><id> == 2
|
||||
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
|
||||
internal int TagCount(string template)
|
||||
=> GetTemplateTags()
|
||||
// for <id><id> == 1, use:
|
||||
// .Count(t => template.Contains($"<{t.TagName}>"))
|
||||
// .Sum() impl: <id><id> == 2
|
||||
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
|
||||
|
||||
internal static bool ContainsChapterOnlyTags(string template)
|
||||
=> TemplateTags.GetAll()
|
||||
.Where(t => t.IsChapterOnly)
|
||||
.Any(t => ContainsTag(template, t.TagName));
|
||||
internal static bool ContainsChapterOnlyTags(string template)
|
||||
=> TemplateTags.GetAll()
|
||||
.Where(t => t.IsChapterOnly)
|
||||
.Any(t => ContainsTag(template, t.TagName));
|
||||
|
||||
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
|
||||
#endregion
|
||||
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
|
||||
#endregion
|
||||
|
||||
#region to file name
|
||||
/// <summary>
|
||||
/// EditTemplateDialog: Get template generated filename for portion of path
|
||||
/// </summary>
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template)
|
||||
=> string.IsNullOrWhiteSpace(template)
|
||||
? ""
|
||||
: getFileNamingTemplate(libraryBookDto, template, null, null)
|
||||
.GetFilePath();
|
||||
#region to file name
|
||||
/// <summary>
|
||||
/// EditTemplateDialog: Get template generated filename for portion of path
|
||||
/// </summary>
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template)
|
||||
=> string.IsNullOrWhiteSpace(template)
|
||||
? ""
|
||||
: getFileNamingTemplate(libraryBookDto, template, null, null)
|
||||
.GetFilePath(Configuration.Instance.ReplacementCharacters).PathWithoutPrefix;
|
||||
|
||||
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", 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)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
|
||||
dirFullPath = dirFullPath?.Trim() ?? "";
|
||||
dirFullPath = dirFullPath?.Trim() ?? "";
|
||||
|
||||
// for non-series, remove <if series-> and <-if series> tags and everything in between
|
||||
// for series, remove <if series-> and <-if series> tags, what's in between will remain
|
||||
template = ifSeriesRegex.Replace(
|
||||
template,
|
||||
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
|
||||
// for non-series, remove <if series-> and <-if series> tags and everything in between
|
||||
// for series, remove <if series-> and <-if series> tags, what's in between will remain
|
||||
template = ifSeriesRegex.Replace(
|
||||
template,
|
||||
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
|
||||
|
||||
var t = template + FileUtility.GetStandardizedExtension(extension);
|
||||
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
|
||||
var t = template + FileUtility.GetStandardizedExtension(extension);
|
||||
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename);
|
||||
|
||||
var title = libraryBookDto.Title ?? "";
|
||||
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
|
||||
var title = libraryBookDto.Title ?? "";
|
||||
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
|
||||
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
|
||||
|
||||
return fileNamingTemplate;
|
||||
}
|
||||
#endregion
|
||||
return fileNamingTemplate;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public IEnumerable<TemplateTags> GetTemplateTags()
|
||||
=> TemplateTags.GetAll()
|
||||
// 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 virtual IEnumerable<TemplateTags> GetTemplateTags()
|
||||
=> TemplateTags.GetAll()
|
||||
// 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)
|
||||
{
|
||||
var value = template ?? "";
|
||||
public string Sanitize(string template)
|
||||
{
|
||||
var value = template ?? "";
|
||||
|
||||
// don't use alt slash
|
||||
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
// don't use alt slash
|
||||
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
// don't allow double slashes
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (value.Contains(dbl))
|
||||
value = value.Replace(dbl, sing);
|
||||
// don't allow double slashes
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
|
||||
while (value.Contains(dbl))
|
||||
value = value.Replace(dbl, sing);
|
||||
|
||||
// trim. don't start or end with slash
|
||||
while (true)
|
||||
{
|
||||
var start = value.Length;
|
||||
value = value
|
||||
.Trim()
|
||||
.Trim(Path.DirectorySeparatorChar);
|
||||
var end = value.Length;
|
||||
if (start == end)
|
||||
break;
|
||||
}
|
||||
// trim. don't start or end with slash
|
||||
while (true)
|
||||
{
|
||||
var start = value.Length;
|
||||
value = value
|
||||
.Trim()
|
||||
.Trim(Path.DirectorySeparatorChar);
|
||||
var end = value.Length;
|
||||
if (start == end)
|
||||
break;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public class FolderTemplate : Templates
|
||||
{
|
||||
public class FolderTemplate : Templates
|
||||
{
|
||||
public override string Name => "Folder Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title short> [<id>]";
|
||||
protected override bool IsChapterized { get; } = false;
|
||||
protected override bool IsChapterized { get; } = false;
|
||||
|
||||
internal FolderTemplate() : base() { }
|
||||
internal FolderTemplate() : base() { }
|
||||
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template)
|
||||
{
|
||||
// null is invalid. whitespace is valid but not recommended
|
||||
if (template is null)
|
||||
return new[] { ERROR_NULL_IS_INVALID };
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template)
|
||||
{
|
||||
// null is invalid. whitespace is valid but not recommended
|
||||
if (template is null)
|
||||
return new[] { ERROR_NULL_IS_INVALID };
|
||||
|
||||
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
|
||||
if (template.Contains(':'))
|
||||
return new[] { ERROR_FULL_PATH_IS_INVALID };
|
||||
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
|
||||
if (template.Contains(':'))
|
||||
return new[] { ERROR_FULL_PATH_IS_INVALID };
|
||||
|
||||
return Valid;
|
||||
}
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
|
||||
#endregion
|
||||
// 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.ContainsInvalid(template.Replace("<", "").Replace(">", "")))
|
||||
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
|
||||
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto)
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, AudibleFileStorage.BooksDirectory, null)
|
||||
.GetFilePath();
|
||||
#endregion
|
||||
}
|
||||
return Valid;
|
||||
}
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
|
||||
#endregion
|
||||
|
||||
public class FileTemplate : Templates
|
||||
{
|
||||
public override string Name => "File Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title> [<id>]";
|
||||
protected override bool IsChapterized { get; } = false;
|
||||
#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);
|
||||
#endregion
|
||||
}
|
||||
|
||||
internal FileTemplate() : base() { }
|
||||
public class FileTemplate : Templates
|
||||
{
|
||||
public override string Name => "File Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title> [<id>]";
|
||||
protected override bool IsChapterized { get; } = false;
|
||||
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
|
||||
internal FileTemplate() : base() { }
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
|
||||
#endregion
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
|
||||
|
||||
#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(returnFirstExisting);
|
||||
#endregion
|
||||
}
|
||||
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
|
||||
#endregion
|
||||
|
||||
public class ChapterFileTemplate : Templates
|
||||
{
|
||||
public override string Name => "Chapter File Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
|
||||
protected override bool IsChapterized { get; } = true;
|
||||
#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, returnFirstExisting);
|
||||
#endregion
|
||||
}
|
||||
|
||||
internal ChapterFileTemplate() : base() { }
|
||||
public class ChapterFileTemplate : Templates
|
||||
{
|
||||
public override string Name => "Chapter File Template";
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
|
||||
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
|
||||
protected override bool IsChapterized { get; } = true;
|
||||
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
|
||||
internal ChapterFileTemplate() : base() { }
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template)
|
||||
{
|
||||
var warnings = GetStandardWarnings(template).ToList();
|
||||
if (template is null)
|
||||
return warnings;
|
||||
#region validation
|
||||
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
|
||||
|
||||
// recommended to incl. <ch#> or <ch# 0>
|
||||
if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName))
|
||||
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
|
||||
public override IEnumerable<string> GetWarnings(string template)
|
||||
{
|
||||
var warnings = GetStandardWarnings(template).ToList();
|
||||
if (template is null)
|
||||
return warnings;
|
||||
|
||||
return warnings;
|
||||
}
|
||||
#endregion
|
||||
// recommended to incl. <ch#> or <ch# 0>
|
||||
if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName))
|
||||
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
|
||||
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
|
||||
return warnings;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath)
|
||||
{
|
||||
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
|
||||
|
||||
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 ?? "");
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null)
|
||||
{
|
||||
replacements ??= Configuration.Instance.ReplacementCharacters;
|
||||
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
|
||||
|
||||
return fileNamingTemplate.GetFilePath();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
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).PathWithoutPrefix;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class ChapterTitleTemplate : Templates
|
||||
{
|
||||
private List<TemplateTags> _templateTags { get; } = new()
|
||||
{
|
||||
TemplateTags.Title,
|
||||
TemplateTags.TitleShort,
|
||||
TemplateTags.Series,
|
||||
TemplateTags.ChCount,
|
||||
TemplateTags.ChNumber,
|
||||
TemplateTags.ChNumber0,
|
||||
TemplateTags.ChTitle,
|
||||
};
|
||||
public override string Name => "Chapter Title Template";
|
||||
|
||||
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
|
||||
|
||||
public override string DefaultTemplate => "<ch#> - <title short>: <ch title>";
|
||||
|
||||
protected override bool IsChapterized => true;
|
||||
|
||||
public override IEnumerable<string> GetErrors(string template)
|
||||
=> new List<string>();
|
||||
|
||||
public override IEnumerable<string> GetWarnings(string template)
|
||||
=> GetStandardWarnings(template).ToList();
|
||||
|
||||
public string GetTitle(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
|
||||
=> GetPortionTitle(libraryBookDto, Configuration.Instance.ChapterTitleTemplate, props);
|
||||
|
||||
public string GetPortionTitle(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
|
||||
var fileNamingTemplate = new MetadataNamingTemplate(template);
|
||||
|
||||
var title = libraryBookDto.Title ?? "";
|
||||
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
|
||||
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
|
||||
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.GetTagContents();
|
||||
}
|
||||
public override IEnumerable<TemplateTags> GetTemplateTags() => _templateTags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace LibationFileManager
|
||||
{
|
||||
public static class UtilityExtensions
|
||||
{
|
||||
public static void AddParameterReplacement(this FileNamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
|
||||
public static void AddParameterReplacement(this NamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
|
||||
=> fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,11 @@ namespace LibationSearchEngine
|
||||
{
|
||||
public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30;
|
||||
|
||||
// not customizable. don't move to config
|
||||
private static string SearchEngineDirectory { get; }
|
||||
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
|
||||
|
||||
public const string _ID_ = "_ID_";
|
||||
public const string TAGS = "tags";
|
||||
// special field for each book which includes all major parts of the book's metadata. enables non-targetting searching
|
||||
public const string ALL = "all";
|
||||
|
||||
// the workaround which allows displaying all books when query is empty
|
||||
public const string ALL_QUERY = "*:*";
|
||||
|
||||
#region index rules
|
||||
// common fields used in the "all" default search field
|
||||
public const string ALL_AUDIBLE_PRODUCT_ID = nameof(Book.AudibleProductId);
|
||||
@@ -124,15 +117,16 @@ namespace LibationSearchEngine
|
||||
[nameof(Book.IsAbridged)] = lb => lb.Book.IsAbridged,
|
||||
["Abridged"] = lb => lb.Book.IsAbridged,
|
||||
|
||||
// this will only be evaluated at time of re-index. ie: state of files moved later will be out of sync until next re-index
|
||||
["IsLiberated"] = lb => isLiberated(lb.Book),
|
||||
["Liberated"] = lb => isLiberated(lb.Book),
|
||||
["LiberatedError"] = lb => liberatedError(lb.Book),
|
||||
|
||||
["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode,
|
||||
["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode,
|
||||
["Episode"] = lb => lb.Book.ContentType == ContentType.Episode,
|
||||
["IsEpisode"] = lb => lb.Book.ContentType == ContentType.Episode,
|
||||
["Podcast"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["Podcasts"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["IsPodcast"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["Episode"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["Episodes"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["IsEpisode"] = lb => lb.Book.IsEpisodeChild(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -182,18 +176,6 @@ namespace LibationSearchEngine
|
||||
foreach (var key in numberIndexRules.Keys)
|
||||
yield return key;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetSearchFields()
|
||||
{
|
||||
foreach (var key in idIndexRules.Keys)
|
||||
yield return key;
|
||||
foreach (var key in stringIndexRules.Keys)
|
||||
yield return key;
|
||||
foreach (var key in boolIndexRules.Keys)
|
||||
yield return key;
|
||||
foreach (var key in numberIndexRules.Keys)
|
||||
yield return key;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region create and update index
|
||||
@@ -290,6 +272,10 @@ namespace LibationSearchEngine
|
||||
book.AudibleProductId,
|
||||
d =>
|
||||
{
|
||||
//
|
||||
// TODO: better synonym handling. This is too easy to mess up
|
||||
//
|
||||
|
||||
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
|
||||
// ie: must remove old before adding new else will create unwanted duplicates.
|
||||
var v1 = isLiberated(book);
|
||||
@@ -331,6 +317,9 @@ namespace LibationSearchEngine
|
||||
}
|
||||
#endregion
|
||||
|
||||
// the workaround which allows displaying all books when query is empty
|
||||
public const string ALL_QUERY = "*:*";
|
||||
|
||||
#region search
|
||||
public SearchResultSet Search(string searchString)
|
||||
{
|
||||
@@ -345,7 +334,7 @@ namespace LibationSearchEngine
|
||||
return results;
|
||||
}
|
||||
|
||||
public static string FormatSearchQuery(string searchString)
|
||||
internal static string FormatSearchQuery(string searchString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
return ALL_QUERY;
|
||||
@@ -491,5 +480,9 @@ namespace LibationSearchEngine
|
||||
#endregion
|
||||
|
||||
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
|
||||
|
||||
// not customizable. don't move to config
|
||||
private static string SearchEngineDirectory { get; }
|
||||
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
using DataLayer;
|
||||
using System;
|
||||
|
||||
namespace LibationWinForms.BookLiberation
|
||||
{
|
||||
partial class DownloadForm
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.filenameLbl = new System.Windows.Forms.Label();
|
||||
this.progressBar1 = new System.Windows.Forms.ProgressBar();
|
||||
this.progressLbl = new System.Windows.Forms.Label();
|
||||
this.lastUpdateLbl = new System.Windows.Forms.Label();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// filenameLbl
|
||||
//
|
||||
this.filenameLbl.AutoSize = true;
|
||||
this.filenameLbl.Location = new System.Drawing.Point(12, 9);
|
||||
this.filenameLbl.Name = "filenameLbl";
|
||||
this.filenameLbl.Size = new System.Drawing.Size(52, 13);
|
||||
this.filenameLbl.TabIndex = 0;
|
||||
this.filenameLbl.Text = "[filename]";
|
||||
//
|
||||
// progressBar1
|
||||
//
|
||||
this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.progressBar1.Location = new System.Drawing.Point(15, 67);
|
||||
this.progressBar1.Name = "progressBar1";
|
||||
this.progressBar1.Size = new System.Drawing.Size(877, 23);
|
||||
this.progressBar1.TabIndex = 4;
|
||||
//
|
||||
// progressLbl
|
||||
//
|
||||
this.progressLbl.Location = new System.Drawing.Point(12, 36);
|
||||
this.progressLbl.Name = "progressLbl";
|
||||
this.progressLbl.Size = new System.Drawing.Size(173, 13);
|
||||
this.progressLbl.TabIndex = 5;
|
||||
this.progressLbl.Text = "[2,999,999,999] of [2,999,999,999]";
|
||||
this.progressLbl.TextAlign = System.Drawing.ContentAlignment.TopRight;
|
||||
//
|
||||
// lastUpdateLbl
|
||||
//
|
||||
this.lastUpdateLbl.AutoSize = true;
|
||||
this.lastUpdateLbl.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
|
||||
this.lastUpdateLbl.ForeColor = System.Drawing.Color.DarkRed;
|
||||
this.lastUpdateLbl.Location = new System.Drawing.Point(361, 36);
|
||||
this.lastUpdateLbl.Name = "lastUpdateLbl";
|
||||
this.lastUpdateLbl.Size = new System.Drawing.Size(81, 13);
|
||||
this.lastUpdateLbl.TabIndex = 6;
|
||||
this.lastUpdateLbl.Text = "Last updated";
|
||||
this.lastUpdateLbl.Visible = false;
|
||||
//
|
||||
// DownloadForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(904, 102);
|
||||
this.Controls.Add(this.lastUpdateLbl);
|
||||
this.Controls.Add(this.progressLbl);
|
||||
this.Controls.Add(this.progressBar1);
|
||||
this.Controls.Add(this.filenameLbl);
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = false;
|
||||
this.Name = "DownloadForm";
|
||||
this.ShowIcon = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Downloading";
|
||||
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.DownloadForm_FormClosing);
|
||||
this.Load += new System.EventHandler(this.DownloadForm_Load);
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Label filenameLbl;
|
||||
private System.Windows.Forms.ProgressBar progressBar1;
|
||||
private System.Windows.Forms.Label progressLbl;
|
||||
private System.Windows.Forms.Label lastUpdateLbl;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.Threading;
|
||||
using FileLiberator;
|
||||
|
||||
namespace LibationWinForms.BookLiberation
|
||||
{
|
||||
public partial class DownloadForm : Form
|
||||
{
|
||||
protected Streamable Streamable { get; private set; }
|
||||
protected LogMe LogMe { get; private set; }
|
||||
private SynchronizeInvoker Invoker { get; init; }
|
||||
|
||||
public DownloadForm()
|
||||
{
|
||||
//SynchronizationContext.Current will be null until the process contains a Form.
|
||||
//If this is the first form created, it will not exist until after execution
|
||||
//reaches inside the constructor (after base class has been initialized).
|
||||
Invoker = new SynchronizeInvoker();
|
||||
InitializeComponent();
|
||||
|
||||
this.SetLibationIcon();
|
||||
progressLbl.Text = "";
|
||||
filenameLbl.Text = "";
|
||||
}
|
||||
|
||||
public void RegisterFileLiberator(Streamable streamable, LogMe logMe = null)
|
||||
{
|
||||
if (streamable is null) return;
|
||||
streamable.StreamingBegin += Streamable_StreamingBegin;
|
||||
streamable.StreamingProgressChanged += Streamable_StreamingProgressChanged;
|
||||
streamable.StreamingCompleted += (_, _) => this.UIThreadAsync(Close);
|
||||
Streamable = streamable;
|
||||
LogMe = logMe;
|
||||
}
|
||||
|
||||
|
||||
#region Streamable event handler overrides
|
||||
public void Streamable_StreamingBegin(object sender, string beginString)
|
||||
{
|
||||
Invoker.UIThreadAsync(Show);
|
||||
filenameLbl.UIThreadAsync(() => filenameLbl.Text = beginString);
|
||||
}
|
||||
public void Streamable_StreamingProgressChanged(object sender, DownloadProgress downloadProgress)
|
||||
{
|
||||
// this won't happen with download file. it will happen with download string
|
||||
if (!downloadProgress.TotalBytesToReceive.HasValue || downloadProgress.TotalBytesToReceive.Value <= 0)
|
||||
return;
|
||||
|
||||
progressLbl.UIThreadAsync(() => progressLbl.Text = $"{downloadProgress.BytesReceived:#,##0} of {downloadProgress.TotalBytesToReceive.Value:#,##0}");
|
||||
|
||||
var d = double.Parse(downloadProgress.BytesReceived.ToString()) / double.Parse(downloadProgress.TotalBytesToReceive.Value.ToString()) * 100.0;
|
||||
var i = int.Parse(Math.Truncate(d).ToString());
|
||||
progressBar1.UIThreadAsync(() => progressBar1.Value = i);
|
||||
|
||||
lastDownloadProgress = DateTime.Now;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region timer
|
||||
private Timer timer { get; } = new Timer { Interval = 1000 };
|
||||
private void DownloadForm_Load(object sender, EventArgs e)
|
||||
{
|
||||
timer.Tick += new EventHandler(timer_Tick);
|
||||
timer.Start();
|
||||
}
|
||||
private DateTime lastDownloadProgress = DateTime.Now;
|
||||
private void timer_Tick(object sender, EventArgs e)
|
||||
{
|
||||
// if no update in the last 30 seconds, display frozen label
|
||||
lastUpdateLbl.UIThreadAsync(() => lastUpdateLbl.Visible = lastDownloadProgress.AddSeconds(30) < DateTime.Now);
|
||||
if (lastUpdateLbl.Visible)
|
||||
{
|
||||
var diff = DateTime.Now - lastDownloadProgress;
|
||||
var min = (int)diff.TotalMinutes;
|
||||
var minText = min > 0 ? $"{min}min " : "";
|
||||
|
||||
lastUpdateLbl.UIThreadAsync(() => lastUpdateLbl.Text = $"Frozen? Last download activity: {minText}{diff.Seconds}sec ago");
|
||||
}
|
||||
}
|
||||
private void DownloadForm_FormClosing(object sender, FormClosingEventArgs e) => timer.Stop();
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using FileLiberator;
|
||||
|
||||
namespace LibationWinForms.BookLiberation
|
||||
{
|
||||
// decouple serilog and form. include convenience factory method
|
||||
public class LogMe
|
||||
{
|
||||
public event EventHandler<string> LogInfo;
|
||||
public event EventHandler<string> LogErrorString;
|
||||
public event EventHandler<(Exception, string)> LogError;
|
||||
|
||||
private LogMe()
|
||||
{
|
||||
LogInfo += (_, text) => Serilog.Log.Logger.Information($"Automated backup: {text}");
|
||||
LogErrorString += (_, text) => Serilog.Log.Logger.Error(text);
|
||||
LogError += (_, tuple) => Serilog.Log.Logger.Error(tuple.Item1, tuple.Item2 ?? "Automated backup: error");
|
||||
}
|
||||
|
||||
public static LogMe RegisterForm<T>(T form) where T : ILogForm
|
||||
{
|
||||
var logMe = new LogMe();
|
||||
|
||||
if (form is null)
|
||||
return logMe;
|
||||
|
||||
logMe.LogInfo += (_, text) => form?.WriteLine(text);
|
||||
|
||||
logMe.LogErrorString += (_, text) => form?.WriteLine(text);
|
||||
|
||||
logMe.LogError += (_, tuple) =>
|
||||
{
|
||||
form?.WriteLine(tuple.Item2 ?? "Automated backup: error");
|
||||
form?.WriteLine("ERROR: " + tuple.Item1.Message);
|
||||
};
|
||||
|
||||
return logMe;
|
||||
}
|
||||
|
||||
public void Info(string text) => LogInfo?.Invoke(this, text);
|
||||
public void Error(string text) => LogErrorString?.Invoke(this, text);
|
||||
public void Error(Exception ex, string text = null) => LogError?.Invoke(this, (ex, text));
|
||||
}
|
||||
|
||||
public static class ProcessorAutomationController
|
||||
{
|
||||
public static void DownloadFile(string url, string destination, bool showDownloadCompletedDialog = false)
|
||||
{
|
||||
Serilog.Log.Logger.Information($"Begin {nameof(DownloadFile)} for {url}");
|
||||
|
||||
void onDownloadFileStreamingCompleted(object sender, string savedFile)
|
||||
{
|
||||
Serilog.Log.Logger.Information($"Completed {nameof(DownloadFile)} for {url}. Saved to {savedFile}");
|
||||
|
||||
if (showDownloadCompletedDialog)
|
||||
MessageBox.Show($"File downloaded to:{Environment.NewLine}{Environment.NewLine}{savedFile}");
|
||||
}
|
||||
|
||||
var downloadFile = new DownloadFile();
|
||||
var downloadForm = new DownloadForm();
|
||||
downloadForm.RegisterFileLiberator(downloadFile);
|
||||
downloadFile.StreamingCompleted += onDownloadFileStreamingCompleted;
|
||||
|
||||
async void runDownload() => await downloadFile.PerformDownloadFileAsync(url, destination);
|
||||
new Task(runDownload).Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@
|
||||
this.cancelBtn = new System.Windows.Forms.Button();
|
||||
this.saveBtn = new System.Windows.Forms.Button();
|
||||
this.dataGridView1 = new System.Windows.Forms.DataGridView();
|
||||
this.importBtn = new System.Windows.Forms.Button();
|
||||
this.DeleteAccount = new System.Windows.Forms.DataGridViewButtonColumn();
|
||||
this.ExportAccount = new System.Windows.Forms.DataGridViewButtonColumn();
|
||||
this.LibraryScan = new System.Windows.Forms.DataGridViewCheckBoxColumn();
|
||||
this.AccountId = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.Locale = new System.Windows.Forms.DataGridViewComboBoxColumn();
|
||||
@@ -43,9 +45,10 @@
|
||||
//
|
||||
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
this.cancelBtn.Location = new System.Drawing.Point(713, 415);
|
||||
this.cancelBtn.Location = new System.Drawing.Point(832, 479);
|
||||
this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.cancelBtn.Name = "cancelBtn";
|
||||
this.cancelBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.cancelBtn.Size = new System.Drawing.Size(88, 27);
|
||||
this.cancelBtn.TabIndex = 2;
|
||||
this.cancelBtn.Text = "Cancel";
|
||||
this.cancelBtn.UseVisualStyleBackColor = true;
|
||||
@@ -54,9 +57,10 @@
|
||||
// saveBtn
|
||||
//
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveBtn.Location = new System.Drawing.Point(612, 415);
|
||||
this.saveBtn.Location = new System.Drawing.Point(714, 479);
|
||||
this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
this.saveBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.saveBtn.Size = new System.Drawing.Size(88, 27);
|
||||
this.saveBtn.TabIndex = 1;
|
||||
this.saveBtn.Text = "Save";
|
||||
this.saveBtn.UseVisualStyleBackColor = true;
|
||||
@@ -71,60 +75,83 @@
|
||||
this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||
this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
|
||||
this.DeleteAccount,
|
||||
this.ExportAccount,
|
||||
this.LibraryScan,
|
||||
this.AccountId,
|
||||
this.Locale,
|
||||
this.AccountName});
|
||||
this.dataGridView1.Location = new System.Drawing.Point(12, 12);
|
||||
this.dataGridView1.Location = new System.Drawing.Point(14, 14);
|
||||
this.dataGridView1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.dataGridView1.MultiSelect = false;
|
||||
this.dataGridView1.Name = "dataGridView1";
|
||||
this.dataGridView1.Size = new System.Drawing.Size(776, 397);
|
||||
this.dataGridView1.Size = new System.Drawing.Size(905, 458);
|
||||
this.dataGridView1.TabIndex = 0;
|
||||
this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick);
|
||||
this.dataGridView1.DefaultValuesNeeded += new System.Windows.Forms.DataGridViewRowEventHandler(this.dataGridView1_DefaultValuesNeeded);
|
||||
//
|
||||
// importBtn
|
||||
//
|
||||
this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.importBtn.Location = new System.Drawing.Point(14, 480);
|
||||
this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.importBtn.Name = "importBtn";
|
||||
this.importBtn.Size = new System.Drawing.Size(156, 27);
|
||||
this.importBtn.TabIndex = 1;
|
||||
this.importBtn.Text = "Import from audible-cli";
|
||||
this.importBtn.UseVisualStyleBackColor = true;
|
||||
this.importBtn.Click += new System.EventHandler(this.importBtn_Click);
|
||||
//
|
||||
// DeleteAccount
|
||||
//
|
||||
this.DeleteAccount.HeaderText = "Delete";
|
||||
this.DeleteAccount.Name = "DeleteAccount";
|
||||
this.DeleteAccount.ReadOnly = true;
|
||||
this.DeleteAccount.Text = "x";
|
||||
this.DeleteAccount.Width = 44;
|
||||
this.DeleteAccount.Width = 46;
|
||||
//
|
||||
// ExportAccount
|
||||
//
|
||||
this.ExportAccount.HeaderText = "Export";
|
||||
this.ExportAccount.Name = "ExportAccount";
|
||||
this.ExportAccount.Text = "Export to audible-cli";
|
||||
this.ExportAccount.Width = 47;
|
||||
//
|
||||
// LibraryScan
|
||||
//
|
||||
this.LibraryScan.HeaderText = "Include in library scan?";
|
||||
this.LibraryScan.Name = "LibraryScan";
|
||||
this.LibraryScan.Width = 83;
|
||||
this.LibraryScan.Width = 94;
|
||||
//
|
||||
// AccountId
|
||||
//
|
||||
this.AccountId.HeaderText = "Audible email/login";
|
||||
this.AccountId.Name = "AccountId";
|
||||
this.AccountId.Width = 111;
|
||||
this.AccountId.Width = 125;
|
||||
//
|
||||
// Locale
|
||||
//
|
||||
this.Locale.HeaderText = "Locale";
|
||||
this.Locale.Name = "Locale";
|
||||
this.Locale.Width = 45;
|
||||
this.Locale.Width = 47;
|
||||
//
|
||||
// AccountName
|
||||
//
|
||||
this.AccountName.HeaderText = "Account nickname (optional)";
|
||||
this.AccountName.Name = "AccountName";
|
||||
this.AccountName.Width = 152;
|
||||
this.AccountName.Width = 170;
|
||||
//
|
||||
// AccountsDialog
|
||||
//
|
||||
this.AcceptButton = this.saveBtn;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.CancelButton = this.cancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
this.ClientSize = new System.Drawing.Size(933, 519);
|
||||
this.Controls.Add(this.dataGridView1);
|
||||
this.Controls.Add(this.importBtn);
|
||||
this.Controls.Add(this.saveBtn);
|
||||
this.Controls.Add(this.cancelBtn);
|
||||
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.Name = "AccountsDialog";
|
||||
this.Text = "Audible Accounts";
|
||||
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
|
||||
@@ -137,7 +164,9 @@
|
||||
private System.Windows.Forms.Button cancelBtn;
|
||||
private System.Windows.Forms.Button saveBtn;
|
||||
private System.Windows.Forms.DataGridView dataGridView1;
|
||||
private System.Windows.Forms.Button importBtn;
|
||||
private System.Windows.Forms.DataGridViewButtonColumn DeleteAccount;
|
||||
private System.Windows.Forms.DataGridViewButtonColumn ExportAccount;
|
||||
private System.Windows.Forms.DataGridViewCheckBoxColumn LibraryScan;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn AccountId;
|
||||
private System.Windows.Forms.DataGridViewComboBoxColumn Locale;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using AudibleApi;
|
||||
@@ -10,6 +11,7 @@ namespace LibationWinForms.Dialogs
|
||||
public partial class AccountsDialog : Form
|
||||
{
|
||||
private const string COL_Delete = nameof(DeleteAccount);
|
||||
private const string COL_Export = nameof(ExportAccount);
|
||||
private const string COL_LibraryScan = nameof(LibraryScan);
|
||||
private const string COL_AccountId = nameof(AccountId);
|
||||
private const string COL_AccountName = nameof(AccountName);
|
||||
@@ -44,12 +46,20 @@ namespace LibationWinForms.Dialogs
|
||||
return;
|
||||
|
||||
foreach (var account in accounts)
|
||||
dataGridView1.Rows.Add(
|
||||
AddAccountToGrid(account);
|
||||
}
|
||||
|
||||
private void AddAccountToGrid(Account account)
|
||||
{
|
||||
int row = dataGridView1.Rows.Add(
|
||||
"X",
|
||||
"Export",
|
||||
account.LibraryScan,
|
||||
account.AccountId,
|
||||
account.Locale.Name,
|
||||
account.AccountName);
|
||||
|
||||
dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli";
|
||||
}
|
||||
|
||||
private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e)
|
||||
@@ -73,6 +83,11 @@ namespace LibationWinForms.Dialogs
|
||||
if (e.RowIndex < dgv.RowCount - 1)
|
||||
dgv.Rows.Remove(row);
|
||||
break;
|
||||
case COL_Export:
|
||||
// if final/edit row: do nothing
|
||||
if (e.RowIndex < dgv.RowCount - 1)
|
||||
Export((string)row.Cells[COL_AccountId].Value, (string)row.Cells[COL_Locale].Value);
|
||||
break;
|
||||
//case COL_MoveUp:
|
||||
// // if top: do nothing
|
||||
// if (e.RowIndex < 1)
|
||||
@@ -124,7 +139,7 @@ namespace LibationWinForms.Dialogs
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert("Error attempting to save accounts", "Error saving accounts", ex);
|
||||
MessageBoxLib.ShowAdminAlert(this, "Error attempting to save accounts", "Error saving accounts", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +151,13 @@ namespace LibationWinForms.Dialogs
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.AccountId))
|
||||
{
|
||||
MessageBox.Show("Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.LocaleName))
|
||||
{
|
||||
MessageBox.Show("Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -194,5 +209,95 @@ namespace LibationWinForms.Dialogs
|
||||
LibraryScan = (bool)r.Cells[COL_LibraryScan].Value
|
||||
})
|
||||
.ToList();
|
||||
|
||||
private string GetAudibleCliAppDataPath()
|
||||
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible");
|
||||
|
||||
private void Export(string accountId, string locale)
|
||||
{
|
||||
// without transaction, accounts persister will write ANY EDIT immediately to file
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountId && a.Locale.Name == locale);
|
||||
|
||||
if (account is null)
|
||||
return;
|
||||
|
||||
if (account.IdentityTokens?.IsValid != true)
|
||||
{
|
||||
MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
SaveFileDialog sfd = new();
|
||||
sfd.Filter = "JSON File|*.json";
|
||||
|
||||
string audibleAppDataDir = GetAudibleCliAppDataPath();
|
||||
|
||||
if (Directory.Exists(audibleAppDataDir))
|
||||
sfd.InitialDirectory = audibleAppDataDir;
|
||||
|
||||
if (sfd.ShowDialog() != DialogResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
var mkbAuth = Mkb79Auth.FromAccount(account);
|
||||
var jsonText = mkbAuth.ToJson();
|
||||
|
||||
File.WriteAllText(sfd.FileName, jsonText);
|
||||
|
||||
MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(
|
||||
this,
|
||||
$"An error occurred while exporting account:\r\n{account.AccountName}",
|
||||
"Error Exporting Account",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void importBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
OpenFileDialog ofd = new();
|
||||
ofd.Filter = "JSON File|*.json";
|
||||
ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
|
||||
string audibleAppDataDir = GetAudibleCliAppDataPath();
|
||||
|
||||
if (Directory.Exists(audibleAppDataDir))
|
||||
ofd.InitialDirectory = audibleAppDataDir;
|
||||
|
||||
if (ofd.ShowDialog() != DialogResult.OK) return;
|
||||
|
||||
try
|
||||
{
|
||||
var jsonText = File.ReadAllText(ofd.FileName);
|
||||
var mkbAuth = Mkb79Auth.FromJson(jsonText);
|
||||
var account = await mkbAuth.ToAccountAsync();
|
||||
|
||||
// without transaction, accounts persister will write ANY EDIT immediately to file
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name))
|
||||
{
|
||||
MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account");
|
||||
return;
|
||||
}
|
||||
|
||||
persister.AccountsSettings.Add(account);
|
||||
|
||||
AddAccountToGrid(account);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(
|
||||
this,
|
||||
$"An error occurred while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?",
|
||||
"Error Importing Account",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
@@ -58,10 +57,10 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="Original.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<metadata name="DeleteAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="DeleteAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<metadata name="ExportAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="LibraryScan.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
@@ -70,10 +69,10 @@
|
||||
<metadata name="AccountId.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="AccountName.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="Locale.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="AccountName.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
</root>
|
||||
171
Source/LibationWinForms/Dialogs/EditReplacementChars.Designer.cs
generated
Normal file
@@ -0,0 +1,171 @@
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
partial class EditReplacementChars
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.dataGridView1 = new System.Windows.Forms.DataGridView();
|
||||
this.defaultsBtn = new System.Windows.Forms.Button();
|
||||
this.loFiDefaultsBtn = new System.Windows.Forms.Button();
|
||||
this.saveBtn = new System.Windows.Forms.Button();
|
||||
this.cancelBtn = new System.Windows.Forms.Button();
|
||||
this.minDefaultBtn = new System.Windows.Forms.Button();
|
||||
this.charToReplaceCol = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.replacementStringCol = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.descriptionCol = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// dataGridView1
|
||||
//
|
||||
this.dataGridView1.AllowUserToResizeColumns = false;
|
||||
this.dataGridView1.AllowUserToResizeRows = false;
|
||||
this.dataGridView1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||
this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
|
||||
this.charToReplaceCol,
|
||||
this.replacementStringCol,
|
||||
this.descriptionCol});
|
||||
this.dataGridView1.Location = new System.Drawing.Point(12, 12);
|
||||
this.dataGridView1.Name = "dataGridView1";
|
||||
this.dataGridView1.RowTemplate.Height = 25;
|
||||
this.dataGridView1.Size = new System.Drawing.Size(498, 393);
|
||||
this.dataGridView1.TabIndex = 0;
|
||||
this.dataGridView1.CellEndEdit += new System.Windows.Forms.DataGridViewCellEventHandler(this.dataGridView1_CellEndEdit);
|
||||
this.dataGridView1.UserDeletingRow += new System.Windows.Forms.DataGridViewRowCancelEventHandler(this.dataGridView1_UserDeletingRow);
|
||||
this.dataGridView1.Resize += new System.EventHandler(this.dataGridView1_Resize);
|
||||
//
|
||||
// defaultsBtn
|
||||
//
|
||||
this.defaultsBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.defaultsBtn.Location = new System.Drawing.Point(12, 430);
|
||||
this.defaultsBtn.Name = "defaultsBtn";
|
||||
this.defaultsBtn.Size = new System.Drawing.Size(64, 25);
|
||||
this.defaultsBtn.TabIndex = 1;
|
||||
this.defaultsBtn.Text = "Defaults";
|
||||
this.defaultsBtn.UseVisualStyleBackColor = true;
|
||||
this.defaultsBtn.Click += new System.EventHandler(this.defaultsBtn_Click);
|
||||
//
|
||||
// loFiDefaultsBtn
|
||||
//
|
||||
this.loFiDefaultsBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.loFiDefaultsBtn.Location = new System.Drawing.Point(82, 430);
|
||||
this.loFiDefaultsBtn.Name = "loFiDefaultsBtn";
|
||||
this.loFiDefaultsBtn.Size = new System.Drawing.Size(84, 25);
|
||||
this.loFiDefaultsBtn.TabIndex = 1;
|
||||
this.loFiDefaultsBtn.Text = "LoFi Defaults";
|
||||
this.loFiDefaultsBtn.UseVisualStyleBackColor = true;
|
||||
this.loFiDefaultsBtn.Click += new System.EventHandler(this.loFiDefaultsBtn_Click);
|
||||
//
|
||||
// saveBtn
|
||||
//
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveBtn.Location = new System.Drawing.Point(428, 430);
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
this.saveBtn.Size = new System.Drawing.Size(82, 25);
|
||||
this.saveBtn.TabIndex = 1;
|
||||
this.saveBtn.Text = "Save";
|
||||
this.saveBtn.UseVisualStyleBackColor = true;
|
||||
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
|
||||
//
|
||||
// cancelBtn
|
||||
//
|
||||
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.cancelBtn.Location = new System.Drawing.Point(340, 430);
|
||||
this.cancelBtn.Name = "cancelBtn";
|
||||
this.cancelBtn.Size = new System.Drawing.Size(82, 25);
|
||||
this.cancelBtn.TabIndex = 1;
|
||||
this.cancelBtn.Text = "Cancel";
|
||||
this.cancelBtn.UseVisualStyleBackColor = true;
|
||||
this.cancelBtn.Click += new System.EventHandler(this.cancelBtn_Click);
|
||||
//
|
||||
// minDefaultBtn
|
||||
//
|
||||
this.minDefaultBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.minDefaultBtn.Location = new System.Drawing.Point(172, 430);
|
||||
this.minDefaultBtn.Name = "minDefaultBtn";
|
||||
this.minDefaultBtn.Size = new System.Drawing.Size(80, 25);
|
||||
this.minDefaultBtn.TabIndex = 1;
|
||||
this.minDefaultBtn.Text = "Barebones";
|
||||
this.minDefaultBtn.UseVisualStyleBackColor = true;
|
||||
this.minDefaultBtn.Click += new System.EventHandler(this.minDefaultBtn_Click);
|
||||
//
|
||||
// charToReplaceCol
|
||||
//
|
||||
this.charToReplaceCol.HeaderText = "Char to Replace";
|
||||
this.charToReplaceCol.MinimumWidth = 70;
|
||||
this.charToReplaceCol.Name = "charToReplaceCol";
|
||||
this.charToReplaceCol.Resizable = System.Windows.Forms.DataGridViewTriState.False;
|
||||
this.charToReplaceCol.Width = 70;
|
||||
//
|
||||
// replacementStringCol
|
||||
//
|
||||
this.replacementStringCol.HeaderText = "Replacement Text";
|
||||
this.replacementStringCol.MinimumWidth = 85;
|
||||
this.replacementStringCol.Name = "replacementStringCol";
|
||||
this.replacementStringCol.Width = 85;
|
||||
//
|
||||
// descriptionCol
|
||||
//
|
||||
this.descriptionCol.HeaderText = "Description";
|
||||
this.descriptionCol.MinimumWidth = 100;
|
||||
this.descriptionCol.Name = "descriptionCol";
|
||||
this.descriptionCol.Width = 200;
|
||||
//
|
||||
// EditReplacementChars
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(522, 467);
|
||||
this.Controls.Add(this.minDefaultBtn);
|
||||
this.Controls.Add(this.loFiDefaultsBtn);
|
||||
this.Controls.Add(this.cancelBtn);
|
||||
this.Controls.Add(this.saveBtn);
|
||||
this.Controls.Add(this.defaultsBtn);
|
||||
this.Controls.Add(this.dataGridView1);
|
||||
this.Name = "EditReplacementChars";
|
||||
this.Text = "Character Replacements";
|
||||
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.DataGridView dataGridView1;
|
||||
private System.Windows.Forms.Button defaultsBtn;
|
||||
private System.Windows.Forms.Button loFiDefaultsBtn;
|
||||
private System.Windows.Forms.Button saveBtn;
|
||||
private System.Windows.Forms.Button cancelBtn;
|
||||
private System.Windows.Forms.Button minDefaultBtn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn charToReplaceCol;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn replacementStringCol;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn descriptionCol;
|
||||
}
|
||||
}
|
||||
144
Source/LibationWinForms/Dialogs/EditReplacementChars.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class EditReplacementChars : Form
|
||||
{
|
||||
Configuration config;
|
||||
public EditReplacementChars()
|
||||
{
|
||||
InitializeComponent();
|
||||
dataGridView1_Resize(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public EditReplacementChars(Configuration config) : this()
|
||||
{
|
||||
this.config = config;
|
||||
LoadTable(config.ReplacementCharacters.Replacements);
|
||||
}
|
||||
|
||||
private void LoadTable(IReadOnlyList<Replacement> replacements)
|
||||
{
|
||||
dataGridView1.Rows.Clear();
|
||||
for (int i = 0; i < replacements.Count; i++)
|
||||
{
|
||||
var r = replacements[i];
|
||||
|
||||
int row = dataGridView1.Rows.Add(r.CharacterToReplace, r.ReplacementString, r.Description);
|
||||
dataGridView1.Rows[row].Tag = r;
|
||||
|
||||
|
||||
if (r.Mandatory)
|
||||
{
|
||||
dataGridView1.Rows[row].Cells[charToReplaceCol.Index].ReadOnly = true;
|
||||
dataGridView1.Rows[row].Cells[descriptionCol.Index].ReadOnly = true;
|
||||
dataGridView1.Rows[row].Cells[charToReplaceCol.Index].Style.BackColor = System.Drawing.Color.LightGray;
|
||||
dataGridView1.Rows[row].Cells[descriptionCol.Index].Style.BackColor = System.Drawing.Color.LightGray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dataGridView1_UserDeletingRow(object sender, DataGridViewRowCancelEventArgs e)
|
||||
{
|
||||
if (e.Row?.Tag is Replacement r && r.Mandatory)
|
||||
e.Cancel = true;
|
||||
}
|
||||
|
||||
private void loFiDefaultsBtn_Click(object sender, EventArgs e)
|
||||
=> LoadTable(ReplacementCharacters.LoFiDefault.Replacements);
|
||||
|
||||
private void defaultsBtn_Click(object sender, EventArgs e)
|
||||
=> LoadTable(ReplacementCharacters.Default.Replacements);
|
||||
|
||||
private void minDefaultBtn_Click(object sender, EventArgs e)
|
||||
=> LoadTable(ReplacementCharacters.Barebones.Replacements);
|
||||
|
||||
|
||||
private void dataGridView1_CellEndEdit(object sender, DataGridViewCellEventArgs e)
|
||||
{
|
||||
if (e.RowIndex < 0) return;
|
||||
|
||||
dataGridView1.Rows[e.RowIndex].ErrorText = string.Empty;
|
||||
|
||||
var charToReplaceStr = dataGridView1.Rows[e.RowIndex].Cells[charToReplaceCol.Index].Value?.ToString();
|
||||
var replacement = dataGridView1.Rows[e.RowIndex].Cells[replacementStringCol.Index].Value?.ToString() ?? string.Empty;
|
||||
var description = dataGridView1.Rows[e.RowIndex].Cells[descriptionCol.Index].Value?.ToString() ?? string.Empty;
|
||||
|
||||
//Validate the whole row. If it passes all validation, add or update the row's tag.
|
||||
if (string.IsNullOrEmpty(charToReplaceStr) && replacement == string.Empty && description == string.Empty)
|
||||
{
|
||||
//Invalid entry, so delete row
|
||||
var row = dataGridView1.Rows[e.RowIndex];
|
||||
if (!row.IsNewRow)
|
||||
{
|
||||
BeginInvoke(new MethodInvoker(delegate
|
||||
{
|
||||
dataGridView1.Rows.Remove(row);
|
||||
}));
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrEmpty(charToReplaceStr))
|
||||
{
|
||||
dataGridView1.Rows[e.RowIndex].ErrorText = $"You must choose a character to replace";
|
||||
}
|
||||
else if (charToReplaceStr.Length > 1)
|
||||
{
|
||||
dataGridView1.Rows[e.RowIndex].ErrorText = $"Only 1 {charToReplaceCol.HeaderText} per entry";
|
||||
}
|
||||
else if (e.RowIndex >= Replacement.FIXED_COUNT &&
|
||||
dataGridView1.Rows
|
||||
.Cast<DataGridViewRow>()
|
||||
.Where(r => r.Index != e.RowIndex)
|
||||
.Select(r => r.Tag)
|
||||
.OfType<Replacement>()
|
||||
.Any(r => r.CharacterToReplace == charToReplaceStr[0])
|
||||
)
|
||||
{
|
||||
dataGridView1.Rows[e.RowIndex].ErrorText = $"The {charToReplaceStr[0]} character is already being replaced";
|
||||
}
|
||||
else if (ReplacementCharacters.ContainsInvalid(replacement))
|
||||
{
|
||||
dataGridView1.Rows[e.RowIndex].ErrorText = $"Your {replacementStringCol.HeaderText} contains illegal characters";
|
||||
}
|
||||
else
|
||||
{
|
||||
//valid entry. Add or update Replacement in row's Tag
|
||||
var charToReplace = charToReplaceStr[0];
|
||||
|
||||
if (dataGridView1.Rows[e.RowIndex].Tag is Replacement existing)
|
||||
existing.Update(charToReplace, replacement, description);
|
||||
else
|
||||
dataGridView1.Rows[e.RowIndex].Tag = new Replacement(charToReplace, replacement, description);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
var replacements = dataGridView1.Rows
|
||||
.Cast<DataGridViewRow>()
|
||||
.Select(r => r.Tag)
|
||||
.OfType<Replacement>()
|
||||
.ToList();
|
||||
|
||||
config.ReplacementCharacters = new ReplacementCharacters { Replacements = replacements };
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void cancelBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void dataGridView1_Resize(object sender, EventArgs e)
|
||||
{
|
||||
dataGridView1.Columns[^1].Width = dataGridView1.Width - dataGridView1.Columns.Cast<DataGridViewColumn>().Sum(c => c == dataGridView1.Columns[^1] ? 0 : c.Width) - dataGridView1.RowHeadersWidth - 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,18 @@
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="gridEntryBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>17, 17</value>
|
||||
<metadata name="charToReplaceCol.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="replacementStringCol.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
<metadata name="descriptionCol.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
|
||||
<value>True</value>
|
||||
</metadata>
|
||||
</root>
|
||||
@@ -46,7 +46,7 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
if (template is null)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert($"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(template)} is null"));
|
||||
MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(template)} is null"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace LibationWinForms.Dialogs
|
||||
private void templateTb_TextChanged(object sender, EventArgs e)
|
||||
{
|
||||
workingTemplateText = templateTb.Text;
|
||||
|
||||
var isChapterTitle = template == Templates.ChapterTitle;
|
||||
var isFolder = template == Templates.Folder;
|
||||
|
||||
var libraryBookDto = new LibraryBookDto
|
||||
@@ -85,22 +85,35 @@ namespace LibationWinForms.Dialogs
|
||||
var chapterNumber = 4;
|
||||
var chaptersTotal = 10;
|
||||
|
||||
var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
|
||||
{
|
||||
OutputFileName = "",
|
||||
PartsPosition = chapterNumber,
|
||||
PartsTotal = chaptersTotal,
|
||||
Title = chapterName
|
||||
};
|
||||
|
||||
|
||||
var books = config.Books;
|
||||
var folder = Templates.Folder.GetPortionFilename(
|
||||
libraryBookDto,
|
||||
isFolder ? workingTemplateText : config.FolderTemplate);
|
||||
|
||||
|
||||
var file
|
||||
= template == Templates.ChapterFile
|
||||
? Templates.ChapterFile.GetPortionFilename(
|
||||
libraryBookDto,
|
||||
workingTemplateText,
|
||||
new() { OutputFileName = "", PartsPosition = chapterNumber, PartsTotal = chaptersTotal, Title = chapterName },
|
||||
partFileProperties,
|
||||
"")
|
||||
: Templates.File.GetPortionFilename(
|
||||
libraryBookDto,
|
||||
isFolder ? config.FileTemplate : workingTemplateText);
|
||||
var ext = config.DecryptToLossy ? "mp3" : "m4b";
|
||||
|
||||
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
|
||||
|
||||
const char ZERO_WIDTH_SPACE = '\u200B';
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
|
||||
@@ -126,6 +139,13 @@ namespace LibationWinForms.Dialogs
|
||||
richTextBox1.Clear();
|
||||
richTextBox1.SelectionFont = reg;
|
||||
|
||||
if (isChapterTitle)
|
||||
{
|
||||
richTextBox1.SelectionFont = bold;
|
||||
richTextBox1.AppendText(chapterTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
richTextBox1.AppendText(slashWrap(books));
|
||||
richTextBox1.AppendText(sing);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using FileManager;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
@@ -47,7 +48,7 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
private void logsLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
|
||||
{
|
||||
string dir = "";
|
||||
LongPath dir = "";
|
||||
try
|
||||
{
|
||||
dir = LibationFileManager.Configuration.Instance.LibationFiles;
|
||||
@@ -56,7 +57,7 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
try
|
||||
{
|
||||
Go.To.Folder(dir);
|
||||
Go.To.Folder(dir.ShortPathName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
partial class RemoveBooksDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.components = new System.ComponentModel.Container();
|
||||
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle();
|
||||
this._dataGridView = new System.Windows.Forms.DataGridView();
|
||||
this.removeDataGridViewCheckBoxColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
|
||||
this.coverDataGridViewImageColumn = new System.Windows.Forms.DataGridViewImageColumn();
|
||||
this.titleDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.authorsDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.miscDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.purchaseDateGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.gridEntryBindingSource = new LibationWinForms.SyncBindingSource(this.components);
|
||||
this.btnRemoveBooks = new System.Windows.Forms.Button();
|
||||
this.label1 = new System.Windows.Forms.Label();
|
||||
((System.ComponentModel.ISupportInitialize)(this._dataGridView)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// _dataGridView
|
||||
//
|
||||
this._dataGridView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this._dataGridView.AutoGenerateColumns = false;
|
||||
this._dataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||
this._dataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
|
||||
this.removeDataGridViewCheckBoxColumn,
|
||||
this.coverDataGridViewImageColumn,
|
||||
this.titleDataGridViewTextBoxColumn,
|
||||
this.authorsDataGridViewTextBoxColumn,
|
||||
this.miscDataGridViewTextBoxColumn,
|
||||
this.purchaseDateGridViewTextBoxColumn});
|
||||
this._dataGridView.DataSource = this.gridEntryBindingSource;
|
||||
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
|
||||
dataGridViewCellStyle1.BackColor = System.Drawing.SystemColors.Window;
|
||||
dataGridViewCellStyle1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
|
||||
dataGridViewCellStyle1.ForeColor = System.Drawing.SystemColors.ControlText;
|
||||
dataGridViewCellStyle1.SelectionBackColor = System.Drawing.SystemColors.Highlight;
|
||||
dataGridViewCellStyle1.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
|
||||
dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
|
||||
this._dataGridView.DefaultCellStyle = dataGridViewCellStyle1;
|
||||
this._dataGridView.Location = new System.Drawing.Point(0, 0);
|
||||
this._dataGridView.Name = "_dataGridView";
|
||||
this._dataGridView.RowHeadersVisible = false;
|
||||
this._dataGridView.RowTemplate.Height = 82;
|
||||
this._dataGridView.Size = new System.Drawing.Size(730, 409);
|
||||
this._dataGridView.TabIndex = 0;
|
||||
//
|
||||
// removeDataGridViewCheckBoxColumn
|
||||
//
|
||||
this.removeDataGridViewCheckBoxColumn.DataPropertyName = "Remove";
|
||||
this.removeDataGridViewCheckBoxColumn.FalseValue = "False";
|
||||
this.removeDataGridViewCheckBoxColumn.Frozen = true;
|
||||
this.removeDataGridViewCheckBoxColumn.HeaderText = "Remove";
|
||||
this.removeDataGridViewCheckBoxColumn.MinimumWidth = 80;
|
||||
this.removeDataGridViewCheckBoxColumn.Name = "removeDataGridViewCheckBoxColumn";
|
||||
this.removeDataGridViewCheckBoxColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
|
||||
this.removeDataGridViewCheckBoxColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
|
||||
this.removeDataGridViewCheckBoxColumn.TrueValue = "True";
|
||||
this.removeDataGridViewCheckBoxColumn.Width = 80;
|
||||
//
|
||||
// coverDataGridViewImageColumn
|
||||
//
|
||||
this.coverDataGridViewImageColumn.DataPropertyName = "Cover";
|
||||
this.coverDataGridViewImageColumn.HeaderText = "Cover";
|
||||
this.coverDataGridViewImageColumn.MinimumWidth = 80;
|
||||
this.coverDataGridViewImageColumn.Name = "coverDataGridViewImageColumn";
|
||||
this.coverDataGridViewImageColumn.ReadOnly = true;
|
||||
this.coverDataGridViewImageColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
|
||||
this.coverDataGridViewImageColumn.Width = 80;
|
||||
//
|
||||
// titleDataGridViewTextBoxColumn
|
||||
//
|
||||
this.titleDataGridViewTextBoxColumn.DataPropertyName = "Title";
|
||||
this.titleDataGridViewTextBoxColumn.HeaderText = "Title";
|
||||
this.titleDataGridViewTextBoxColumn.Name = "titleDataGridViewTextBoxColumn";
|
||||
this.titleDataGridViewTextBoxColumn.ReadOnly = true;
|
||||
this.titleDataGridViewTextBoxColumn.Width = 200;
|
||||
//
|
||||
// authorsDataGridViewTextBoxColumn
|
||||
//
|
||||
this.authorsDataGridViewTextBoxColumn.DataPropertyName = "Authors";
|
||||
this.authorsDataGridViewTextBoxColumn.HeaderText = "Authors";
|
||||
this.authorsDataGridViewTextBoxColumn.Name = "authorsDataGridViewTextBoxColumn";
|
||||
this.authorsDataGridViewTextBoxColumn.ReadOnly = true;
|
||||
//
|
||||
// miscDataGridViewTextBoxColumn
|
||||
//
|
||||
this.miscDataGridViewTextBoxColumn.DataPropertyName = "Misc";
|
||||
this.miscDataGridViewTextBoxColumn.HeaderText = "Misc";
|
||||
this.miscDataGridViewTextBoxColumn.Name = "miscDataGridViewTextBoxColumn";
|
||||
this.miscDataGridViewTextBoxColumn.ReadOnly = true;
|
||||
this.miscDataGridViewTextBoxColumn.Width = 150;
|
||||
//
|
||||
// purchaseDateGridViewTextBoxColumn
|
||||
//
|
||||
this.purchaseDateGridViewTextBoxColumn.DataPropertyName = "PurchaseDate";
|
||||
this.purchaseDateGridViewTextBoxColumn.HeaderText = "Purchase Date";
|
||||
this.purchaseDateGridViewTextBoxColumn.Name = "purchaseDateGridViewTextBoxColumn";
|
||||
this.purchaseDateGridViewTextBoxColumn.ReadOnly = true;
|
||||
this.purchaseDateGridViewTextBoxColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
|
||||
//
|
||||
// gridEntryBindingSource
|
||||
//
|
||||
this.gridEntryBindingSource.AllowNew = false;
|
||||
this.gridEntryBindingSource.DataSource = typeof(LibationWinForms.Dialogs.RemovableGridEntry);
|
||||
//
|
||||
// btnRemoveBooks
|
||||
//
|
||||
this.btnRemoveBooks.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.btnRemoveBooks.Location = new System.Drawing.Point(500, 419);
|
||||
this.btnRemoveBooks.Name = "btnRemoveBooks";
|
||||
this.btnRemoveBooks.Size = new System.Drawing.Size(218, 23);
|
||||
this.btnRemoveBooks.TabIndex = 1;
|
||||
this.btnRemoveBooks.Text = "Remove Selected Books from Libation";
|
||||
this.btnRemoveBooks.UseVisualStyleBackColor = true;
|
||||
this.btnRemoveBooks.Click += new System.EventHandler(this.btnRemoveBooks_Click);
|
||||
//
|
||||
// label1
|
||||
//
|
||||
this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.label1.AutoSize = true;
|
||||
this.label1.Location = new System.Drawing.Point(12, 423);
|
||||
this.label1.Name = "label1";
|
||||
this.label1.Size = new System.Drawing.Size(178, 15);
|
||||
this.label1.TabIndex = 2;
|
||||
this.label1.Text = "{0} book{1} selected for removal.";
|
||||
//
|
||||
// RemoveBooksDialog
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(730, 450);
|
||||
this.Controls.Add(this.label1);
|
||||
this.Controls.Add(this.btnRemoveBooks);
|
||||
this.Controls.Add(this._dataGridView);
|
||||
this.Name = "RemoveBooksDialog";
|
||||
this.Text = "Remove Books from Libation's Database";
|
||||
this.Shown += new System.EventHandler(this.RemoveBooksDialog_Shown);
|
||||
((System.ComponentModel.ISupportInitialize)(this._dataGridView)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.DataGridView _dataGridView;
|
||||
private LibationWinForms.SyncBindingSource gridEntryBindingSource;
|
||||
private System.Windows.Forms.Button btnRemoveBooks;
|
||||
private System.Windows.Forms.Label label1;
|
||||
private System.Windows.Forms.DataGridViewCheckBoxColumn removeDataGridViewCheckBoxColumn;
|
||||
private System.Windows.Forms.DataGridViewImageColumn coverDataGridViewImageColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn titleDataGridViewTextBoxColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn authorsDataGridViewTextBoxColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn miscDataGridViewTextBoxColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGridViewTextBoxColumn;
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core.DataBinding;
|
||||
using LibationFileManager;
|
||||
using LibationWinForms.Login;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class RemoveBooksDialog : Form
|
||||
{
|
||||
private Account[] _accounts { get; }
|
||||
private List<LibraryBook> _libraryBooks { get; }
|
||||
private SortableBindingList<RemovableGridEntry> _removableGridEntries { get; }
|
||||
private string _labelFormat { get; }
|
||||
private int SelectedCount => SelectedEntries?.Count() ?? 0;
|
||||
private IEnumerable<RemovableGridEntry> SelectedEntries => _removableGridEntries?.Where(b => b.Remove);
|
||||
|
||||
public RemoveBooksDialog(params Account[] accounts)
|
||||
{
|
||||
_libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
_accounts = accounts;
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance);
|
||||
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
|
||||
_labelFormat = label1.Text;
|
||||
|
||||
_dataGridView.CellContentClick += (_, _) => _dataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit);
|
||||
_dataGridView.CellValueChanged += (_, _) => UpdateSelection();
|
||||
_dataGridView.BindingContextChanged += _dataGridView_BindingContextChanged;
|
||||
|
||||
var orderedGridEntries = _libraryBooks
|
||||
.Select(lb => new RemovableGridEntry(lb))
|
||||
.OrderByDescending(ge => (DateTime)ge.GetMemberValue(nameof(ge.PurchaseDate)))
|
||||
.ToList();
|
||||
|
||||
_removableGridEntries = new SortableBindingList<RemovableGridEntry>(orderedGridEntries);
|
||||
gridEntryBindingSource.DataSource = _removableGridEntries;
|
||||
|
||||
_dataGridView.Enabled = false;
|
||||
this.SetLibationIcon();
|
||||
}
|
||||
|
||||
private void _dataGridView_BindingContextChanged(object sender, EventArgs e)
|
||||
{
|
||||
_dataGridView.Sort(_dataGridView.Columns[0], ListSortDirection.Descending);
|
||||
UpdateSelection();
|
||||
}
|
||||
|
||||
private async void RemoveBooksDialog_Shown(object sender, EventArgs e)
|
||||
{
|
||||
if (_accounts is null || _accounts.Length == 0)
|
||||
return;
|
||||
try
|
||||
{
|
||||
var removedBooks = await LibraryCommands.FindInactiveBooks(WinformLoginChoiceEager.ApiExtendedFunc, _libraryBooks, _accounts);
|
||||
|
||||
var removable = _removableGridEntries.Where(rge => removedBooks.Any(rb => rb.Book.AudibleProductId == rge.AudibleProductId)).ToList();
|
||||
|
||||
if (!removable.Any())
|
||||
return;
|
||||
|
||||
foreach (var r in removable)
|
||||
r.Remove = true;
|
||||
|
||||
UpdateSelection();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(
|
||||
"Error scanning library. You may still manually select books to remove from Libation's library.",
|
||||
"Error scanning library",
|
||||
ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dataGridView.Enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async void btnRemoveBooks_Click(object sender, EventArgs e)
|
||||
{
|
||||
var selectedBooks = SelectedEntries.ToList();
|
||||
|
||||
if (selectedBooks.Count == 0)
|
||||
return;
|
||||
|
||||
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||
var result = MessageBoxLib.ShowConfirmationDialog(
|
||||
libraryBooks,
|
||||
$"Are you sure you want to remove {0} from Libation's library?",
|
||||
"Remove books from Libation?");
|
||||
|
||||
if (result != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
|
||||
|
||||
foreach (var rEntry in selectedBooks)
|
||||
_removableGridEntries.Remove(rEntry);
|
||||
|
||||
UpdateSelection();
|
||||
}
|
||||
|
||||
private void UpdateSelection()
|
||||
{
|
||||
var selectedCount = SelectedCount;
|
||||
label1.Text = string.Format(_labelFormat, selectedCount, selectedCount != 1 ? "s" : string.Empty);
|
||||
btnRemoveBooks.Enabled = selectedCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
internal class RemovableGridEntry : GridEntry
|
||||
{
|
||||
private static readonly IComparer BoolComparer = new ObjectComparer<bool>();
|
||||
|
||||
private bool _remove = false;
|
||||
public RemovableGridEntry(LibraryBook libraryBook) : base(libraryBook) { }
|
||||
|
||||
public bool Remove
|
||||
{
|
||||
get
|
||||
{
|
||||
return _remove;
|
||||
}
|
||||
set
|
||||
{
|
||||
_remove = value;
|
||||
NotifyPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public override object GetMemberValue(string memberName)
|
||||
{
|
||||
if (memberName == nameof(Remove))
|
||||
return Remove;
|
||||
return base.GetMemberValue(memberName);
|
||||
}
|
||||
|
||||
public override IComparer GetMemberComparer(Type memberType)
|
||||
{
|
||||
if (memberType == typeof(bool))
|
||||
return BoolComparer;
|
||||
return base.GetMemberComparer(memberType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using LibationFileManager;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
partial class SettingsDialog
|
||||
{
|
||||
private void Load_AudioSettings(Configuration config)
|
||||
{
|
||||
this.allowLibationFixupCbox.Text = desc(nameof(config.AllowLibationFixup));
|
||||
this.createCueSheetCbox.Text = desc(nameof(config.CreateCueSheet));
|
||||
this.downloadCoverArtCbox.Text = desc(nameof(config.DownloadCoverArt));
|
||||
this.retainAaxFileCbox.Text = desc(nameof(config.RetainAaxFile));
|
||||
this.splitFilesByChapterCbox.Text = desc(nameof(config.SplitFilesByChapter));
|
||||
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
|
||||
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
|
||||
|
||||
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
|
||||
createCueSheetCbox.Checked = config.CreateCueSheet;
|
||||
downloadCoverArtCbox.Checked = config.DownloadCoverArt;
|
||||
retainAaxFileCbox.Checked = config.RetainAaxFile;
|
||||
splitFilesByChapterCbox.Checked = config.SplitFilesByChapter;
|
||||
stripUnabridgedCbox.Checked = config.StripUnabridged;
|
||||
stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio;
|
||||
convertLosslessRb.Checked = !config.DecryptToLossy;
|
||||
convertLossyRb.Checked = config.DecryptToLossy;
|
||||
|
||||
lameTargetBitrateRb.Checked = config.LameTargetBitrate;
|
||||
lameTargetQualityRb.Checked = !config.LameTargetBitrate;
|
||||
lameDownsampleMonoCbox.Checked = config.LameDownsampleMono;
|
||||
lameBitrateTb.Value = config.LameBitrate;
|
||||
lameConstantBitrateCbox.Checked = config.LameConstantBitrate;
|
||||
LameMatchSourceBRCbox.Checked = config.LameMatchSourceBR;
|
||||
lameVBRQualityTb.Value = config.LameVBRQuality;
|
||||
|
||||
chapterTitleTemplateGb.Text = desc(nameof(config.ChapterTitleTemplate));
|
||||
chapterTitleTemplateTb.Text = config.ChapterTitleTemplate;
|
||||
|
||||
lameTargetRb_CheckedChanged(this, EventArgs.Empty);
|
||||
LameMatchSourceBRCbox_CheckedChanged(this, EventArgs.Empty);
|
||||
convertFormatRb_CheckedChanged(this, EventArgs.Empty);
|
||||
allowLibationFixupCbox_CheckedChanged(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void Save_AudioSettings(Configuration config)
|
||||
{
|
||||
config.AllowLibationFixup = allowLibationFixupCbox.Checked;
|
||||
config.CreateCueSheet = createCueSheetCbox.Checked;
|
||||
config.DownloadCoverArt = downloadCoverArtCbox.Checked;
|
||||
config.RetainAaxFile = retainAaxFileCbox.Checked;
|
||||
config.SplitFilesByChapter = splitFilesByChapterCbox.Checked;
|
||||
config.StripUnabridged = stripUnabridgedCbox.Checked;
|
||||
config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked;
|
||||
config.DecryptToLossy = convertLossyRb.Checked;
|
||||
|
||||
config.LameTargetBitrate = lameTargetBitrateRb.Checked;
|
||||
config.LameDownsampleMono = lameDownsampleMonoCbox.Checked;
|
||||
config.LameBitrate = lameBitrateTb.Value;
|
||||
config.LameConstantBitrate = lameConstantBitrateCbox.Checked;
|
||||
config.LameMatchSourceBR = LameMatchSourceBRCbox.Checked;
|
||||
config.LameVBRQuality = lameVBRQualityTb.Value;
|
||||
|
||||
config.ChapterTitleTemplate = chapterTitleTemplateTb.Text;
|
||||
}
|
||||
|
||||
private void lameTargetRb_CheckedChanged(object sender, EventArgs e)
|
||||
{
|
||||
lameBitrateGb.Enabled = lameTargetBitrateRb.Checked;
|
||||
@@ -19,6 +75,13 @@ namespace LibationWinForms.Dialogs
|
||||
lameBitrateTb.Enabled = !LameMatchSourceBRCbox.Checked;
|
||||
}
|
||||
|
||||
private void splitFilesByChapterCbox_CheckedChanged(object sender, EventArgs e)
|
||||
{
|
||||
chapterTitleTemplateGb.Enabled = splitFilesByChapterCbox.Checked;
|
||||
}
|
||||
|
||||
private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterTitle, chapterTitleTemplateTb);
|
||||
|
||||
private void convertFormatRb_CheckedChanged(object sender, EventArgs e)
|
||||
{
|
||||
lameTargetRb_CheckedChanged(sender, e);
|
||||
|
||||
@@ -52,12 +52,15 @@
|
||||
this.tabControl = new System.Windows.Forms.TabControl();
|
||||
this.tab1ImportantSettings = new System.Windows.Forms.TabPage();
|
||||
this.booksGb = new System.Windows.Forms.GroupBox();
|
||||
this.saveEpisodesToSeriesFolderCbox = new System.Windows.Forms.CheckBox();
|
||||
this.tab2ImportLibrary = new System.Windows.Forms.TabPage();
|
||||
this.autoDownloadEpisodesCb = new System.Windows.Forms.CheckBox();
|
||||
this.autoScanCb = new System.Windows.Forms.CheckBox();
|
||||
this.showImportedStatsCb = new System.Windows.Forms.CheckBox();
|
||||
this.tab3DownloadDecrypt = new System.Windows.Forms.TabPage();
|
||||
this.inProgressFilesGb = new System.Windows.Forms.GroupBox();
|
||||
this.customFileNamingGb = new System.Windows.Forms.GroupBox();
|
||||
this.editCharreplacementBtn = new System.Windows.Forms.Button();
|
||||
this.chapterFileTemplateBtn = new System.Windows.Forms.Button();
|
||||
this.chapterFileTemplateTb = new System.Windows.Forms.TextBox();
|
||||
this.chapterFileTemplateLbl = new System.Windows.Forms.Label();
|
||||
@@ -68,6 +71,9 @@
|
||||
this.folderTemplateTb = new System.Windows.Forms.TextBox();
|
||||
this.folderTemplateLbl = new System.Windows.Forms.Label();
|
||||
this.tab4AudioFileOptions = new System.Windows.Forms.TabPage();
|
||||
this.chapterTitleTemplateGb = new System.Windows.Forms.GroupBox();
|
||||
this.chapterTitleTemplateBtn = new System.Windows.Forms.Button();
|
||||
this.chapterTitleTemplateTb = new System.Windows.Forms.TextBox();
|
||||
this.lameOptionsGb = new System.Windows.Forms.GroupBox();
|
||||
this.lameDownsampleMonoCbox = new System.Windows.Forms.CheckBox();
|
||||
this.lameBitrateGb = new System.Windows.Forms.GroupBox();
|
||||
@@ -100,8 +106,8 @@
|
||||
this.lameTargetBitrateRb = new System.Windows.Forms.RadioButton();
|
||||
this.stripUnabridgedCbox = new System.Windows.Forms.CheckBox();
|
||||
this.retainAaxFileCbox = new System.Windows.Forms.CheckBox();
|
||||
this.createCueSheetCbox = new System.Windows.Forms.CheckBox();
|
||||
this.downloadCoverArtCbox = new System.Windows.Forms.CheckBox();
|
||||
this.createCueSheetCbox = new System.Windows.Forms.CheckBox();
|
||||
this.badBookGb.SuspendLayout();
|
||||
this.tabControl.SuspendLayout();
|
||||
this.tab1ImportantSettings.SuspendLayout();
|
||||
@@ -111,6 +117,7 @@
|
||||
this.inProgressFilesGb.SuspendLayout();
|
||||
this.customFileNamingGb.SuspendLayout();
|
||||
this.tab4AudioFileOptions.SuspendLayout();
|
||||
this.chapterTitleTemplateGb.SuspendLayout();
|
||||
this.lameOptionsGb.SuspendLayout();
|
||||
this.lameBitrateGb.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.lameBitrateTb)).BeginInit();
|
||||
@@ -142,7 +149,7 @@
|
||||
// saveBtn
|
||||
//
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveBtn.Location = new System.Drawing.Point(667, 441);
|
||||
this.saveBtn.Location = new System.Drawing.Point(667, 461);
|
||||
this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
this.saveBtn.Size = new System.Drawing.Size(88, 27);
|
||||
@@ -155,7 +162,7 @@
|
||||
//
|
||||
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
this.cancelBtn.Location = new System.Drawing.Point(785, 441);
|
||||
this.cancelBtn.Location = new System.Drawing.Point(785, 461);
|
||||
this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
this.cancelBtn.Name = "cancelBtn";
|
||||
this.cancelBtn.Size = new System.Drawing.Size(88, 27);
|
||||
@@ -260,6 +267,7 @@
|
||||
this.splitFilesByChapterCbox.TabIndex = 13;
|
||||
this.splitFilesByChapterCbox.Text = "[SplitFilesByChapter desc]";
|
||||
this.splitFilesByChapterCbox.UseVisualStyleBackColor = true;
|
||||
this.splitFilesByChapterCbox.CheckedChanged += new System.EventHandler(this.splitFilesByChapterCbox_CheckedChanged);
|
||||
//
|
||||
// allowLibationFixupCbox
|
||||
//
|
||||
@@ -310,7 +318,7 @@
|
||||
//
|
||||
// logsBtn
|
||||
//
|
||||
this.logsBtn.Location = new System.Drawing.Point(256, 169);
|
||||
this.logsBtn.Location = new System.Drawing.Point(256, 198);
|
||||
this.logsBtn.Name = "logsBtn";
|
||||
this.logsBtn.Size = new System.Drawing.Size(132, 23);
|
||||
this.logsBtn.TabIndex = 5;
|
||||
@@ -331,7 +339,7 @@
|
||||
// loggingLevelLbl
|
||||
//
|
||||
this.loggingLevelLbl.AutoSize = true;
|
||||
this.loggingLevelLbl.Location = new System.Drawing.Point(6, 172);
|
||||
this.loggingLevelLbl.Location = new System.Drawing.Point(6, 201);
|
||||
this.loggingLevelLbl.Name = "loggingLevelLbl";
|
||||
this.loggingLevelLbl.Size = new System.Drawing.Size(78, 15);
|
||||
this.loggingLevelLbl.TabIndex = 3;
|
||||
@@ -341,7 +349,7 @@
|
||||
//
|
||||
this.loggingLevelCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.loggingLevelCb.FormattingEnabled = true;
|
||||
this.loggingLevelCb.Location = new System.Drawing.Point(90, 169);
|
||||
this.loggingLevelCb.Location = new System.Drawing.Point(90, 198);
|
||||
this.loggingLevelCb.Name = "loggingLevelCb";
|
||||
this.loggingLevelCb.Size = new System.Drawing.Size(129, 23);
|
||||
this.loggingLevelCb.TabIndex = 4;
|
||||
@@ -358,7 +366,7 @@
|
||||
this.tabControl.Location = new System.Drawing.Point(12, 12);
|
||||
this.tabControl.Name = "tabControl";
|
||||
this.tabControl.SelectedIndex = 0;
|
||||
this.tabControl.Size = new System.Drawing.Size(862, 423);
|
||||
this.tabControl.Size = new System.Drawing.Size(862, 443);
|
||||
this.tabControl.TabIndex = 100;
|
||||
//
|
||||
// tab1ImportantSettings
|
||||
@@ -370,7 +378,7 @@
|
||||
this.tab1ImportantSettings.Location = new System.Drawing.Point(4, 24);
|
||||
this.tab1ImportantSettings.Name = "tab1ImportantSettings";
|
||||
this.tab1ImportantSettings.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.tab1ImportantSettings.Size = new System.Drawing.Size(854, 395);
|
||||
this.tab1ImportantSettings.Size = new System.Drawing.Size(854, 415);
|
||||
this.tab1ImportantSettings.TabIndex = 0;
|
||||
this.tab1ImportantSettings.Text = "Important settings";
|
||||
this.tab1ImportantSettings.UseVisualStyleBackColor = true;
|
||||
@@ -379,17 +387,29 @@
|
||||
//
|
||||
this.booksGb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.booksGb.Controls.Add(this.saveEpisodesToSeriesFolderCbox);
|
||||
this.booksGb.Controls.Add(this.booksSelectControl);
|
||||
this.booksGb.Controls.Add(this.booksLocationDescLbl);
|
||||
this.booksGb.Location = new System.Drawing.Point(6, 6);
|
||||
this.booksGb.Name = "booksGb";
|
||||
this.booksGb.Size = new System.Drawing.Size(842, 129);
|
||||
this.booksGb.Size = new System.Drawing.Size(842, 156);
|
||||
this.booksGb.TabIndex = 0;
|
||||
this.booksGb.TabStop = false;
|
||||
this.booksGb.Text = "Books location";
|
||||
//
|
||||
// saveEpisodesToSeriesFolderCbox
|
||||
//
|
||||
this.saveEpisodesToSeriesFolderCbox.AutoSize = true;
|
||||
this.saveEpisodesToSeriesFolderCbox.Location = new System.Drawing.Point(7, 131);
|
||||
this.saveEpisodesToSeriesFolderCbox.Name = "saveEpisodesToSeriesFolderCbox";
|
||||
this.saveEpisodesToSeriesFolderCbox.Size = new System.Drawing.Size(191, 19);
|
||||
this.saveEpisodesToSeriesFolderCbox.TabIndex = 3;
|
||||
this.saveEpisodesToSeriesFolderCbox.Text = "[Save Episodes To Series Folder]";
|
||||
this.saveEpisodesToSeriesFolderCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// tab2ImportLibrary
|
||||
//
|
||||
this.tab2ImportLibrary.Controls.Add(this.autoDownloadEpisodesCb);
|
||||
this.tab2ImportLibrary.Controls.Add(this.autoScanCb);
|
||||
this.tab2ImportLibrary.Controls.Add(this.showImportedStatsCb);
|
||||
this.tab2ImportLibrary.Controls.Add(this.importEpisodesCb);
|
||||
@@ -397,11 +417,21 @@
|
||||
this.tab2ImportLibrary.Location = new System.Drawing.Point(4, 24);
|
||||
this.tab2ImportLibrary.Name = "tab2ImportLibrary";
|
||||
this.tab2ImportLibrary.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.tab2ImportLibrary.Size = new System.Drawing.Size(854, 395);
|
||||
this.tab2ImportLibrary.Size = new System.Drawing.Size(854, 415);
|
||||
this.tab2ImportLibrary.TabIndex = 1;
|
||||
this.tab2ImportLibrary.Text = "Import library";
|
||||
this.tab2ImportLibrary.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// autoDownloadEpisodesCb
|
||||
//
|
||||
this.autoDownloadEpisodesCb.AutoSize = true;
|
||||
this.autoDownloadEpisodesCb.Location = new System.Drawing.Point(6, 106);
|
||||
this.autoDownloadEpisodesCb.Name = "autoDownloadEpisodesCb";
|
||||
this.autoDownloadEpisodesCb.Size = new System.Drawing.Size(190, 19);
|
||||
this.autoDownloadEpisodesCb.TabIndex = 5;
|
||||
this.autoDownloadEpisodesCb.Text = "[auto download episodes desc]";
|
||||
this.autoDownloadEpisodesCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// autoScanCb
|
||||
//
|
||||
this.autoScanCb.AutoSize = true;
|
||||
@@ -430,7 +460,7 @@
|
||||
this.tab3DownloadDecrypt.Location = new System.Drawing.Point(4, 24);
|
||||
this.tab3DownloadDecrypt.Name = "tab3DownloadDecrypt";
|
||||
this.tab3DownloadDecrypt.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.tab3DownloadDecrypt.Size = new System.Drawing.Size(854, 395);
|
||||
this.tab3DownloadDecrypt.Size = new System.Drawing.Size(854, 415);
|
||||
this.tab3DownloadDecrypt.TabIndex = 2;
|
||||
this.tab3DownloadDecrypt.Text = "Download/Decrypt";
|
||||
this.tab3DownloadDecrypt.UseVisualStyleBackColor = true;
|
||||
@@ -441,7 +471,7 @@
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.inProgressFilesGb.Controls.Add(this.inProgressDescLbl);
|
||||
this.inProgressFilesGb.Controls.Add(this.inProgressSelectControl);
|
||||
this.inProgressFilesGb.Location = new System.Drawing.Point(7, 251);
|
||||
this.inProgressFilesGb.Location = new System.Drawing.Point(6, 281);
|
||||
this.inProgressFilesGb.Name = "inProgressFilesGb";
|
||||
this.inProgressFilesGb.Size = new System.Drawing.Size(841, 128);
|
||||
this.inProgressFilesGb.TabIndex = 21;
|
||||
@@ -452,6 +482,7 @@
|
||||
//
|
||||
this.customFileNamingGb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.customFileNamingGb.Controls.Add(this.editCharreplacementBtn);
|
||||
this.customFileNamingGb.Controls.Add(this.chapterFileTemplateBtn);
|
||||
this.customFileNamingGb.Controls.Add(this.chapterFileTemplateTb);
|
||||
this.customFileNamingGb.Controls.Add(this.chapterFileTemplateLbl);
|
||||
@@ -463,11 +494,22 @@
|
||||
this.customFileNamingGb.Controls.Add(this.folderTemplateLbl);
|
||||
this.customFileNamingGb.Location = new System.Drawing.Point(7, 88);
|
||||
this.customFileNamingGb.Name = "customFileNamingGb";
|
||||
this.customFileNamingGb.Size = new System.Drawing.Size(841, 157);
|
||||
this.customFileNamingGb.Size = new System.Drawing.Size(841, 187);
|
||||
this.customFileNamingGb.TabIndex = 20;
|
||||
this.customFileNamingGb.TabStop = false;
|
||||
this.customFileNamingGb.Text = "Custom file naming";
|
||||
//
|
||||
// editCharreplacementBtn
|
||||
//
|
||||
this.editCharreplacementBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.editCharreplacementBtn.Location = new System.Drawing.Point(5, 158);
|
||||
this.editCharreplacementBtn.Name = "editCharreplacementBtn";
|
||||
this.editCharreplacementBtn.Size = new System.Drawing.Size(387, 23);
|
||||
this.editCharreplacementBtn.TabIndex = 8;
|
||||
this.editCharreplacementBtn.Text = "[edit char replacement desc]";
|
||||
this.editCharreplacementBtn.UseVisualStyleBackColor = true;
|
||||
this.editCharreplacementBtn.Click += new System.EventHandler(this.editCharreplacementBtn_Click);
|
||||
//
|
||||
// chapterFileTemplateBtn
|
||||
//
|
||||
this.chapterFileTemplateBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
|
||||
@@ -494,9 +536,9 @@
|
||||
this.chapterFileTemplateLbl.AutoSize = true;
|
||||
this.chapterFileTemplateLbl.Location = new System.Drawing.Point(6, 107);
|
||||
this.chapterFileTemplateLbl.Name = "chapterFileTemplateLbl";
|
||||
this.chapterFileTemplateLbl.Size = new System.Drawing.Size(123, 15);
|
||||
this.chapterFileTemplateLbl.Size = new System.Drawing.Size(132, 15);
|
||||
this.chapterFileTemplateLbl.TabIndex = 6;
|
||||
this.chapterFileTemplateLbl.Text = "[folder template desc]";
|
||||
this.chapterFileTemplateLbl.Text = "[chapter template desc]";
|
||||
//
|
||||
// fileTemplateBtn
|
||||
//
|
||||
@@ -524,9 +566,9 @@
|
||||
this.fileTemplateLbl.AutoSize = true;
|
||||
this.fileTemplateLbl.Location = new System.Drawing.Point(6, 63);
|
||||
this.fileTemplateLbl.Name = "fileTemplateLbl";
|
||||
this.fileTemplateLbl.Size = new System.Drawing.Size(123, 15);
|
||||
this.fileTemplateLbl.Size = new System.Drawing.Size(108, 15);
|
||||
this.fileTemplateLbl.TabIndex = 3;
|
||||
this.fileTemplateLbl.Text = "[folder template desc]";
|
||||
this.fileTemplateLbl.Text = "[file template desc]";
|
||||
//
|
||||
// folderTemplateBtn
|
||||
//
|
||||
@@ -560,6 +602,7 @@
|
||||
//
|
||||
// tab4AudioFileOptions
|
||||
//
|
||||
this.tab4AudioFileOptions.Controls.Add(this.chapterTitleTemplateGb);
|
||||
this.tab4AudioFileOptions.Controls.Add(this.lameOptionsGb);
|
||||
this.tab4AudioFileOptions.Controls.Add(this.convertLossyRb);
|
||||
this.tab4AudioFileOptions.Controls.Add(this.stripAudibleBrandingCbox);
|
||||
@@ -573,11 +616,43 @@
|
||||
this.tab4AudioFileOptions.Location = new System.Drawing.Point(4, 24);
|
||||
this.tab4AudioFileOptions.Name = "tab4AudioFileOptions";
|
||||
this.tab4AudioFileOptions.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.tab4AudioFileOptions.Size = new System.Drawing.Size(854, 395);
|
||||
this.tab4AudioFileOptions.Size = new System.Drawing.Size(854, 415);
|
||||
this.tab4AudioFileOptions.TabIndex = 3;
|
||||
this.tab4AudioFileOptions.Text = "Audio File Options";
|
||||
this.tab4AudioFileOptions.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// chapterTitleTemplateGb
|
||||
//
|
||||
this.chapterTitleTemplateGb.Controls.Add(this.chapterTitleTemplateBtn);
|
||||
this.chapterTitleTemplateGb.Controls.Add(this.chapterTitleTemplateTb);
|
||||
this.chapterTitleTemplateGb.Location = new System.Drawing.Point(6, 335);
|
||||
this.chapterTitleTemplateGb.Name = "chapterTitleTemplateGb";
|
||||
this.chapterTitleTemplateGb.Size = new System.Drawing.Size(842, 54);
|
||||
this.chapterTitleTemplateGb.TabIndex = 18;
|
||||
this.chapterTitleTemplateGb.TabStop = false;
|
||||
this.chapterTitleTemplateGb.Text = "[chapter title template desc]";
|
||||
//
|
||||
// chapterTitleTemplateBtn
|
||||
//
|
||||
this.chapterTitleTemplateBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.chapterTitleTemplateBtn.Location = new System.Drawing.Point(764, 22);
|
||||
this.chapterTitleTemplateBtn.Name = "chapterTitleTemplateBtn";
|
||||
this.chapterTitleTemplateBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.chapterTitleTemplateBtn.TabIndex = 17;
|
||||
this.chapterTitleTemplateBtn.Text = "Edit...";
|
||||
this.chapterTitleTemplateBtn.UseVisualStyleBackColor = true;
|
||||
this.chapterTitleTemplateBtn.Click += new System.EventHandler(this.chapterTitleTemplateBtn_Click);
|
||||
//
|
||||
// chapterTitleTemplateTb
|
||||
//
|
||||
this.chapterTitleTemplateTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.chapterTitleTemplateTb.Location = new System.Drawing.Point(6, 22);
|
||||
this.chapterTitleTemplateTb.Name = "chapterTitleTemplateTb";
|
||||
this.chapterTitleTemplateTb.ReadOnly = true;
|
||||
this.chapterTitleTemplateTb.Size = new System.Drawing.Size(752, 23);
|
||||
this.chapterTitleTemplateTb.TabIndex = 16;
|
||||
//
|
||||
// lameOptionsGb
|
||||
//
|
||||
this.lameOptionsGb.Controls.Add(this.lameDownsampleMonoCbox);
|
||||
@@ -585,9 +660,9 @@
|
||||
this.lameOptionsGb.Controls.Add(this.label1);
|
||||
this.lameOptionsGb.Controls.Add(this.lameQualityGb);
|
||||
this.lameOptionsGb.Controls.Add(this.groupBox2);
|
||||
this.lameOptionsGb.Location = new System.Drawing.Point(415, 18);
|
||||
this.lameOptionsGb.Location = new System.Drawing.Point(415, 6);
|
||||
this.lameOptionsGb.Name = "lameOptionsGb";
|
||||
this.lameOptionsGb.Size = new System.Drawing.Size(433, 371);
|
||||
this.lameOptionsGb.Size = new System.Drawing.Size(433, 323);
|
||||
this.lameOptionsGb.TabIndex = 14;
|
||||
this.lameOptionsGb.TabStop = false;
|
||||
this.lameOptionsGb.Text = "Mp3 Encoding Options";
|
||||
@@ -615,7 +690,7 @@
|
||||
this.lameBitrateGb.Controls.Add(this.lameBitrateTb);
|
||||
this.lameBitrateGb.Location = new System.Drawing.Point(6, 84);
|
||||
this.lameBitrateGb.Name = "lameBitrateGb";
|
||||
this.lameBitrateGb.Size = new System.Drawing.Size(421, 112);
|
||||
this.lameBitrateGb.Size = new System.Drawing.Size(421, 101);
|
||||
this.lameBitrateGb.TabIndex = 0;
|
||||
this.lameBitrateGb.TabStop = false;
|
||||
this.lameBitrateGb.Text = "Bitrate";
|
||||
@@ -623,7 +698,7 @@
|
||||
// LameMatchSourceBRCbox
|
||||
//
|
||||
this.LameMatchSourceBRCbox.AutoSize = true;
|
||||
this.LameMatchSourceBRCbox.Location = new System.Drawing.Point(260, 87);
|
||||
this.LameMatchSourceBRCbox.Location = new System.Drawing.Point(260, 77);
|
||||
this.LameMatchSourceBRCbox.Name = "LameMatchSourceBRCbox";
|
||||
this.LameMatchSourceBRCbox.Size = new System.Drawing.Size(140, 19);
|
||||
this.LameMatchSourceBRCbox.TabIndex = 3;
|
||||
@@ -634,7 +709,7 @@
|
||||
// lameConstantBitrateCbox
|
||||
//
|
||||
this.lameConstantBitrateCbox.AutoSize = true;
|
||||
this.lameConstantBitrateCbox.Location = new System.Drawing.Point(6, 87);
|
||||
this.lameConstantBitrateCbox.Location = new System.Drawing.Point(6, 77);
|
||||
this.lameConstantBitrateCbox.Name = "lameConstantBitrateCbox";
|
||||
this.lameConstantBitrateCbox.Size = new System.Drawing.Size(216, 19);
|
||||
this.lameConstantBitrateCbox.TabIndex = 2;
|
||||
@@ -720,7 +795,7 @@
|
||||
this.label1.AutoSize = true;
|
||||
this.label1.Enabled = false;
|
||||
this.label1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Italic, System.Drawing.GraphicsUnit.Point);
|
||||
this.label1.Location = new System.Drawing.Point(6, 353);
|
||||
this.label1.Location = new System.Drawing.Point(6, 298);
|
||||
this.label1.Name = "label1";
|
||||
this.label1.Size = new System.Drawing.Size(172, 15);
|
||||
this.label1.TabIndex = 1;
|
||||
@@ -741,7 +816,7 @@
|
||||
this.lameQualityGb.Controls.Add(this.label14);
|
||||
this.lameQualityGb.Controls.Add(this.label2);
|
||||
this.lameQualityGb.Controls.Add(this.lameVBRQualityTb);
|
||||
this.lameQualityGb.Location = new System.Drawing.Point(6, 202);
|
||||
this.lameQualityGb.Location = new System.Drawing.Point(6, 186);
|
||||
this.lameQualityGb.Name = "lameQualityGb";
|
||||
this.lameQualityGb.Size = new System.Drawing.Size(421, 109);
|
||||
this.lameQualityGb.TabIndex = 0;
|
||||
@@ -923,19 +998,6 @@
|
||||
this.retainAaxFileCbox.UseVisualStyleBackColor = true;
|
||||
this.retainAaxFileCbox.CheckedChanged += new System.EventHandler(this.allowLibationFixupCbox_CheckedChanged);
|
||||
//
|
||||
// createCueSheetCbox
|
||||
//
|
||||
this.createCueSheetCbox.AutoSize = true;
|
||||
this.createCueSheetCbox.Checked = true;
|
||||
this.createCueSheetCbox.CheckState = System.Windows.Forms.CheckState.Checked;
|
||||
this.createCueSheetCbox.Location = new System.Drawing.Point(19, 43);
|
||||
this.createCueSheetCbox.Name = "createCueSheetCbox";
|
||||
this.createCueSheetCbox.Size = new System.Drawing.Size(145, 19);
|
||||
this.createCueSheetCbox.TabIndex = 10;
|
||||
this.createCueSheetCbox.Text = "[CreateCueSheet desc]";
|
||||
this.createCueSheetCbox.UseVisualStyleBackColor = true;
|
||||
this.createCueSheetCbox.CheckedChanged += new System.EventHandler(this.allowLibationFixupCbox_CheckedChanged);
|
||||
//
|
||||
// downloadCoverArtCbox
|
||||
//
|
||||
this.downloadCoverArtCbox.AutoSize = true;
|
||||
@@ -949,13 +1011,26 @@
|
||||
this.downloadCoverArtCbox.UseVisualStyleBackColor = true;
|
||||
this.downloadCoverArtCbox.CheckedChanged += new System.EventHandler(this.allowLibationFixupCbox_CheckedChanged);
|
||||
//
|
||||
// createCueSheetCbox
|
||||
//
|
||||
this.createCueSheetCbox.AutoSize = true;
|
||||
this.createCueSheetCbox.Checked = true;
|
||||
this.createCueSheetCbox.CheckState = System.Windows.Forms.CheckState.Checked;
|
||||
this.createCueSheetCbox.Location = new System.Drawing.Point(19, 43);
|
||||
this.createCueSheetCbox.Name = "createCueSheetCbox";
|
||||
this.createCueSheetCbox.Size = new System.Drawing.Size(145, 19);
|
||||
this.createCueSheetCbox.TabIndex = 10;
|
||||
this.createCueSheetCbox.Text = "[CreateCueSheet desc]";
|
||||
this.createCueSheetCbox.UseVisualStyleBackColor = true;
|
||||
this.createCueSheetCbox.CheckedChanged += new System.EventHandler(this.allowLibationFixupCbox_CheckedChanged);
|
||||
//
|
||||
// SettingsDialog
|
||||
//
|
||||
this.AcceptButton = this.saveBtn;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.CancelButton = this.cancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(886, 484);
|
||||
this.ClientSize = new System.Drawing.Size(886, 504);
|
||||
this.Controls.Add(this.tabControl);
|
||||
this.Controls.Add(this.cancelBtn);
|
||||
this.Controls.Add(this.saveBtn);
|
||||
@@ -983,6 +1058,8 @@
|
||||
this.customFileNamingGb.PerformLayout();
|
||||
this.tab4AudioFileOptions.ResumeLayout(false);
|
||||
this.tab4AudioFileOptions.PerformLayout();
|
||||
this.chapterTitleTemplateGb.ResumeLayout(false);
|
||||
this.chapterTitleTemplateGb.PerformLayout();
|
||||
this.lameOptionsGb.ResumeLayout(false);
|
||||
this.lameOptionsGb.PerformLayout();
|
||||
this.lameBitrateGb.ResumeLayout(false);
|
||||
@@ -1002,11 +1079,11 @@
|
||||
private System.Windows.Forms.Label inProgressDescLbl;
|
||||
private System.Windows.Forms.Button saveBtn;
|
||||
private System.Windows.Forms.Button cancelBtn;
|
||||
private System.Windows.Forms.CheckBox allowLibationFixupCbox;
|
||||
private System.Windows.Forms.CheckBox allowLibationFixupCbox;
|
||||
private DirectoryOrCustomSelectControl booksSelectControl;
|
||||
private DirectorySelectControl inProgressSelectControl;
|
||||
private System.Windows.Forms.RadioButton convertLossyRb;
|
||||
private System.Windows.Forms.RadioButton convertLosslessRb;
|
||||
private System.Windows.Forms.RadioButton convertLossyRb;
|
||||
private System.Windows.Forms.RadioButton convertLosslessRb;
|
||||
private System.Windows.Forms.Button logsBtn;
|
||||
private System.Windows.Forms.Label loggingLevelLbl;
|
||||
private System.Windows.Forms.ComboBox loggingLevelCb;
|
||||
@@ -1017,7 +1094,7 @@
|
||||
private System.Windows.Forms.RadioButton badBookIgnoreRb;
|
||||
private System.Windows.Forms.CheckBox downloadEpisodesCb;
|
||||
private System.Windows.Forms.CheckBox importEpisodesCb;
|
||||
private System.Windows.Forms.CheckBox splitFilesByChapterCbox;
|
||||
private System.Windows.Forms.CheckBox splitFilesByChapterCbox;
|
||||
private System.Windows.Forms.TabControl tabControl;
|
||||
private System.Windows.Forms.TabPage tab1ImportantSettings;
|
||||
private System.Windows.Forms.GroupBox booksGb;
|
||||
@@ -1034,7 +1111,7 @@
|
||||
private System.Windows.Forms.Button folderTemplateBtn;
|
||||
private System.Windows.Forms.TextBox folderTemplateTb;
|
||||
private System.Windows.Forms.Label folderTemplateLbl;
|
||||
private System.Windows.Forms.CheckBox showImportedStatsCb;
|
||||
private System.Windows.Forms.CheckBox showImportedStatsCb;
|
||||
private System.Windows.Forms.CheckBox stripAudibleBrandingCbox;
|
||||
private System.Windows.Forms.TabPage tab4AudioFileOptions;
|
||||
private System.Windows.Forms.CheckBox retainAaxFileCbox;
|
||||
@@ -1070,7 +1147,13 @@
|
||||
private System.Windows.Forms.Label label17;
|
||||
private System.Windows.Forms.Label label16;
|
||||
private System.Windows.Forms.CheckBox createCueSheetCbox;
|
||||
private System.Windows.Forms.CheckBox autoScanCb;
|
||||
private System.Windows.Forms.CheckBox autoScanCb;
|
||||
private System.Windows.Forms.CheckBox downloadCoverArtCbox;
|
||||
private System.Windows.Forms.CheckBox autoDownloadEpisodesCb;
|
||||
private System.Windows.Forms.CheckBox saveEpisodesToSeriesFolderCbox;
|
||||
private System.Windows.Forms.GroupBox chapterTitleTemplateGb;
|
||||
private System.Windows.Forms.Button chapterTitleTemplateBtn;
|
||||
private System.Windows.Forms.TextBox chapterTitleTemplateTb;
|
||||
private System.Windows.Forms.Button editCharreplacementBtn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class SettingsDialog
|
||||
{
|
||||
private void folderTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.Folder, folderTemplateTb);
|
||||
private void fileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.File, fileTemplateTb);
|
||||
private void chapterFileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterFile, chapterFileTemplateTb);
|
||||
|
||||
|
||||
private void editCharreplacementBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
var form = new EditReplacementChars(config);
|
||||
form.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
form.ShowDialog(this);
|
||||
}
|
||||
|
||||
private void Load_DownloadDecrypt(Configuration config)
|
||||
{
|
||||
inProgressDescLbl.Text = desc(nameof(config.InProgress));
|
||||
editCharreplacementBtn.Text = desc(nameof(config.ReplacementCharacters));
|
||||
|
||||
badBookGb.Text = desc(nameof(config.BadBook));
|
||||
badBookAskRb.Text = Configuration.BadBookAction.Ask.GetDescription();
|
||||
badBookAbortRb.Text = Configuration.BadBookAction.Abort.GetDescription();
|
||||
badBookRetryRb.Text = Configuration.BadBookAction.Retry.GetDescription();
|
||||
badBookIgnoreRb.Text = Configuration.BadBookAction.Ignore.GetDescription();
|
||||
|
||||
inProgressSelectControl.SetDirectoryItems(new()
|
||||
{
|
||||
Configuration.KnownDirectories.WinTemp,
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
Configuration.KnownDirectories.AppDir,
|
||||
Configuration.KnownDirectories.MyDocs,
|
||||
Configuration.KnownDirectories.LibationFiles
|
||||
}, Configuration.KnownDirectories.WinTemp);
|
||||
inProgressSelectControl.SelectDirectory(config.InProgress);
|
||||
|
||||
var rb = config.BadBook switch
|
||||
{
|
||||
Configuration.BadBookAction.Ask => this.badBookAskRb,
|
||||
Configuration.BadBookAction.Abort => this.badBookAbortRb,
|
||||
Configuration.BadBookAction.Retry => this.badBookRetryRb,
|
||||
Configuration.BadBookAction.Ignore => this.badBookIgnoreRb,
|
||||
_ => this.badBookAskRb
|
||||
};
|
||||
rb.Checked = true;
|
||||
|
||||
folderTemplateLbl.Text = desc(nameof(config.FolderTemplate));
|
||||
fileTemplateLbl.Text = desc(nameof(config.FileTemplate));
|
||||
chapterFileTemplateLbl.Text = desc(nameof(config.ChapterFileTemplate));
|
||||
folderTemplateTb.Text = config.FolderTemplate;
|
||||
fileTemplateTb.Text = config.FileTemplate;
|
||||
chapterFileTemplateTb.Text = config.ChapterFileTemplate;
|
||||
}
|
||||
|
||||
private void Save_DownloadDecrypt(Configuration config)
|
||||
{
|
||||
config.InProgress = inProgressSelectControl.SelectedDirectory;
|
||||
|
||||
config.BadBook
|
||||
= badBookAskRb.Checked ? Configuration.BadBookAction.Ask
|
||||
: badBookAbortRb.Checked ? Configuration.BadBookAction.Abort
|
||||
: badBookRetryRb.Checked ? Configuration.BadBookAction.Retry
|
||||
: badBookIgnoreRb.Checked ? Configuration.BadBookAction.Ignore
|
||||
: Configuration.BadBookAction.Ask;
|
||||
|
||||
config.FolderTemplate = folderTemplateTb.Text;
|
||||
config.FileTemplate = fileTemplateTb.Text;
|
||||
config.ChapterFileTemplate = chapterFileTemplateTb.Text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class SettingsDialog
|
||||
{
|
||||
private void Load_ImportLibrary(Configuration config)
|
||||
{
|
||||
this.autoScanCb.Text = desc(nameof(config.AutoScan));
|
||||
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
|
||||
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
|
||||
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
|
||||
this.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes));
|
||||
|
||||
autoScanCb.Checked = config.AutoScan;
|
||||
showImportedStatsCb.Checked = config.ShowImportedStats;
|
||||
importEpisodesCb.Checked = config.ImportEpisodes;
|
||||
downloadEpisodesCb.Checked = config.DownloadEpisodes;
|
||||
autoDownloadEpisodesCb.Checked = config.AutoDownloadEpisodes;
|
||||
}
|
||||
private void Save_ImportLibrary(Configuration config)
|
||||
{
|
||||
config.AutoScan = autoScanCb.Checked;
|
||||
config.ShowImportedStats = showImportedStatsCb.Checked;
|
||||
config.ImportEpisodes = importEpisodesCb.Checked;
|
||||
config.DownloadEpisodes = downloadEpisodesCb.Checked;
|
||||
config.AutoDownloadEpisodes = autoDownloadEpisodesCb.Checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class SettingsDialog
|
||||
{
|
||||
private void logsBtn_Click(object sender, EventArgs e) => Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
|
||||
|
||||
private void Load_Important(Configuration config)
|
||||
{
|
||||
{
|
||||
loggingLevelCb.Items.Clear();
|
||||
foreach (var level in Enum<Serilog.Events.LogEventLevel>.GetValues())
|
||||
loggingLevelCb.Items.Add(level);
|
||||
loggingLevelCb.SelectedItem = config.LogLevel;
|
||||
}
|
||||
|
||||
booksLocationDescLbl.Text = desc(nameof(config.Books));
|
||||
this.saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder));
|
||||
|
||||
booksSelectControl.SetSearchTitle("books location");
|
||||
booksSelectControl.SetDirectoryItems(
|
||||
new()
|
||||
{
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
Configuration.KnownDirectories.AppDir,
|
||||
Configuration.KnownDirectories.MyDocs
|
||||
},
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
"Books");
|
||||
booksSelectControl.SelectDirectory(config.Books);
|
||||
|
||||
saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder;
|
||||
}
|
||||
|
||||
private void Save_Important(Configuration config)
|
||||
{
|
||||
var newBooks = booksSelectControl.SelectedDirectory;
|
||||
|
||||
#region validation
|
||||
static void validationError(string text, string caption)
|
||||
=> MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
if (string.IsNullOrWhiteSpace(newBooks))
|
||||
{
|
||||
validationError("Cannot set Books Location to blank", "Location is blank");
|
||||
return;
|
||||
}
|
||||
|
||||
// these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
|
||||
if (!Templates.Folder.IsValid(folderTemplateTb.Text))
|
||||
{
|
||||
validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
|
||||
return;
|
||||
}
|
||||
if (!Templates.File.IsValid(fileTemplateTb.Text))
|
||||
{
|
||||
validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
|
||||
return;
|
||||
}
|
||||
if (!Templates.ChapterFile.IsValid(chapterFileTemplateTb.Text))
|
||||
{
|
||||
validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
|
||||
return;
|
||||
}
|
||||
#endregion
|
||||
|
||||
LongPath lonNewBooks = newBooks;
|
||||
if (!Directory.Exists(lonNewBooks))
|
||||
Directory.CreateDirectory(lonNewBooks);
|
||||
|
||||
config.Books = newBooks;
|
||||
|
||||
{
|
||||
var logLevelOld = config.LogLevel;
|
||||
var logLevelNew = (Serilog.Events.LogEventLevel)loggingLevelCb.SelectedItem;
|
||||
|
||||
config.LogLevel = logLevelNew;
|
||||
|
||||
// only warn if changed during this time. don't want to warn every time user happens to change settings while level is verbose
|
||||
if (logLevelOld != logLevelNew)
|
||||
MessageBoxLib.VerboseLoggingWarning_ShowIfTrue();
|
||||
}
|
||||
|
||||
config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
@@ -24,106 +21,12 @@ namespace LibationWinForms.Dialogs
|
||||
if (this.DesignMode)
|
||||
return;
|
||||
|
||||
{
|
||||
loggingLevelCb.Items.Clear();
|
||||
foreach (var level in Enum<Serilog.Events.LogEventLevel>.GetValues())
|
||||
loggingLevelCb.Items.Add(level);
|
||||
loggingLevelCb.SelectedItem = config.LogLevel;
|
||||
}
|
||||
|
||||
this.autoScanCb.Text = desc(nameof(config.AutoScan));
|
||||
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
|
||||
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
|
||||
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
|
||||
|
||||
this.booksLocationDescLbl.Text = desc(nameof(config.Books));
|
||||
this.inProgressDescLbl.Text = desc(nameof(config.InProgress));
|
||||
this.allowLibationFixupCbox.Text = desc(nameof(config.AllowLibationFixup));
|
||||
this.splitFilesByChapterCbox.Text = desc(nameof(config.SplitFilesByChapter));
|
||||
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
|
||||
this.retainAaxFileCbox.Text = desc(nameof(config.RetainAaxFile));
|
||||
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
|
||||
this.createCueSheetCbox.Text = desc(nameof(config.CreateCueSheet));
|
||||
this.downloadCoverArtCbox.Text = desc(nameof(config.DownloadCoverArt));
|
||||
|
||||
booksSelectControl.SetSearchTitle("books location");
|
||||
booksSelectControl.SetDirectoryItems(
|
||||
new()
|
||||
{
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
Configuration.KnownDirectories.AppDir,
|
||||
Configuration.KnownDirectories.MyDocs
|
||||
},
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
"Books");
|
||||
booksSelectControl.SelectDirectory(config.Books);
|
||||
|
||||
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
|
||||
createCueSheetCbox.Checked = config.CreateCueSheet;
|
||||
retainAaxFileCbox.Checked = config.RetainAaxFile;
|
||||
splitFilesByChapterCbox.Checked = config.SplitFilesByChapter;
|
||||
stripUnabridgedCbox.Checked = config.StripUnabridged;
|
||||
stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio;
|
||||
convertLosslessRb.Checked = !config.DecryptToLossy;
|
||||
convertLossyRb.Checked = config.DecryptToLossy;
|
||||
|
||||
lameTargetBitrateRb.Checked = config.LameTargetBitrate;
|
||||
lameTargetQualityRb.Checked = !config.LameTargetBitrate;
|
||||
lameDownsampleMonoCbox.Checked = config.LameDownsampleMono;
|
||||
lameBitrateTb.Value = config.LameBitrate;
|
||||
lameConstantBitrateCbox.Checked = config.LameConstantBitrate;
|
||||
LameMatchSourceBRCbox.Checked = config.LameMatchSourceBR;
|
||||
lameVBRQualityTb.Value = config.LameVBRQuality;
|
||||
downloadCoverArtCbox.Checked = config.DownloadCoverArt;
|
||||
|
||||
autoScanCb.Checked = config.AutoScan;
|
||||
showImportedStatsCb.Checked = config.ShowImportedStats;
|
||||
importEpisodesCb.Checked = config.ImportEpisodes;
|
||||
downloadEpisodesCb.Checked = config.DownloadEpisodes;
|
||||
|
||||
lameTargetRb_CheckedChanged(this, e);
|
||||
LameMatchSourceBRCbox_CheckedChanged(this, e);
|
||||
convertFormatRb_CheckedChanged(this, e);
|
||||
allowLibationFixupCbox_CheckedChanged(this, e);
|
||||
|
||||
inProgressSelectControl.SetDirectoryItems(new()
|
||||
{
|
||||
Configuration.KnownDirectories.WinTemp,
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
Configuration.KnownDirectories.AppDir,
|
||||
Configuration.KnownDirectories.MyDocs,
|
||||
Configuration.KnownDirectories.LibationFiles
|
||||
}, Configuration.KnownDirectories.WinTemp);
|
||||
inProgressSelectControl.SelectDirectory(config.InProgress);
|
||||
|
||||
badBookGb.Text = desc(nameof(config.BadBook));
|
||||
badBookAskRb.Text = Configuration.BadBookAction.Ask.GetDescription();
|
||||
badBookAbortRb.Text = Configuration.BadBookAction.Abort.GetDescription();
|
||||
badBookRetryRb.Text = Configuration.BadBookAction.Retry.GetDescription();
|
||||
badBookIgnoreRb.Text = Configuration.BadBookAction.Ignore.GetDescription();
|
||||
var rb = config.BadBook switch
|
||||
{
|
||||
Configuration.BadBookAction.Ask => this.badBookAskRb,
|
||||
Configuration.BadBookAction.Abort => this.badBookAbortRb,
|
||||
Configuration.BadBookAction.Retry => this.badBookRetryRb,
|
||||
Configuration.BadBookAction.Ignore => this.badBookIgnoreRb,
|
||||
_ => this.badBookAskRb
|
||||
};
|
||||
rb.Checked = true;
|
||||
|
||||
folderTemplateLbl.Text = desc(nameof(config.FolderTemplate));
|
||||
fileTemplateLbl.Text = desc(nameof(config.FileTemplate));
|
||||
chapterFileTemplateLbl.Text = desc(nameof(config.ChapterFileTemplate));
|
||||
folderTemplateTb.Text = config.FolderTemplate;
|
||||
fileTemplateTb.Text = config.FileTemplate;
|
||||
chapterFileTemplateTb.Text = config.ChapterFileTemplate;
|
||||
Load_Important(config);
|
||||
Load_ImportLibrary(config);
|
||||
Load_DownloadDecrypt(config);
|
||||
Load_AudioSettings(config);
|
||||
}
|
||||
|
||||
private void logsBtn_Click(object sender, EventArgs e) => Go.To.Folder(Configuration.Instance.LibationFiles);
|
||||
|
||||
private void folderTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.Folder, folderTemplateTb);
|
||||
private void fileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.File, fileTemplateTb);
|
||||
private void chapterFileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterFile, chapterFileTemplateTb);
|
||||
private static void editTemplate(Templates template, TextBox textBox)
|
||||
{
|
||||
var form = new EditTemplateDialog(template, textBox.Text);
|
||||
@@ -133,90 +36,10 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
private void saveBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
var newBooks = booksSelectControl.SelectedDirectory;
|
||||
|
||||
#region validation
|
||||
static void validationError(string text, string caption)
|
||||
=> MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
if (string.IsNullOrWhiteSpace(newBooks))
|
||||
{
|
||||
validationError("Cannot set Books Location to blank", "Location is blank");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(newBooks) && booksSelectControl.SelectedDirectoryIsCustom)
|
||||
{
|
||||
validationError($"Not saving change to Books location. This folder does not exist:\r\n{newBooks}", "Folder does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
// these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
|
||||
if (!Templates.Folder.IsValid(folderTemplateTb.Text))
|
||||
{
|
||||
validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
|
||||
return;
|
||||
}
|
||||
if (!Templates.File.IsValid(fileTemplateTb.Text))
|
||||
{
|
||||
validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
|
||||
return;
|
||||
}
|
||||
if (!Templates.ChapterFile.IsValid(chapterFileTemplateTb.Text))
|
||||
{
|
||||
validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
|
||||
return;
|
||||
}
|
||||
#endregion
|
||||
|
||||
if (!Directory.Exists(newBooks) && booksSelectControl.SelectedDirectoryIsKnown)
|
||||
Directory.CreateDirectory(newBooks);
|
||||
|
||||
config.Books = newBooks;
|
||||
|
||||
{
|
||||
var logLevelOld = config.LogLevel;
|
||||
var logLevelNew = (Serilog.Events.LogEventLevel)loggingLevelCb.SelectedItem;
|
||||
|
||||
config.LogLevel = logLevelNew;
|
||||
|
||||
// only warn if changed during this time. don't want to warn every time user happens to change settings while level is verbose
|
||||
if (logLevelOld != logLevelNew)
|
||||
MessageBoxLib.VerboseLoggingWarning_ShowIfTrue();
|
||||
}
|
||||
|
||||
config.AllowLibationFixup = allowLibationFixupCbox.Checked;
|
||||
config.CreateCueSheet = createCueSheetCbox.Checked;
|
||||
config.RetainAaxFile = retainAaxFileCbox.Checked;
|
||||
config.SplitFilesByChapter = splitFilesByChapterCbox.Checked;
|
||||
config.StripUnabridged = stripUnabridgedCbox.Checked;
|
||||
config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked;
|
||||
config.DecryptToLossy = convertLossyRb.Checked;
|
||||
|
||||
config.LameTargetBitrate = lameTargetBitrateRb.Checked;
|
||||
config.LameDownsampleMono = lameDownsampleMonoCbox.Checked;
|
||||
config.LameBitrate = lameBitrateTb.Value;
|
||||
config.LameConstantBitrate = lameConstantBitrateCbox.Checked;
|
||||
config.LameMatchSourceBR = LameMatchSourceBRCbox.Checked;
|
||||
config.LameVBRQuality = lameVBRQualityTb.Value;
|
||||
config.DownloadCoverArt = downloadCoverArtCbox.Checked;
|
||||
|
||||
config.AutoScan = autoScanCb.Checked;
|
||||
config.ShowImportedStats = showImportedStatsCb.Checked;
|
||||
config.ImportEpisodes = importEpisodesCb.Checked;
|
||||
config.DownloadEpisodes = downloadEpisodesCb.Checked;
|
||||
|
||||
config.InProgress = inProgressSelectControl.SelectedDirectory;
|
||||
|
||||
config.BadBook
|
||||
= badBookAskRb.Checked ? Configuration.BadBookAction.Ask
|
||||
: badBookAbortRb.Checked ? Configuration.BadBookAction.Abort
|
||||
: badBookRetryRb.Checked ? Configuration.BadBookAction.Retry
|
||||
: badBookIgnoreRb.Checked ? Configuration.BadBookAction.Ignore
|
||||
: Configuration.BadBookAction.Ask;
|
||||
|
||||
config.FolderTemplate = folderTemplateTb.Text;
|
||||
config.FileTemplate = fileTemplateTb.Text;
|
||||
config.ChapterFileTemplate = chapterFileTemplateTb.Text;
|
||||
Save_Important(config);
|
||||
Save_ImportLibrary(config);
|
||||
Save_DownloadDecrypt(config);
|
||||
Save_AudioSettings(config);
|
||||
|
||||
this.DialogResult = DialogResult.OK;
|
||||
this.Close();
|
||||
@@ -227,6 +50,5 @@ namespace LibationWinForms.Dialogs
|
||||
this.DialogResult = DialogResult.Cancel;
|
||||
this.Close();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace LibationWinForms
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private System.ComponentModel.BackgroundWorker updateCountsBw = new();
|
||||
|
||||
protected void Configure_BackupCounts()
|
||||
{
|
||||
// init formattable
|
||||
@@ -16,22 +18,23 @@ namespace LibationWinForms
|
||||
Load += setBackupCounts;
|
||||
LibraryCommands.LibrarySizeChanged += setBackupCounts;
|
||||
LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts;
|
||||
}
|
||||
|
||||
private System.ComponentModel.BackgroundWorker updateCountsBw;
|
||||
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
|
||||
updateCountsBw.RunWorkerCompleted += exportMenuEnable;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers;
|
||||
updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbers;
|
||||
updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem;
|
||||
}
|
||||
|
||||
private bool runBackupCountsAgain;
|
||||
|
||||
private void setBackupCounts(object _, object __)
|
||||
{
|
||||
runBackupCountsAgain = true;
|
||||
|
||||
if (updateCountsBw is not null)
|
||||
return;
|
||||
|
||||
updateCountsBw = new System.ComponentModel.BackgroundWorker();
|
||||
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
|
||||
updateCountsBw.RunWorkerCompleted += UpdateCountsBw_RunWorkerCompleted;
|
||||
updateCountsBw.RunWorkerAsync();
|
||||
if (!updateCountsBw.IsBusy)
|
||||
updateCountsBw.RunWorkerAsync();
|
||||
}
|
||||
|
||||
private void UpdateCountsBw_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
|
||||
@@ -39,87 +42,78 @@ namespace LibationWinForms
|
||||
while (runBackupCountsAgain)
|
||||
{
|
||||
runBackupCountsAgain = false;
|
||||
|
||||
var libraryStats = LibraryCommands.GetCounts();
|
||||
e.Result = libraryStats;
|
||||
e.Result = LibraryCommands.GetCounts();
|
||||
}
|
||||
updateCountsBw = null;
|
||||
}
|
||||
|
||||
private void UpdateCountsBw_RunWorkerCompleted(object sender, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
private void exportMenuEnable(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
setBookBackupCounts(libraryStats);
|
||||
setPdfBackupCounts(libraryStats);
|
||||
exportLibraryToolStripMenuItem.Enabled = libraryStats.HasBookResults;
|
||||
}
|
||||
|
||||
// this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text
|
||||
private const string backupsCountsLbl_Format = "BACKUPS: No progress: {0} In process: {1} Fully backed up: {2}";
|
||||
|
||||
private void setBookBackupCounts(LibraryCommands.LibraryStats libraryStats)
|
||||
private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var pending = libraryStats.booksNoProgress + libraryStats.booksDownloadedOnly;
|
||||
var hasResults = 0 < (libraryStats.booksFullyBackedUp + libraryStats.booksDownloadedOnly + libraryStats.booksNoProgress + libraryStats.booksError);
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
// enable/disable export
|
||||
{
|
||||
exportLibraryToolStripMenuItem.Enabled = hasResults;
|
||||
}
|
||||
|
||||
// update bottom numbers
|
||||
{
|
||||
var formatString
|
||||
= !hasResults ? "No books. Begin by importing your library"
|
||||
: libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
|
||||
: pending > 0 ? backupsCountsLbl_Format
|
||||
: $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up";
|
||||
var statusStripText = string.Format(formatString,
|
||||
libraryStats.booksNoProgress,
|
||||
libraryStats.booksDownloadedOnly,
|
||||
libraryStats.booksFullyBackedUp,
|
||||
libraryStats.booksError);
|
||||
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = statusStripText);
|
||||
}
|
||||
|
||||
// update 'begin book backups' menu item
|
||||
{
|
||||
var menuItemText
|
||||
= pending > 0
|
||||
? $"{pending} remaining"
|
||||
: "All books have been liberated";
|
||||
menuStrip1.UIThreadAsync(() =>
|
||||
{
|
||||
beginBookBackupsToolStripMenuItem.Format(menuItemText);
|
||||
beginBookBackupsToolStripMenuItem.Enabled = pending > 0;
|
||||
});
|
||||
}
|
||||
var formatString
|
||||
= !libraryStats.HasBookResults ? "No books. Begin by importing your library"
|
||||
: libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
|
||||
: libraryStats.HasPendingBooks ? backupsCountsLbl_Format
|
||||
: $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up";
|
||||
var statusStripText = string.Format(formatString,
|
||||
libraryStats.booksNoProgress,
|
||||
libraryStats.booksDownloadedOnly,
|
||||
libraryStats.booksFullyBackedUp,
|
||||
libraryStats.booksError);
|
||||
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = statusStripText);
|
||||
}
|
||||
private void setPdfBackupCounts(LibraryCommands.LibraryStats libraryStats)
|
||||
{
|
||||
// update bottom numbers
|
||||
{
|
||||
var hasResults = 0 < (libraryStats.pdfsNotDownloaded + libraryStats.pdfsDownloaded);
|
||||
// don't need to assign the output of Format(). It just makes this logic cleaner
|
||||
var statusStripText
|
||||
= !hasResults ? ""
|
||||
: libraryStats.pdfsNotDownloaded > 0 ? pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded)
|
||||
: $"| All {libraryStats.pdfsDownloaded} PDFs downloaded";
|
||||
statusStrip1.UIThreadAsync(() => pdfsCountsLbl.Text = statusStripText);
|
||||
}
|
||||
|
||||
// update 'begin pdf only backups' menu item
|
||||
// update 'begin book backups' menu item
|
||||
private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
var menuItemText
|
||||
= libraryStats.HasPendingBooks
|
||||
? $"{libraryStats.PendingBooks} remaining"
|
||||
: "All books have been liberated";
|
||||
menuStrip1.UIThreadAsync(() =>
|
||||
{
|
||||
var menuItemText
|
||||
= libraryStats.pdfsNotDownloaded > 0
|
||||
? $"{libraryStats.pdfsNotDownloaded} remaining"
|
||||
: "All PDFs have been downloaded";
|
||||
menuStrip1.UIThreadAsync(() =>
|
||||
{
|
||||
beginPdfBackupsToolStripMenuItem.Format(menuItemText);
|
||||
beginPdfBackupsToolStripMenuItem.Enabled = libraryStats.pdfsNotDownloaded > 0;
|
||||
});
|
||||
}
|
||||
beginBookBackupsToolStripMenuItem.Format(menuItemText);
|
||||
beginBookBackupsToolStripMenuItem.Enabled = libraryStats.HasPendingBooks;
|
||||
});
|
||||
}
|
||||
|
||||
private void updateBottomPdfNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
// don't need to assign the output of Format(). It just makes this logic cleaner
|
||||
var statusStripText
|
||||
= !libraryStats.HasPdfResults ? ""
|
||||
: libraryStats.pdfsNotDownloaded > 0 ? pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded)
|
||||
: $"| All {libraryStats.pdfsDownloaded} PDFs downloaded";
|
||||
statusStrip1.UIThreadAsync(() => pdfsCountsLbl.Text = statusStripText);
|
||||
}
|
||||
|
||||
// update 'begin pdf only backups' menu item
|
||||
private void udpate_BeginPdfOnlyBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
var menuItemText
|
||||
= libraryStats.pdfsNotDownloaded > 0
|
||||
? $"{libraryStats.pdfsNotDownloaded} remaining"
|
||||
: "All PDFs have been downloaded";
|
||||
menuStrip1.UIThreadAsync(() =>
|
||||
{
|
||||
beginPdfBackupsToolStripMenuItem.Format(menuItemText);
|
||||
beginPdfBackupsToolStripMenuItem.Enabled = libraryStats.pdfsNotDownloaded > 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||