Compare commits

...

79 Commits

Author SHA1 Message Date
Robert McRackan
ad79075fd7 Fix issues #183 and #186 2022-06-20 16:30:40 -04:00
rmcrackan
7baefe2f44 Merge pull request #277 from Mbucari/master
Issues #183 and #186, and a lot of other little things
2022-06-20 16:29:20 -04:00
Michael Bucari-Tovo
141a4c29bb Correct error in saving settings 2022-06-20 14:04:03 -06:00
Michael Bucari-Tovo
b2992da370 Move DownloadOptions to FileLiberator 2022-06-20 10:22:21 -06:00
Michael Bucari-Tovo
fdee254020 Only copy files if conversion succeeded. 2022-06-20 09:04:06 -06:00
Michael Bucari-Tovo
c51489ac74 Await cancell 2022-06-19 18:49:47 -06:00
Michael Bucari-Tovo
3cd394ec10 Change unicode asterisk 2022-06-19 18:04:00 -06:00
Michael Bucari-Tovo
8374fea776 Update tests for unicode chars 2022-06-19 17:59:16 -06:00
Michael Bucari-Tovo
733ca891de Fix unicode replacement 2022-06-19 17:29:46 -06:00
Michael Bucari-Tovo
490d121db3 Add unicode replacements for illegal characters 2022-06-19 16:57:44 -06:00
Michael Bucari-Tovo
45c5efffbd Add support for multipart title naming templates 2022-06-19 15:42:21 -06:00
Michael Bucari-Tovo
a24c929acf Update tests for long file paths 2022-06-19 15:38:59 -06:00
Michael Bucari-Tovo
86a39f10d1 Formatting 2022-06-19 12:59:35 -06:00
Michael Bucari-Tovo
4658afdc20 Add Track Number support and make Cancel async 2022-06-19 12:56:33 -06:00
Michael Bucari-Tovo
ae6c2afb30 Improve filename template 2022-06-18 13:04:57 -06:00
Michael Bucari-Tovo
a3844a3535 Add long path support 2022-06-18 11:28:48 -06:00
Michael Bucari-Tovo
b710075544 Make use of unauthenticated API 2022-06-17 23:09:22 -06:00
Mbucari
c4c9786050 Merge branch 'rmcrackan:master' into master 2022-06-17 16:46:31 -06:00
Robert McRackan
b4cc81139a Bug fix ( #276 ): x-thread error on fresh install 2022-06-17 12:40:37 -04:00
Mbucari
fb20eb9162 Merge branch 'rmcrackan:master' into master 2022-06-15 14:22:09 -06:00
Robert McRackan
263987d2c9 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-06-15 10:43:04 -04:00
Robert McRackan
0b30a35383 updated dependencies 2022-06-15 10:42:56 -04:00
Mbucari
47df1fc602 Merge branch 'rmcrackan:master' into master 2022-06-14 10:46:00 -06:00
rmcrackan
d8375454b9 Merge pull request #274 from maaximal/spelling
Fix spelling error
2022-06-14 09:16:05 -04:00
Max Byszio
ad535501c4 Fix spelling error 2022-06-14 14:43:21 +02:00
Michael Bucari-Tovo
159f5cbd00 Add lame options to ConvertToMp3 2022-06-13 22:18:00 -06:00
Michael Bucari-Tovo
2bc74d5378 Combine Streamable and Processable, remove unused events. 2022-06-13 21:40:37 -06:00
Robert McRackan
eb513f563e Allow sorting by "Remove" column 2022-06-13 13:55:39 -04:00
rmcrackan
09dc5e9846 Merge pull request #273 from Mbucari/master
Add option to save episodes to series parent
2022-06-13 12:00:05 -04:00
Mbucari
cf35a87d85 Update AppScaffolding.csproj 2022-06-12 19:48:51 -06:00
Michael Bucari-Tovo
9f25f619a8 Formatting 2022-06-12 19:37:42 -06:00
Michael Bucari-Tovo
7e989c730c Add option to save podcasts to series folder 2022-06-12 19:36:18 -06:00
Robert McRackan
0926e86956 Version 8 2022-06-12 21:20:02 -04:00
rmcrackan
75967730fd Merge pull request #271 from Mbucari/master
Move Remove Books function into main grid, added more db migrations and fixups for episodes
2022-06-12 21:14:51 -04:00
Michael Bucari-Tovo
a3be3e354f Code readability changes 2022-06-12 17:30:11 -06:00
Michael Bucari-Tovo
58c52196f1 Remove Books button now on Main button row 2022-06-12 17:03:29 -06:00
Michael Bucari-Tovo
b7b49a60cf Migration Exception handling 2022-06-12 16:35:48 -06:00
Michael Bucari-Tovo
fa195483d6 Set Import/Esport initial directory 2022-06-12 16:29:33 -06:00
Michael Bucari-Tovo
2341f6ea3b Better display and hiding of process queue 2022-06-12 16:29:06 -06:00
Michael Bucari-Tovo
ffe0f0730d Don't fire click for error books 2022-06-12 15:27:10 -06:00
Michael Bucari-Tovo
23b512910e Update 2022-06-12 15:23:55 -06:00
Michael Bucari-Tovo
b1c624b104 Revised stoplight icons 2022-06-12 15:17:07 -06:00
Michael Bucari-Tovo
fe35be6682 New libation icons 2022-06-12 13:39:35 -06:00
Michael Bucari-Tovo
2d3eb29bd5 Move event invoke out of lock 2022-06-11 19:10:08 -06:00
Michael Bucari-Tovo
26f0ff62df Additional safety check 2022-06-11 15:10:18 -06:00
Michael Bucari-Tovo
5e145846bd Only check non-liberated books when doing scan remove books. 2022-06-11 12:42:00 -06:00
Michael Bucari-Tovo
1ae5f99bf0 Add migration to try and fix db for incorrect or missing espiode series entries. 2022-06-11 12:41:20 -06:00
Michael Bucari-Tovo
984119c7ee Exit download loop if zero bytes are read. 2022-06-10 21:00:04 -06:00
Michael Bucari-Tovo
f8f5eac109 Refactor 2022-06-10 20:45:10 -06:00
Michael Bucari-Tovo
4111d5fa48 Remove redundant declarations. 2022-06-10 19:37:50 -06:00
Michael Bucari-Tovo
2eca9056b9 Reorder api calls 2022-06-10 19:36:00 -06:00
Michael Bucari-Tovo
60e96572ff Always refresh token, regardless of expiration date. 2022-06-10 19:34:49 -06:00
Michael Bucari-Tovo
52193933b2 Add scan and remove books tomain view, remove separate dialog. 2022-06-10 19:22:54 -06:00
Michael Bucari-Tovo
7bcabdda38 FindInactiveBooks now fires ScanBegin and ScanEnd events 2022-06-10 18:30:16 -06:00
Mbucari
d993941c4d Merge branch 'rmcrackan:master' into master 2022-06-10 15:41:07 -06:00
Michael Bucari-Tovo
b447bff9a6 Add audible-cli import/export accounts 2022-06-10 15:19:05 -06:00
Robert McRackan
73cb5ffba4 clearly Hoopla integration isn't going to happen. delete temp files 2022-06-09 17:01:33 -04:00
Robert McRackan
7d694229c1 Prep for version release 2022-06-08 14:48:44 -04:00
rmcrackan
cdb6c9a1a4 Merge pull request #268 from Mbucari/master
Address issues in 263
2022-06-08 14:46:17 -04:00
Michael Bucari-Tovo
cc1d2b423f Fix an oopsie 2022-06-08 12:15:21 -06:00
Michael Bucari-Tovo
508e031143 Move all event invocations outside locks 2022-06-08 12:08:15 -06:00
Michael Bucari-Tovo
5a093a9a04 add event keyword 2022-06-08 10:53:45 -06:00
Michael Bucari-Tovo
074d647d19 Improve Query 2022-06-08 10:36:06 -06:00
Michael Bucari-Tovo
6cb98f99c5 Use new content type queries 2022-06-08 10:34:05 -06:00
Michael Bucari-Tovo
7d28681b23 Move queries into DataLayer 2022-06-08 10:08:18 -06:00
Michael Bucari-Tovo
859a8e933c Formatting 2022-06-08 09:46:11 -06:00
Michael Bucari-Tovo
a476d5986d Update dependency 2022-06-08 09:44:06 -06:00
Michael Bucari-Tovo
31812bc2d9 Refactoring 2022-06-08 09:24:06 -06:00
Michael Bucari-Tovo
30ba69eca7 Minor refactoring. 2022-06-08 08:52:25 -06:00
Michael Bucari-Tovo
cf1bc1c252 By defauly, only get actual books and not parents from DB 2022-06-08 08:40:25 -06:00
Michael Bucari-Tovo
ee109ba67d Refactor 2022-06-08 08:39:59 -06:00
Michael Bucari-Tovo
9c6211e8e0 Improve UI speed when adding many books to queue at once. 2022-06-08 08:39:17 -06:00
Michael Bucari-Tovo
0729e4ab09 Minor refactor 2022-06-07 15:41:33 -06:00
Michael Bucari-Tovo
5cbe728631 Don't add series parents to list 2022-06-07 15:32:49 -06:00
Michael Bucari-Tovo
920f4df213 Use new ContentType.Parent to add series info to grid display 2022-06-07 15:28:16 -06:00
Michael Bucari-Tovo
c48eacd9af Add ContentType.Parent
Import Series parent when only individual episodes are in library
2022-06-07 15:27:18 -06:00
Michael Bucari-Tovo
30e6deeeaa Add migration to cleans DB of 7.10.1 hack 2022-06-07 15:25:52 -06:00
Robert McRackan
5bc76a3160 New debugging tool: "Hangover". Will be packaged with all releases 2022-06-01 11:49:30 -04:00
Robert McRackan
114925ebce Global exception handling. Threadsafe MessageBoxAlertAdminDialog 2022-05-27 13:38:28 -04:00
125 changed files with 7604 additions and 4655 deletions

View File

@@ -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.9" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core;
using Dinah.Core.StepRunner;
using FileManager;
@@ -12,28 +13,23 @@ 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)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
["Step 3: Cleanup"] = Step_Cleanup,
};
this.multipartFileNameCallback = multipartFileNameCallback ?? MultiConvertFileProperties.DefaultMultipartFilename;
}
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
["Step 3: Cleanup"] = Step_Cleanup,
};
}
/*
/*
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 +52,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 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 = ConvertToMultiMp4a(splitChapters);
else
result = 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 ConversionResult ConvertToMultiMp4a(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp4a
(
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 ConversionResult ConvertToMultiMp3(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp3
(
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);
OnFileCreated(fileName);
}
}
multiPartFilePaths.Add(fileName);
FileUtility.SaferDelete(fileName);
var file = File.Open(fileName, FileMode.OpenOrCreate);
OnFileCreated(fileName);
return file;
}
}
}

View File

@@ -11,45 +11,45 @@ namespace AaxDecrypter
{
protected override StepSequence Steps { get; }
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + 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: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
["Step 3: Create Cue"] = Step_CreateCue,
["Step 4: Cleanup"] = Step_Cleanup,
};
}
private bool Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
private bool Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
FileUtility.SaferDelete(OutputFileName);
FileUtility.SaferDelete(OutputFileName);
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
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;
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;
DownloadOptions.ChapterInfo = AaxFile.Chapters;
DownloadOptions.ChapterInfo = AaxFile.Chapters;
Step_DownloadAudiobook_End(zeroProgress);
Step_DownloadAudiobook_End(zeroProgress);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
return success;
}
}
return success;
}
}
}

View File

@@ -1,6 +1,7 @@
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;
@@ -21,9 +22,10 @@ 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
@@ -33,29 +35,28 @@ namespace AaxDecrypter
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)
{
@@ -65,7 +66,7 @@ namespace AaxDecrypter
public bool Run()
{
var (IsSuccess, Elapsed) = Steps.Run();
var (IsSuccess, _) = Steps.Run();
if (!IsSuccess)
Serilog.Log.Logger.Error("Conversion failed");
@@ -79,10 +80,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)

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,23 @@
using AAXClean;
namespace AaxDecrypter
{
public interface IDownloadOptions
{
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);
}
}

View File

@@ -221,9 +221,10 @@ namespace AaxDecrypter
try
{
int bytesRead;
do
{
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
@@ -237,7 +238,7 @@ namespace AaxDecrypter
downloadedPiece.Set();
}
} while (downloadPosition < ContentLength && !IsCancelled);
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
_writeFile.Close();
_networkStream.Close();

View File

@@ -1,5 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
@@ -10,7 +11,7 @@ namespace AaxDecrypter
{
protected override StepSequence Steps { get; }
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, DownloadOptions dlLic)
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
@@ -24,10 +25,11 @@ namespace AaxDecrypter
};
}
public override void Cancel()
public override Task CancelAsync()
{
IsCanceled = true;
CloseInputFileStream();
return Task.CompletedTask;
}
protected bool Step_GetMetadata()

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Version>7.10.2.1</Version>
<Version>8.0.3.1</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -5,10 +5,10 @@ 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;
@@ -37,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();
//***********************************************//
// //
@@ -58,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)
@@ -124,6 +126,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;
@@ -256,18 +261,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(),
@@ -351,41 +359,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";
@@ -432,5 +405,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 occured while running database migrations in {0}", nameof(migrate_from_7_10_1));
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 wel'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 occured 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 endpointdoes 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 occured while trying to scan for orphaned episode parents.");
return -1;
}
}
public static int SaveContext(LibationContext context)
{
try
{

View File

@@ -29,7 +29,7 @@ namespace ApplicationServices
}
#endregion
public static EventHandler SearchEngineUpdated;
public static event EventHandler SearchEngineUpdated;
#region Update
private static bool isUpdating;

View File

@@ -1,11 +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;
@@ -129,12 +132,12 @@ namespace AudibleUtilities
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
{
if (item.IsEpisodes && importEpisodes)
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)
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
count++;
@@ -173,16 +176,65 @@ namespace AudibleUtilities
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
var children = await getEpisodeChildrenAsync(parent);
List<Item> children;
if (!children.Any())
if (parent.IsEpisodes)
{
//The parent is the only episode in the podcase series,
//so the parent is its own child.
parent.Series = new Series[] { new Series { Asin = parent.Asin, Sequence = RelationshipToProduct.Parent, Title = parent.TitleWithSubtitle } };
children.Add(parent);
return 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;
}
else
{
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)
{
@@ -199,17 +251,10 @@ namespace AudibleUtilities
Title = parent.TitleWithSubtitle
}
};
// overload (read: abuse) IsEpisodes flag
child.Relationships = new Relationship[]
{
new Relationship
{
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode
}
};
}
children.Add(parent);
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children;

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="3.0.2.1" />
<PackageReference Include="AudibleApi" Version="4.0.0.1" />
</ItemGroup>
<ItemGroup>

View 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,
};
}
}

View File

@@ -13,12 +13,12 @@
<ItemGroup>
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
<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>

View File

@@ -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
{

View File

@@ -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;
}
}

View File

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

View File

@@ -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)
@@ -184,5 +184,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;
}
}
}

View File

@@ -1,4 +1,7 @@
using System;
using LibationFileManager;
using NAudio.Lame;
using System;
using System.Threading.Tasks;
namespace FileLiberator
{
@@ -10,8 +13,32 @@ namespace FileLiberator
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)
{

View File

@@ -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);
}
}

View File

@@ -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 = 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 Task.Run(() => m4bBook.ConvertToMp3(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);
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
});
}
}
}

View File

@@ -14,251 +14,221 @@ 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.UpdateBookStatus(LiberatedStatus.Liberated);
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 Task.Run(abDownloader.Run);
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)
abDownloader.SetCoverArt(OnRequestCoverArt());
}
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)
};
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 < 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;
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
}
return dlOptions;
}
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;
@@ -283,33 +253,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.");
}
}
}
}

View File

@@ -0,0 +1,44 @@
using AaxDecrypter;
using AAXClean;
using Dinah.Core;
using DataLayer;
using LibationFileManager;
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 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
}
}
}

View File

@@ -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)

View File

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

View File

@@ -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));
}
}
}

View File

@@ -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)
@@ -124,16 +130,18 @@ 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)
{
path = path.LongPathName;
if (!File.Exists(path) && !Directory.Exists(path))
return;
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
@@ -141,12 +149,14 @@ namespace FileManager
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);

View File

@@ -1,64 +1,88 @@
using System;
using System.Collections.Generic;
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: &lt;name&gt;</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>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
public string IllegalCharacterReplacements { get; set; }
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
/// <summary>Generate a valid path for this file or directory</summary>
public LongPath GetFilePath(bool returnFirstExisting = false)
{
int lastSlash = Template.LastIndexOf('\\');
/// <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 directoryName = lastSlash >= 0 ? Template[..(lastSlash + 1)] : string.Empty;
var filename = lastSlash >= 0 ? Template[(lastSlash + 1)..] : Template;
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
public int? ParameterMaxSize { get; set; } = 50;
List<StringBuilder> filenameParts = new();
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
public string IllegalCharacterReplacements { get; set; }
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value));
/// <summary>Generate a valid path for this file or directory</summary>
public string GetFilePath(bool returnFirstExisting = false)
{
var filename = Template;
//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('>');
foreach (var r in ParameterReplacements)
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
if (openIndex == 0 && closeIndex > 0)
{
var key = filename[..(closeIndex + 1)];
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements, returnFirstExisting);
}
if (paramReplacements.ContainsKey(key))
filenameParts.Add(new StringBuilder(paramReplacements[key]));
else
filenameParts.Add(new StringBuilder(key));
private static string formatKey(string key)
=> key
.Replace("<", "")
.Replace(">", "");
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;
}
}
private string formatValue(object value)
{
if (value is null)
return "";
//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);
// 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;
}
}
maxEntry.Remove(maxLength - 1, 1);
}
filename = string.Join("", filenameParts);
return FileUtility.GetValidFilename(directoryName + filename, IllegalCharacterReplacements, returnFirstExisting);
}
private string formatValue(object value)
{
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 value
.ToString()
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
}
}
}

View File

@@ -9,264 +9,301 @@ 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, string illegalCharacterReplacements = "", bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
// remove invalid chars
path = GetSafePath(path, illegalCharacterReplacements);
// remove invalid chars
path = GetSafePath(path, illegalCharacterReplacements);
// 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()));
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
/// <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));
/// <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()));
path = replaceInvalidChars(path, illegalCharacterReplacements);
path = standardizeSlashes(path);
path = replaceColons(path, illegalCharacterReplacements);
path = removeDoubleSlashes(path);
/// <summary>Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/'</summary>
public static LongPath GetSafePath(LongPath path, string illegalCharacterReplacements = "")
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
return path;
}
var pathNoPrefix = path.PathWithoutPrefix;
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));
pathNoPrefix = replaceColons(pathNoPrefix, "");
pathNoPrefix = replaceIllegalWithUnicodeAnalog(pathNoPrefix);
pathNoPrefix = replaceInvalidChars(pathNoPrefix, illegalCharacterReplacements);
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
private static string standardizeSlashes(string path)
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
return pathNoPrefix;
}
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();
}
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 removeDoubleSlashes(string path)
{
if (path.Length < 2)
return path;
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
var remainder = path[1..];
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (remainder.Contains(dblSeparator))
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
var remainder = path[1..];
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (remainder.Contains(dblSeparator))
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
return path[0] + remainder;
}
return path[0] + remainder;
}
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
private static string replaceIllegalWithUnicodeAnalog(string path)
{
char[] replaced = path.ToCharArray();
/// <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
char GetQuote(int position)
{
if (
position == 0
|| (position > 0
&& position < replaced.Length
&& !char.IsLetter(replaced[position - 1])
&& !char.IsNumber(replaced[position - 1])
)
) return '“';
else if (
position == replaced.Length - 1
|| (position >= 0
&& position < replaced.Length - 1
&& !char.IsLetter(replaced[position + 1])
&& !char.IsNumber(replaced[position + 1])
)
) return '”';
else return '';
}
// 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);
for (int i = 0; i < replaced.Length; i++)
{
replaced[i] = replaced[i] switch
{
'?' => '',
'*' => '✱',
'<' => '',
'>' => '',
'"' => GetQuote(i),
_ => replaced[i]
};
}
return new string(replaced);
}
fullfilename = removeDoubleSlashes(fullfilename);
return fullfilename;
}
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();
}
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
/// <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
/// <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;
}
// 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);
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);
fullfilename = removeDoubleSlashes(fullfilename);
return fullfilename;
}
/// <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;
}
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
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;
}
});
/// <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)
{
destination = GetValidFilename(destination);
SaferMove(source, destination);
return destination;
}
/// <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;
}
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);
SaferDelete(destination);
/// <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;
}
var dir = Path.GetDirectoryName(destination);
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir);
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;
}
});
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;
}
});
/// <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>
/// 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>();
SaferDelete(destination);
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) { }
}
var dir = Path.GetDirectoryName(destination);
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir);
try
{
// Add files from the current directory
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern));
}
catch (UnauthorizedAccessException) { }
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;
}
});
return foundFiles;
}
}
/// <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>();
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) { }
}
try
{
// Add files from the current directory
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
}
catch (UnauthorizedAccessException) { }
return foundFiles;
}
}
}

View File

@@ -0,0 +1,113 @@
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 = "\\\\?\\";
private static readonly StringBuilder longPathBuffer = new(MaxPathLength);
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 all volumes on the system, run the following command
//from an elevated command prompt:
//
// fsutil 8dot3name set c: 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;
GetShortPathName(Path, longPathBuffer, MaxPathLength);
return longPathBuffer.ToString();
}
}
[JsonIgnore]
public string LongPathName
{
get
{
if (Path is null) return null;
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);
}
}

View 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;
}
}
}

View 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: &lt;name&gt;</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"} => /&lt;name&gt;/ => /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(">", "");
}
}

View 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;
}
}
}

View 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
View 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
View 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
View File

File diff suppressed because it is too large Load Diff

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

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -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
}
}

View File

@@ -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 ]

View File

@@ -1,9 +0,0 @@
using System;
namespace Hoopla
{
public class temp
{
// placeholder
}
}

View File

@@ -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}

View File

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

View File

@@ -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);
}
}

View File

@@ -14,507 +14,521 @@ 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
[Description("Auto download episodes? Efter scan, download new books in 'checked' accounts.")]
public bool AutoDownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoDownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(AutoDownloadEpisodes), value);
}
// 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
#region templates: custom file naming
private PersistentDictionary persistentDictionary;
[Description("How to format the folders in which files will be saved")]
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("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 = getLibationFilesSettingFromJson();
// 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 getLibationFilesSettingFromJson()
{
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;
// ensure exists
if (!File.Exists(APPSETTINGS_JSON))
{
// getter creates new file, loads PersistentDictionary
var _ = LibationFiles;
System.Threading.Thread.Sleep(100);
}
// ensure exists
if (!File.Exists(APPSETTINGS_JSON))
{
// getter creates new file, loads PersistentDictionary
var _ = LibationFiles;
System.Threading.Thread.Sleep(100);
}
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
jObj[LIBATION_FILES_KEY] = directory;
jObj[LIBATION_FILES_KEY] = directory;
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
// now it's set in the file again but no settings have moved yet
File.WriteAllText(APPSETTINGS_JSON, endingContents);
// 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
}
try
{
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
}
catch { }
}
#endregion
}
}

View File

@@ -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)

View File

@@ -8,277 +8,330 @@ 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 (template.Contains(':')
|| template.Contains(Path.DirectorySeparatorChar)
|| template.Contains(Path.AltDirectorySeparatorChar)
)
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().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) { IllegalCharacterReplacements = "_" };
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
return Valid;
}
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#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
}
#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();
#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;
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;
internal FileTemplate() : base() { }
internal FileTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#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
}
#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 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;
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;
internal ChapterFileTemplate() : base() { }
internal ChapterFileTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
public override IEnumerable<string> GetWarnings(string template)
{
var warnings = GetStandardWarnings(template).ToList();
if (template is null)
return warnings;
public override IEnumerable<string> GetWarnings(string template)
{
var warnings = GetStandardWarnings(template).ToList();
if (template is null)
return warnings;
// 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);
// 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);
return warnings;
}
#endregion
return warnings;
}
#endregion
#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);
#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);
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath)
{
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath)
{
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
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 ?? "");
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();
}
#endregion
}
}
return fileNamingTemplate.GetFilePath().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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -121,12 +121,12 @@ namespace LibationSearchEngine
["Liberated"] = lb => isLiberated(lb.Book),
["LiberatedError"] = lb => liberatedError(lb.Book),
["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["Podcasts"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["Episode"] = lb => lb.Book.ContentType == ContentType.Episode,
["Episodes"] = 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(),
}
);

View File

@@ -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;

View File

@@ -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 occured 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 occured while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?",
"Error Importing Account",
ex);
}
}
}
}

View File

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

View File

@@ -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,14 @@ namespace LibationWinForms.Dialogs
richTextBox1.Clear();
richTextBox1.SelectionFont = reg;
if (isChapterTitle)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(chapterTitle);
if (isChapterTitle)
return;
richTextBox1.AppendText(slashWrap(books));
richTextBox1.AppendText(sing);

View File

@@ -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
{

View File

@@ -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.GridView.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.GridView.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;
}
}

View File

@@ -1,148 +0,0 @@
using System;
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 : GridView.LibraryBookEntry
{
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);
}
}
}

View File

@@ -1,63 +0,0 @@
<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">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<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>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=5.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>
</root>

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
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 Load_DownloadDecrypt(Configuration config)
{
inProgressDescLbl.Text = desc(nameof(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();
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;
}
}
}

View File

@@ -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;
}
}
}

View 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;
}
}
}

View File

@@ -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,108 +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.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes));
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;
autoDownloadEpisodesCb.Checked = config.AutoDownloadEpisodes;
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);
@@ -135,91 +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.AutoDownloadEpisodes = autoDownloadEpisodesCb.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();
@@ -230,6 +50,5 @@ namespace LibationWinForms.Dialogs
this.DialogResult = DialogResult.Cancel;
this.Close();
}
}
}

View File

@@ -74,6 +74,8 @@
this.panel1 = new System.Windows.Forms.Panel();
this.productsDisplay = new LibationWinForms.GridView.ProductsDisplay();
this.toggleQueueHideBtn = new System.Windows.Forms.Button();
this.doneRemovingBtn = new System.Windows.Forms.Button();
this.removeBooksBtn = new System.Windows.Forms.Button();
this.processBookQueue1 = new LibationWinForms.ProcessQueue.ProcessQueueControl();
this.menuStrip1.SuspendLayout();
this.statusStrip1.SuspendLayout();
@@ -98,7 +100,7 @@
// filterBtn
//
this.filterBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
this.filterBtn.Location = new System.Drawing.Point(916, 3);
this.filterBtn.Location = new System.Drawing.Point(892, 3);
this.filterBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.filterBtn.Name = "filterBtn";
this.filterBtn.Size = new System.Drawing.Size(88, 27);
@@ -111,10 +113,11 @@
//
this.filterSearchTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.filterSearchTb.Location = new System.Drawing.Point(196, 7);
this.filterSearchTb.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.filterSearchTb.Location = new System.Drawing.Point(195, 5);
this.filterSearchTb.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.filterSearchTb.Name = "filterSearchTb";
this.filterSearchTb.Size = new System.Drawing.Size(712, 23);
this.filterSearchTb.Size = new System.Drawing.Size(689, 25);
this.filterSearchTb.TabIndex = 1;
this.filterSearchTb.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.filterSearchTb_KeyPress);
//
@@ -132,7 +135,7 @@
this.menuStrip1.Location = new System.Drawing.Point(0, 0);
this.menuStrip1.Name = "menuStrip1";
this.menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2);
this.menuStrip1.Size = new System.Drawing.Size(1061, 24);
this.menuStrip1.Size = new System.Drawing.Size(1037, 24);
this.menuStrip1.TabIndex = 0;
this.menuStrip1.Text = "menuStrip1";
//
@@ -396,7 +399,8 @@
this.statusStrip1.Location = new System.Drawing.Point(0, 618);
this.statusStrip1.Name = "statusStrip1";
this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
this.statusStrip1.Size = new System.Drawing.Size(1061, 22);
this.statusStrip1.ShowItemToolTips = true;
this.statusStrip1.Size = new System.Drawing.Size(1037, 22);
this.statusStrip1.TabIndex = 6;
this.statusStrip1.Text = "statusStrip1";
//
@@ -410,7 +414,7 @@
// springLbl
//
this.springLbl.Name = "springLbl";
this.springLbl.Size = new System.Drawing.Size(547, 17);
this.springLbl.Size = new System.Drawing.Size(523, 17);
this.springLbl.Spring = true;
//
// backupsCountsLbl
@@ -440,6 +444,7 @@
// splitContainer1
//
this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill;
this.splitContainer1.FixedPanel = System.Windows.Forms.FixedPanel.Panel2;
this.splitContainer1.Location = new System.Drawing.Point(0, 0);
this.splitContainer1.Name = "splitContainer1";
//
@@ -453,7 +458,7 @@
//
this.splitContainer1.Panel2.Controls.Add(this.processBookQueue1);
this.splitContainer1.Size = new System.Drawing.Size(1463, 640);
this.splitContainer1.SplitterDistance = 1061;
this.splitContainer1.SplitterDistance = 1037;
this.splitContainer1.SplitterWidth = 8;
this.splitContainer1.TabIndex = 7;
//
@@ -462,6 +467,8 @@
this.panel1.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
this.panel1.Controls.Add(this.productsDisplay);
this.panel1.Controls.Add(this.toggleQueueHideBtn);
this.panel1.Controls.Add(this.doneRemovingBtn);
this.panel1.Controls.Add(this.removeBooksBtn);
this.panel1.Controls.Add(this.addQuickFilterBtn);
this.panel1.Controls.Add(this.filterHelpBtn);
this.panel1.Controls.Add(this.filterSearchTb);
@@ -470,7 +477,7 @@
this.panel1.Location = new System.Drawing.Point(0, 24);
this.panel1.Margin = new System.Windows.Forms.Padding(0);
this.panel1.Name = "panel1";
this.panel1.Size = new System.Drawing.Size(1061, 594);
this.panel1.Size = new System.Drawing.Size(1037, 594);
this.panel1.TabIndex = 7;
//
// productsDisplay
@@ -482,16 +489,17 @@
this.productsDisplay.Location = new System.Drawing.Point(15, 36);
this.productsDisplay.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.productsDisplay.Name = "productsDisplay";
this.productsDisplay.Size = new System.Drawing.Size(1031, 555);
this.productsDisplay.Size = new System.Drawing.Size(1007, 555);
this.productsDisplay.TabIndex = 9;
this.productsDisplay.VisibleCountChanged += new System.EventHandler<int>(this.productsDisplay_VisibleCountChanged);
this.productsDisplay.RemovableCountChanged += new System.EventHandler<int>(this.productsDisplay_RemovableCountChanged);
this.productsDisplay.LiberateClicked += new System.EventHandler<DataLayer.LibraryBook>(this.ProductsDisplay_LiberateClicked);
this.productsDisplay.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded);
//
// toggleQueueHideBtn
//
this.toggleQueueHideBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
this.toggleQueueHideBtn.Location = new System.Drawing.Point(1013, 3);
this.toggleQueueHideBtn.Location = new System.Drawing.Point(989, 3);
this.toggleQueueHideBtn.Margin = new System.Windows.Forms.Padding(4, 3, 15, 3);
this.toggleQueueHideBtn.Name = "toggleQueueHideBtn";
this.toggleQueueHideBtn.Size = new System.Drawing.Size(33, 27);
@@ -500,6 +508,31 @@
this.toggleQueueHideBtn.UseVisualStyleBackColor = true;
this.toggleQueueHideBtn.Click += new System.EventHandler(this.ToggleQueueHideBtn_Click);
//
// doneRemovingBtn
//
this.doneRemovingBtn.Location = new System.Drawing.Point(406, 3);
this.doneRemovingBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.doneRemovingBtn.Name = "doneRemovingBtn";
this.doneRemovingBtn.Size = new System.Drawing.Size(145, 27);
this.doneRemovingBtn.TabIndex = 4;
this.doneRemovingBtn.Text = "Done Removing Books";
this.doneRemovingBtn.UseVisualStyleBackColor = true;
this.doneRemovingBtn.Visible = false;
this.doneRemovingBtn.Click += new System.EventHandler(this.doneRemovingBtn_Click);
//
// removeBooksBtn
//
this.removeBooksBtn.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
this.removeBooksBtn.Location = new System.Drawing.Point(206, 3);
this.removeBooksBtn.Margin = new System.Windows.Forms.Padding(15, 3, 4, 3);
this.removeBooksBtn.Name = "removeBooksBtn";
this.removeBooksBtn.Size = new System.Drawing.Size(192, 27);
this.removeBooksBtn.TabIndex = 4;
this.removeBooksBtn.Text = "Remove # Books from Libation";
this.removeBooksBtn.UseVisualStyleBackColor = true;
this.removeBooksBtn.Visible = false;
this.removeBooksBtn.Click += new System.EventHandler(this.removeBooksBtn_Click);
//
// processBookQueue1
//
this.processBookQueue1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
@@ -507,7 +540,7 @@
this.processBookQueue1.Location = new System.Drawing.Point(0, 0);
this.processBookQueue1.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4);
this.processBookQueue1.Name = "processBookQueue1";
this.processBookQueue1.Size = new System.Drawing.Size(394, 640);
this.processBookQueue1.Size = new System.Drawing.Size(418, 640);
this.processBookQueue1.TabIndex = 0;
//
// Form1
@@ -584,5 +617,7 @@
private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.Button toggleQueueHideBtn;
private LibationWinForms.GridView.ProductsDisplay productsDisplay;
private System.Windows.Forms.Button removeBooksBtn;
private System.Windows.Forms.Button doneRemovingBtn;
}
}

View File

@@ -40,7 +40,7 @@ namespace LibationWinForms
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert("Error attempting to export your library.", "Error exporting", ex);
MessageBoxLib.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex);
}
}
}

View File

@@ -38,7 +38,7 @@ namespace LibationWinForms
{
SetQueueCollapseState(false);
await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
.Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated)));
.Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product)));
}
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
}

View File

@@ -17,7 +17,9 @@ namespace LibationWinForms
processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut;
var coppalseState = Configuration.Instance.GetNonString<bool>(nameof(splitContainer1.Panel2Collapsed));
WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth;
int width = this.Width;
SetQueueCollapseState(coppalseState);
this.Width = width;
}
private void ProductsDisplay_LiberateClicked(object sender, LibraryBook e)
@@ -36,7 +38,7 @@ namespace LibationWinForms
{
// liberated: open explorer to file
var filePath = AudibleFileStorage.Audio.GetPath(e.Book.AudibleProductId);
if (!Go.To.File(filePath))
if (!Go.To.File(filePath?.ShortPathName))
{
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
MessageBox.Show($"File not found" + suffix);

View File

@@ -0,0 +1,92 @@
using AudibleUtilities;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForms
{
public partial class Form1
{
public void Configure_RemoveBooks() { }
private async void removeBooksBtn_Click(object sender, EventArgs e)
=> await productsDisplay.RemoveCheckedBooksAsync();
private void doneRemovingBtn_Click(object sender, EventArgs e)
{
removeBooksBtn.Visible = false;
doneRemovingBtn.Visible = false;
productsDisplay.CloseRemoveBooksColumn();
//Restore the filter
filterSearchTb.Enabled = true;
filterSearchTb.Visible = true;
performFilter(filterSearchTb.Text);
}
private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e)
{
// if 0 accounts, this will not be visible
// if 1 account, run scanLibrariesRemovedBooks() on this account
// if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks()
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll();
if (accounts.Count != 1)
return;
var firstAccount = accounts.Single();
scanLibrariesRemovedBooks(firstAccount);
}
// selectively remove books from all accounts
private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var allAccounts = persister.AccountsSettings.GetAll();
scanLibrariesRemovedBooks(allAccounts.ToArray());
}
// selectively remove books from some accounts
private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var scanAccountsDialog = new ScanAccountsDialog();
if (scanAccountsDialog.ShowDialog() != DialogResult.OK)
return;
if (!scanAccountsDialog.CheckedAccounts.Any())
return;
scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray());
}
private async void scanLibrariesRemovedBooks(params Account[] accounts)
{
//This action is meant to operate on the entire library.
//For removing books within a filter set, use
//Visible Books > Remove from library
filterSearchTb.Enabled = false;
filterSearchTb.Visible = false;
productsDisplay.Filter(null);
removeBooksBtn.Visible = true;
doneRemovingBtn.Visible = true;
await productsDisplay.ScanAndRemoveBooksAsync(accounts);
}
private void productsDisplay_RemovableCountChanged(object sender, int removeCount)
{
removeBooksBtn.Text = removeCount switch
{
1 => "Remove 1 Book from Libation",
_ => $"Remove {removeCount} Books from Libation"
};
}
}
}

View File

@@ -67,50 +67,7 @@ namespace LibationWinForms
return;
await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts);
}
private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e)
{
// if 0 accounts, this will not be visible
// if 1 account, run scanLibrariesRemovedBooks() on this account
// if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks()
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll();
if (accounts.Count != 1)
return;
var firstAccount = accounts.Single();
scanLibrariesRemovedBooks(firstAccount);
}
// selectively remove books from all accounts
private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var allAccounts = persister.AccountsSettings.GetAll();
scanLibrariesRemovedBooks(allAccounts.ToArray());
}
// selectively remove books from some accounts
private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var scanAccountsDialog = new ScanAccountsDialog();
if (scanAccountsDialog.ShowDialog() != DialogResult.OK)
return;
if (!scanAccountsDialog.CheckedAccounts.Any())
return;
scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray());
}
private void scanLibrariesRemovedBooks(params Account[] accounts)
{
using var dialog = new RemoveBooksDialog(accounts);
dialog.ShowDialog();
}
}
private async Task scanLibrariesAsync(IEnumerable<Account> accounts) => await scanLibrariesAsync(accounts.ToArray());
private async Task scanLibrariesAsync(params Account[] accounts)
@@ -126,6 +83,7 @@ namespace LibationWinForms
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
"Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator",
"Error importing library",
ex);

View File

@@ -14,6 +14,9 @@ namespace LibationWinForms
private void LibraryCommands_ScanBegin(object sender, int accountsLength)
{
removeLibraryBooksToolStripMenuItem.Enabled = false;
removeAllAccountsToolStripMenuItem.Enabled = false;
removeSomeAccountsToolStripMenuItem.Enabled = false;
scanLibraryToolStripMenuItem.Enabled = false;
scanLibraryOfAllAccountsToolStripMenuItem.Enabled = false;
scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = false;
@@ -27,6 +30,9 @@ namespace LibationWinForms
private void LibraryCommands_ScanEnd(object sender, EventArgs e)
{
removeLibraryBooksToolStripMenuItem.Enabled = true;
removeAllAccountsToolStripMenuItem.Enabled = true;
removeSomeAccountsToolStripMenuItem.Enabled = true;
scanLibraryToolStripMenuItem.Enabled = true;
scanLibraryOfAllAccountsToolStripMenuItem.Enabled = true;
scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = true;

View File

@@ -20,7 +20,7 @@ namespace LibationWinForms
// Before calling anything else, including subscribing to events, ensure database exists. If we wait and let it happen lazily, race conditions and errors are likely during new installs
using var _ = DbContexts.GetContext();
this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance);
this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
// this looks like a perfect opportunity to refactor per below.
@@ -44,6 +44,7 @@ namespace LibationWinForms
Configure_VisibleBooks();
Configure_QuickFilters();
Configure_ScanManual();
Configure_RemoveBooks();
Configure_Liberate();
Configure_Export();
Configure_Settings();

View File

@@ -93,6 +93,12 @@
<metadata name="toggleQueueHideBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="doneRemovingBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="removeBooksBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="processBookQueue1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>

View File

@@ -1,21 +1,39 @@
using DataLayer;
using Dinah.Core;
using Dinah.Core.DataBinding;
using Dinah.Core.Drawing;
using LibationFileManager;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
namespace LibationWinForms.GridView
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
{
protected abstract Book Book { get; }
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] protected Book Book => LibraryBook.Book;
private Image _cover;
#region Model properties exposed to the view
protected RemoveStatus _remove = RemoveStatus.NotRemoved;
public abstract RemoveStatus Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public Image Cover
{
get => _cover;
@@ -25,49 +43,59 @@ namespace LibationWinForms.GridView
NotifyPropertyChanged();
}
}
public new bool InvokeRequired => base.InvokeRequired;
public abstract DateTime DateAdded { get; }
public abstract float SeriesIndex { get; }
public abstract string ProductRating { get; protected set; }
public abstract string PurchaseDate { get; protected set; }
public abstract string MyRating { get; protected set; }
public abstract string Series { get; protected set; }
public abstract string Title { get; protected set; }
public abstract string Length { get; protected set; }
public abstract string Authors { get; protected set; }
public abstract string Narrators { get; protected set; }
public abstract string Category { get; protected set; }
public abstract string Misc { get; protected set; }
public abstract string Description { get; protected set; }
public string PurchaseDate { get; protected set; }
public string Series { get; protected set; }
public string Title { get; protected set; }
public string Length { get; protected set; }
public string Authors { get; protected set; }
public string Narrators { get; protected set; }
public string Category { get; protected set; }
public string Misc { get; protected set; }
public string Description { get; protected set; }
public string ProductRating { get; protected set; }
public string MyRating { get; protected set; }
public abstract string DisplayTags { get; }
public abstract LiberateButtonStatus Liberate { get; }
#endregion
#region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by GridEntryBindingList for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
};
#endregion
#region Cover Art
private Image _cover;
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
{
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = ImageReader.ToImage(picture);
}
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = ImageReader.ToImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
@@ -79,36 +107,61 @@ namespace LibationWinForms.GridView
}
}
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
protected static string GetDescriptionDisplay(Book book)
{
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
};
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
protected static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
protected static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
details.Add($"Account: {locale} - {acct}");
if (libraryBook.Book.HasPdf())
details.Add("Has PDF");
if (libraryBook.Book.IsAbridged)
details.Add("Abridged");
if (libraryBook.Book.DatePublished.HasValue)
details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
// this goes last since it's most likely to have a line-break
if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
if (!details.Any())
return "[details not imported]";
return string.Join("\r\n", details);
}
#endregion
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
internal static class GridEntryExtensions
{
#nullable enable
public static IEnumerable<SeriesEntry> Series(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static IEnumerable<LibraryBookEntry> LibraryBooks(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static LibraryBookEntry? FindBookByAsin(this IEnumerable<LibraryBookEntry> gridEntries, string audibleProductID)
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static SeriesEntry? FindBookSeriesEntry(this IEnumerable<GridEntry> gridEntries, IEnumerable<SeriesBook> matchSeries)
=> gridEntries.Series().FirstOrDefault(i => matchSeries.Any(s => s.Series.Name == i.Series));
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.Series().Where(i => i.Children.Count == 0);
public static bool IsEpisodeChild(this LibraryBook lb) => lb.Book.ContentType == ContentType.Episode;
}
}

View File

@@ -73,10 +73,10 @@ namespace LibationWinForms.GridView
FilterString = filterString;
SearchResults = SearchEngineCommands.Search(filterString);
var booksFilteredIn = Items.LibraryBooks().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
//Find all series containing children that match the search criteria
var seriesFilteredIn = Items.Series().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList();
@@ -89,19 +89,19 @@ namespace LibationWinForms.GridView
public void CollapseAll()
{
foreach (var series in Items.Series().ToList())
foreach (var series in Items.SeriesEntries().ToList())
CollapseItem(series);
}
public void ExpandAll()
{
foreach (var series in Items.Series().ToList())
foreach (var series in Items.SeriesEntries().ToList())
ExpandItem(series);
}
public void CollapseItem(SeriesEntry sEntry)
{
foreach (var episode in Items.LibraryBooks().Where(b => b.Parent == sEntry).ToList())
foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList())
{
FilterRemoved.Add(episode);
base.Remove(episode);
@@ -114,7 +114,7 @@ namespace LibationWinForms.GridView
{
var sindex = Items.IndexOf(sEntry);
foreach (var episode in FilterRemoved.LibraryBooks().Where(b => b.Parent == sEntry).ToList())
foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList())
{
if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId))
{
@@ -174,7 +174,7 @@ namespace LibationWinForms.GridView
{
var itemsList = (List<GridEntry>)Items;
var children = itemsList.LibraryBooks().Where(i => i.Parent is not null).ToList();
var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList();
var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList();

View File

@@ -19,18 +19,19 @@ namespace LibationWinForms.GridView
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (value is LiberateButtonStatus status)
{
if (status.BookStatus is LiberatedStatus.Error)
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null)
{
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR;
}
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (status.IsSeries)
{
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus: Properties.Resources.plus, cellBounds);
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus : Properties.Resources.plus, cellBounds);
ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand";
}
@@ -48,7 +49,7 @@ namespace LibationWinForms.GridView
private static (string mouseoverText, Bitmap buttonImage) GetLiberateDisplay(LiberatedStatus liberatedStatus, LiberatedStatus? pdfStatus)
{
if (liberatedStatus == LiberatedStatus.Error)
return ("Book downloaded ERROR", SystemIcons.Error.ToBitmap());
return ("Book downloaded ERROR", Properties.Resources.error);
(string libState, string image_lib) = liberatedStatus switch
{

View File

@@ -8,23 +8,11 @@ using System.Linq;
namespace LibationWinForms.GridView
{
/// <summary>
/// The View Model for a LibraryBook
/// </summary>
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry : GridEntry
{
#region implementation properties NOT exposed to the view
// hide from public fields from Data Source GUI with [Browsable(false)]
[Browsable(false)]
public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)]
public LibraryBook LibraryBook { get; private set; }
[Browsable(false)]
public string LongDescription { get; private set; }
#endregion
protected override Book Book => LibraryBook.Book;
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public SeriesEntry Parent { get; init; }
#region Model properties exposed to the view
@@ -32,22 +20,20 @@ namespace LibationWinForms.GridView
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override DateTime DateAdded => LibraryBook.DateAdded;
public override float SeriesIndex => Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
public override string ProductRating { get; protected set; }
public override string PurchaseDate { get; protected set; }
public override string MyRating { get; protected set; }
public override string Series { get; protected set; }
public override string Title { get; protected set; }
public override string Length { get; protected set; }
public override string Authors { get; protected set; }
public override string Narrators { get; protected set; }
public override string Category { get; protected set; }
public override string Misc { get; protected set; }
public override string Description { get; protected set; }
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
Parent?.ChildRemoveUpdate();
NotifyPropertyChanged();
}
}
// these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint
public override LiberateButtonStatus Liberate
{
get
@@ -62,6 +48,8 @@ namespace LibationWinForms.GridView
return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
}
}
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
#endregion
public LibraryBookEntry(LibraryBook libraryBook)
@@ -70,7 +58,6 @@ namespace LibationWinForms.GridView
LoadCover();
}
public SeriesEntry Parent { get; init; }
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
@@ -86,26 +73,23 @@ namespace LibationWinForms.GridView
{
LibraryBook = libraryBook;
// Immutable properties
{
Title = Book.Title;
Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
}
Title = Book.Title;
Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
#region detect changes to the model, update the view, and save to database.
/// <summary>
@@ -153,6 +137,7 @@ namespace LibationWinForms.GridView
/// <summary>Create getters for all member object values by name </summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Book.LengthInMinutes },
@@ -169,58 +154,6 @@ namespace LibationWinForms.GridView
{ nameof(DateAdded), () => DateAdded },
};
#endregion
#region Static library display functions
/// <summary>
/// This information should not change during <see cref="LibraryBookEntry"/> lifetime, so call only once.
/// </summary>
private static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
private static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="LibraryBookEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
private static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
details.Add($"Account: {locale} - {acct}");
if (libraryBook.Book.HasPdf())
details.Add("Has PDF");
if (libraryBook.Book.IsAbridged)
details.Add("Abridged");
if (libraryBook.Book.DatePublished.HasValue)
details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
// this goes last since it's most likely to have a line-break
if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
if (!details.Any())
return "[details not imported]";
return string.Join("\r\n", details);
}
#endregion
~LibraryBookEntry()

View File

@@ -39,11 +39,12 @@
this.productsGrid.Name = "productsGrid";
this.productsGrid.Size = new System.Drawing.Size(1510, 380);
this.productsGrid.TabIndex = 0;
this.productsGrid.LiberateClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked);
this.productsGrid.CoverClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_CoverClicked);
this.productsGrid.DetailsClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked);
this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked);
this.productsGrid.VisibleCountChanged += new System.EventHandler<int>(this.productsGrid_VisibleCountChanged);
this.productsGrid.LiberateClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked);
this.productsGrid.CoverClicked += new LibationWinForms.GridView.GridEntryClickedEventHandler(this.productsGrid_CoverClicked);
this.productsGrid.DetailsClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked);
this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.GridEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked);
this.productsGrid.RemovableCountChanged += new System.EventHandler(this.productsGrid_RemovableCountChanged);
//
// ProductsDisplay
//

View File

@@ -1,4 +1,5 @@
using ApplicationServices;
using AudibleUtilities;
using DataLayer;
using FileLiberator;
using LibationFileManager;
@@ -16,6 +17,7 @@ namespace LibationWinForms.GridView
{
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event EventHandler<int> RemovableCountChanged;
public event EventHandler<LibraryBook> LiberateClicked;
public event EventHandler InitialLoaded;
@@ -29,7 +31,7 @@ namespace LibationWinForms.GridView
#region Button controls
private ImageDisplay imageDisplay;
private async void productsGrid_CoverClicked(LibraryBookEntry liveGridEntry)
private async void productsGrid_CoverClicked(GridEntry liveGridEntry)
{
var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition));
@@ -52,7 +54,7 @@ namespace LibationWinForms.GridView
imageDisplay.CoverPicture = await picDlTask;
}
private void productsGrid_DescriptionClicked(LibraryBookEntry liveGridEntry, Rectangle cellRectangle)
private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle)
{
var displayWindow = new DescriptionDisplay
{
@@ -80,14 +82,76 @@ namespace LibationWinForms.GridView
#endregion
#region UI display functions
#region Scan and Remove Books
public void CloseRemoveBooksColumn()
=> productsGrid.RemoveColumnVisible = false;
public async Task RemoveCheckedBooksAsync()
{
var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is RemoveStatus.Removed).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 {selectedBooks.Count} books from Libation's library?",
"Remove books from Libation?");
if (result != DialogResult.Yes)
return;
productsGrid.RemoveBooks(selectedBooks);
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
}
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
{
RemovableCountChanged?.Invoke(this, 0);
productsGrid.RemoveColumnVisible = true;
try
{
if (accounts is null || accounts.Length == 0)
return;
var allBooks = productsGrid.GetAllBookEntries();
var lib = allBooks
.Select(lbe => lbe.LibraryBook)
.Where(lb => !lb.Book.HasLiberated());
var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts);
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
foreach (var r in removable)
r.Remove = RemoveStatus.Removed;
productsGrid_RemovableCountChanged(this, null);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
"Error scanning library. You may still manually select books to remove from Libation's library.",
"Error scanning library",
ex);
}
}
#endregion
#region UI display functions
public void Display()
{
try
{
// don't return early if lib size == 0. this will not update correctly if all books are removed
var lib = DbContexts.GetLibrary_Flat_NoTracking();
var lib = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true);
if (!hasBeenDisplayed)
{
@@ -103,7 +167,6 @@ namespace LibationWinForms.GridView
{
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay));
}
}
#endregion
@@ -115,7 +178,7 @@ namespace LibationWinForms.GridView
#endregion
internal List<LibraryBook> GetVisible() => productsGrid.GetVisible().Select(v => v.LibraryBook).ToList();
internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBooks().ToList();
private void productsGrid_VisibleCountChanged(object sender, int count)
{
@@ -124,7 +187,13 @@ namespace LibationWinForms.GridView
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
{
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}
private void productsGrid_RemovableCountChanged(object sender, EventArgs e)
{
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is RemoveStatus.Removed));
}
}
}

View File

@@ -29,8 +29,9 @@
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle();
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle();
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.removeGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
this.liberateGVColumn = new LibationWinForms.GridView.LiberateDataGridViewImageButtonColumn();
this.coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn();
this.titleGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
@@ -60,41 +61,56 @@
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.liberateGVColumn,
this.coverGVColumn,
this.titleGVColumn,
this.authorsGVColumn,
this.narratorsGVColumn,
this.lengthGVColumn,
this.seriesGVColumn,
this.descriptionGVColumn,
this.categoryGVColumn,
this.productRatingGVColumn,
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.removeGVColumn,
this.liberateGVColumn,
this.coverGVColumn,
this.titleGVColumn,
this.authorsGVColumn,
this.narratorsGVColumn,
this.lengthGVColumn,
this.seriesGVColumn,
this.descriptionGVColumn,
this.categoryGVColumn,
this.productRatingGVColumn,
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.gridEntryDataGridView.ContextMenuStrip = this.contextMenuStrip1;
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
dataGridViewCellStyle2.BackColor = System.Drawing.SystemColors.Window;
dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
dataGridViewCellStyle2.ForeColor = System.Drawing.SystemColors.ControlText;
dataGridViewCellStyle2.SelectionBackColor = System.Drawing.SystemColors.Highlight;
dataGridViewCellStyle2.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
dataGridViewCellStyle2.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle2;
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.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle1;
this.gridEntryDataGridView.Dock = System.Windows.Forms.DockStyle.Fill;
this.gridEntryDataGridView.Location = new System.Drawing.Point(0, 0);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.ReadOnly = true;
this.gridEntryDataGridView.RowHeadersVisible = false;
this.gridEntryDataGridView.RowTemplate.Height = 82;
this.gridEntryDataGridView.Size = new System.Drawing.Size(1510, 380);
this.gridEntryDataGridView.Size = new System.Drawing.Size(1570, 380);
this.gridEntryDataGridView.TabIndex = 0;
this.gridEntryDataGridView.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView_CellContentClick);
this.gridEntryDataGridView.CellToolTipTextNeeded += new System.Windows.Forms.DataGridViewCellToolTipTextNeededEventHandler(this.gridEntryDataGridView_CellToolTipTextNeeded);
//
// removeGVColumn
//
this.removeGVColumn.DataPropertyName = "Remove";
this.removeGVColumn.FalseValue = "";
this.removeGVColumn.Frozen = true;
this.removeGVColumn.HeaderText = "Remove";
this.removeGVColumn.IndeterminateValue = "";
this.removeGVColumn.MinimumWidth = 60;
this.removeGVColumn.Name = "removeGVColumn";
this.removeGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
this.removeGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
this.removeGVColumn.ThreeState = true;
this.removeGVColumn.TrueValue = "";
this.removeGVColumn.Width = 60;
//
// liberateGVColumn
//
this.liberateGVColumn.DataPropertyName = "Liberate";
@@ -223,7 +239,7 @@
this.AutoScroll = true;
this.Controls.Add(this.gridEntryDataGridView);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(1510, 380);
this.Size = new System.Drawing.Size(1570, 380);
this.Load += new System.EventHandler(this.ProductsGrid_Load);
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).EndInit();
@@ -235,6 +251,8 @@
#endregion
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.ContextMenuStrip contextMenuStrip1;
private SyncBindingSource syncBindingSource;
private System.Windows.Forms.DataGridViewCheckBoxColumn removeGVColumn;
private LiberateDataGridViewImageButtonColumn liberateGVColumn;
private System.Windows.Forms.DataGridViewImageColumn coverGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn titleGVColumn;
@@ -249,6 +267,5 @@
private System.Windows.Forms.DataGridViewTextBoxColumn myRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
private SyncBindingSource syncBindingSource;
}
}

View File

@@ -10,23 +10,28 @@ using System.Windows.Forms;
namespace LibationWinForms.GridView
{
public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle);
public partial class ProductsGrid : UserControl
{
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void LibraryBookEntryRectangleClickedEventHandler(LibraryBookEntry liveGridEntry, Rectangle cellRectangle);
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler LiberateClicked;
public event LibraryBookEntryClickedEventHandler CoverClicked;
public event GridEntryClickedEventHandler CoverClicked;
public event LibraryBookEntryClickedEventHandler DetailsClicked;
public event LibraryBookEntryRectangleClickedEventHandler DescriptionClicked;
public event GridEntryRectangleClickedEventHandler DescriptionClicked;
public new event EventHandler<ScrollEventArgs> Scroll;
public event EventHandler RemovableCountChanged;
private GridEntryBindingList bindingList;
internal IEnumerable<LibraryBookEntry> GetVisible()
internal IEnumerable<LibraryBook> GetVisibleBooks()
=> bindingList
.LibraryBooks();
.BookEntries()
.Select(lbe => lbe.LibraryBook);
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> bindingList.AllItems().BookEntries();
public ProductsGrid()
{
@@ -61,16 +66,29 @@ namespace LibationWinForms.GridView
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry);
}
else if (entry is SeriesEntry sEntry && e.ColumnIndex == liberateGVColumn.Index)
else if (entry is SeriesEntry sEntry)
{
if (sEntry.Liberate.Expanded)
bindingList.CollapseItem(sEntry);
else
bindingList.ExpandItem(sEntry);
if (e.ColumnIndex == liberateGVColumn.Index)
{
if (sEntry.Liberate.Expanded)
bindingList.CollapseItem(sEntry);
else
bindingList.ExpandItem(sEntry);
sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate));
sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate));
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
else if (e.ColumnIndex == descriptionGVColumn.Index)
DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false));
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(sEntry);
}
if (e.ColumnIndex == removeGVColumn.Index)
{
gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit);
RemovableCountChanged?.Invoke(this, EventArgs.Empty);
}
}
@@ -80,16 +98,39 @@ namespace LibationWinForms.GridView
#region UI display functions
internal bool RemoveColumnVisible
{
get => removeGVColumn.Visible;
set
{
if (value)
{
foreach (var book in bindingList.AllItems())
book.Remove = RemoveStatus.NotRemoved;
}
removeGVColumn.Visible = value;
}
}
internal void BindToGrid(List<LibraryBook> dbBooks)
{
var geList = dbBooks.Where(b => b.Book.ContentType is not ContentType.Episode).Select(b => new LibraryBookEntry(b)).Cast<GridEntry>().ToList();
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
.Cast<GridEntry>()
.ToList();
var episodes = dbBooks.Where(b => b.IsEpisodeChild()).ToList();
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
var allSeries = episodes.SelectMany(lb => lb.Book.SeriesLink.Where(s => !s.Series.AudibleSeriesId.StartsWith("SERIES_"))).DistinctBy(s => s.Series).ToList();
foreach (var series in allSeries)
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
foreach (var parent in seriesBooks)
{
var seriesEntry = new SeriesEntry(series, episodes.Where(lb => lb.Book.SeriesLink.Any(s => s.Series == series.Series)));
var seriesEpisodes = episodes.FindChildren(parent);
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
@@ -98,81 +139,56 @@ namespace LibationWinForms.GridView
bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded));
bindingList.CollapseAll();
syncBindingSource.DataSource = bindingList;
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
internal void UpdateGrid(List<LibraryBook> dbBooks)
{
#region Add new or update existing grid entries
//Remove filter prior to adding/updating boooks
string existingFilter = syncBindingSource.Filter;
Filter(null);
bindingList.SuspendFilteringOnUpdate = true;
//Add absent books to grid, or update current books
//Add absent entries to grid, or update existing entry
var allItmes = bindingList.AllItems().LibraryBooks();
foreach (var libraryBook in dbBooks)
var allEntries = bindingList.AllItems().BookEntries();
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes();
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingItem = allItmes.FindBookByAsin(libraryBook.Book.AudibleProductId);
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
// add new to top
if (existingItem is null)
{
if (libraryBook.IsEpisodeChild())
{
LibraryBookEntry lbe;
//Find the series that libraryBook belongs to, if it exists
var series = bindingList.AllItems().FindBookSeriesEntry(libraryBook.Book.SeriesLink);
if (series is null)
{
//Series doesn't exist yet, so create and add it
var newSeries = new SeriesEntry(libraryBook.Book.SeriesLink.First(), libraryBook);
lbe = newSeries.Children[0];
newSeries.Liberate.Expanded = true;
bindingList.Insert(0, newSeries);
series = newSeries;
}
else
{
lbe = new(libraryBook) { Parent = series };
series.Children.Add(lbe);
}
//Add episode beneath the parent
int seriesIndex = bindingList.IndexOf(series);
bindingList.Insert(seriesIndex + 1, lbe);
if (series.Liberate.Expanded)
bindingList.ExpandItem(series);
else
bindingList.CollapseItem(series);
series.NotifyPropertyChanged();
}
else if (libraryBook.Book.ContentType is not ContentType.Episode)
//Add the new product
bindingList.Insert(0, new LibraryBookEntry(libraryBook));
}
// update existing
else
{
existingItem.UpdateLibraryBook(libraryBook);
}
}
if (libraryBook.Book.IsProduct())
AddOrUpdateBook(libraryBook, existingEntry);
else if(parentedEpisodes.Any(lb => lb == libraryBook))
//Only try to add or update is this LibraryBook is a know child of a parent
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
bindingList.SuspendFilteringOnUpdate = false;
//Re-filter after updating existing / adding new books to capture any changes
//Re-apply filter after adding new/updating existing books to capture any changes
Filter(existingFilter);
#endregion
// remove deleted from grid.
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
var removedBooks =
bindingList
.AllItems()
.LibraryBooks()
.BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
RemoveBooks(removedBooks);
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
{
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
{
@@ -190,7 +206,72 @@ namespace LibationWinForms.GridView
//no need to re-filter for removed books
bindingList.Remove(removed);
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
bindingList.Insert(0, new LibraryBookEntry(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
if (seriesEntry is null)
{
//Series doesn't exist yet, so create and add it
var seriesBook = dbBooks.FindSeriesParent(episodeBook);
if (seriesBook is null)
{
//This is only possible if the user's db has some malformed
//entries from earlier Libation releases that could not be
//automatically fixed. Log, but don't throw.
Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames());
return;
}
seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
seriesEntry.Liberate.Expanded = true;
bindingList.Insert(0, seriesEntry);
}
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new(episodeBook) { Parent = seriesEntry };
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateSeries(seriesBook);
}
//Add episode to the grid beneath the parent
int seriesIndex = bindingList.IndexOf(seriesEntry);
bindingList.Insert(seriesIndex + 1, episodeEntry);
if (seriesEntry.Liberate.Expanded)
bindingList.ExpandItem(seriesEntry);
else
bindingList.CollapseItem(seriesEntry);
seriesEntry.NotifyPropertyChanged();
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
}
#endregion
@@ -207,7 +288,7 @@ namespace LibationWinForms.GridView
syncBindingSource.Filter = searchString;
if (visibleCount != bindingList.Count)
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
@@ -269,6 +350,14 @@ namespace LibationWinForms.GridView
column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index);
}
//Remove column is always first;
removeGVColumn.DisplayIndex = 0;
removeGVColumn.Visible = false;
removeGVColumn.ValueType = typeof(RemoveStatus);
removeGVColumn.FalseValue = RemoveStatus.NotRemoved;
removeGVColumn.TrueValue = RemoveStatus.Removed;
removeGVColumn.IndeterminateValue = RemoveStatus.SomeRemoved;
}
private void HideMenuItem_Click(object sender, EventArgs e)

View File

@@ -57,16 +57,13 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="removeGVColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="contextMenuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>171, 17</value>
</metadata>
<metadata name="syncBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="bindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>326, 17</value>
</metadata>
<metadata name="bindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>326, 17</value>
</metadata>
</root>

View File

@@ -0,0 +1,44 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationWinForms.GridView
{
#nullable enable
internal static class QueryExtensions
{
public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, 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 gridEntries.SeriesEntries().FirstOrDefault(
lb =>
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
}
#nullable disable
}

View File

@@ -2,108 +2,127 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry
{
public List<LibraryBookEntry> Children { get; init; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded);
public override float SeriesIndex { get; }
public override string ProductRating
[Browsable(false)] public List<LibraryBookEntry> Children { get; }
[Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove is RemoveStatus.Removed);
if (removeCount == 0)
_remove = RemoveStatus.NotRemoved;
else if (removeCount == Children.Count)
_remove = RemoveStatus.Removed;
else
_remove = RemoveStatus.SomeRemoved;
NotifyPropertyChanged(nameof(Remove));
}
#region Model properties exposed to the view
public override RemoveStatus Remove
{
get
{
var productAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.Rating.StoryRating));
return productAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace("");
return _remove;
}
protected set => throw new NotImplementedException();
}
public override string PurchaseDate { get; protected set; }
public override string MyRating
{
get
set
{
var myAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.StoryRating));
return myAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace("");
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
NotifyPropertyChanged();
}
protected set => throw new NotImplementedException();
}
public override string Series { get; protected set; }
public override string Title { get; protected set; }
public override string Length
{
get
{
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
protected set => throw new NotImplementedException();
}
public override string Authors { get; protected set; }
public override string Narrators { get; protected set; }
public override string Category { get; protected set; }
public override string Misc { get; protected set; } = string.Empty;
public override string Description { get; protected set; } = string.Empty;
public override string DisplayTags { get; } = string.Empty;
public override LiberateButtonStatus Liberate { get; }
public override string DisplayTags { get; } = string.Empty;
protected override Book Book => SeriesBook.Book;
#endregion
private SeriesBook SeriesBook { get; set; }
private SeriesEntry(SeriesBook seriesBook)
private SeriesEntry(LibraryBook parent)
{
Liberate = new LiberateButtonStatus { IsSeries = true };
SeriesIndex = seriesBook.Index;
SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
}
public SeriesEntry(SeriesBook seriesBook, IEnumerable<LibraryBook> children) : this(seriesBook)
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children) : this(parent)
{
Children = children.Select(c => new LibraryBookEntry(c) { Parent = this }).OrderBy(c => c.SeriesIndex).ToList();
SetSeriesBook(seriesBook);
Children = children
.Select(c => new LibraryBookEntry(c) { Parent = this })
.OrderBy(c => c.SeriesIndex)
.ToList();
UpdateSeries(parent);
}
public SeriesEntry(SeriesBook seriesBook, LibraryBook child) : this(seriesBook)
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent)
{
Children = new() { new LibraryBookEntry(child) { Parent = this } };
SetSeriesBook(seriesBook);
UpdateSeries(parent);
}
private void SetSeriesBook(SeriesBook seriesBook)
public void UpdateSeries(LibraryBook parent)
{
SeriesBook = seriesBook;
LoadCover();
LibraryBook = parent;
// Immutable properties
{
Title = SeriesBook.Series.Name;
Series = SeriesBook.Series.Name;
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
}
Title = Book.Title;
Series = Book.SeriesNames();
MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(LibraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
NotifyPropertyChanged();
}
#region Data Sorting
/// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Title), () => Book.SeriesSortable() },
{ nameof(Remove), () => Remove },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
{ nameof(MyRating), () => Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.FirstScore()) },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Children.Average(c => c.LibraryBook.Book.Rating.FirstScore()) },
{ nameof(Authors), () => string.Empty },
{ nameof(Narrators), () => string.Empty },
{ nameof(Description), () => string.Empty },
{ nameof(Category), () => string.Empty },
{ nameof(Misc), () => string.Empty },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(DisplayTags), () => string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
}
}

View File

@@ -28,7 +28,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autoupdater.NET.Official" Version="1.7.0" />
<PackageReference Include="Autoupdater.NET.Official" Version="1.7.3" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="4.2.2.1" />
</ItemGroup>
@@ -37,12 +37,17 @@
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Form1.*.cs">
<DependentUpon>Form1.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<Compile Update="Dialogs\SettingsDialog.*.cs">
<DependentUpon>SettingsDialog.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">

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