Compare commits

..

48 Commits

Author SHA1 Message Date
Robert McRackan
825078abc6 New feature: metadata correction in converted output files 2022-04-25 13:34:22 -04:00
Robert McRackan
6be44966ad * enhancement #202 : use audible category for file's genre metatag. Thanks @MBucari ! 2022-04-25 13:23:43 -04:00
rmcrackan
66da138556 Merge pull request #233 from Mbucari/master
Update Libation to work with new AAXClean.Codecs
2022-04-25 13:16:38 -04:00
Michael Bucari-Tovo
e5dd4b856e Update Libation to work with new AAXClean.Codecs 2022-04-24 19:40:34 -06:00
Robert McRackan
5caa9c5687 Improved logging for import errors 2022-04-16 16:36:49 -04:00
Robert McRackan
c8c0ffeb0d Bug fix #231 : Some books without categories are not getting imported 2022-04-15 16:30:43 -04:00
Robert McRackan
bfceb58d6b Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-04-12 09:16:10 -04:00
Robert McRackan
2e4c4cf5f7 bug fix #228 : recover from corrupt BookTags.json 2022-04-12 09:16:02 -04:00
rmcrackan
23966c9b00 Update README.md 2022-04-11 10:06:12 -04:00
rmcrackan
ef73d2243d add login details 2022-04-11 10:03:08 -04:00
Robert McRackan
c95feebd39 Add images for 'alternate login' readme 2022-04-11 09:56:59 -04:00
Robert McRackan
d6601fed83 Bug fix #225 : SaferEnumerateFiles will skip files with UnauthorizedAccessException 2022-04-08 09:11:36 -04:00
Robert McRackan
962e379642 Added debugging around file move and file delete 2022-03-30 09:52:39 -04:00
Robert McRackan
cbc61f5a2d Files and folders cannot end with dots 2022-03-25 13:51:29 -04:00
Robert McRackan
2eaac6acc2 Bug fix #210 : if attempting to paginate more than 10,000 titles : "Implied library size is unsupported" 2022-03-17 16:14:12 -04:00
Robert McRackan
03b458765c Bug fix: getting library had errors for libraries with over 10k titles 2022-03-17 13:06:55 -04:00
Robert McRackan
c8b4bc6361 Address issue #199 : Rare users can be unable to handle library scan batch size of 250. When timeout occurs, retry with batch size of 50 2022-03-06 14:21:21 -05:00
rmcrackan
d9b5725ff1 Update README.md
CLI manual change to settings file no longer needed
2022-03-03 16:59:31 -05:00
Robert McRackan
0a0f60192b Better exception logging with Serilog.Exceptions library 2022-03-03 16:54:43 -05:00
Robert McRackan
424d939c15 Update dependencies 2022-03-03 13:02:15 -05:00
Robert McRackan
87f13ff8ed Incr. ver. 2022-02-21 10:26:58 -05:00
Robert McRackan
1e24df626a Add error recovery around FileLocations.json to handle file corruption 2022-02-21 10:24:56 -05:00
Robert McRackan
0312786721 Add description to exports #197 2022-02-14 13:44:54 -05:00
Robert McRackan
1f8a5b256e Bug fix: Defensive FirstOrDefault. #194 2022-02-03 08:53:41 -05:00
rmcrackan
426391f01c Update README.md
Forgot to remove .nfo from readme
2022-01-30 09:55:34 -05:00
Robert McRackan
c296bff47f Bug fix #181 : if audible gives a null picture id then once that image is searched for, no further cover art was downloaded 2022-01-12 22:20:58 -05:00
Robert McRackan
6b649cf4ca Rare duplicates are making their way into the db. Defensive FirstOrDefault added to address bug #184 2022-01-11 07:34:42 -05:00
Robert McRackan
5103240a76 Bugfix: Latest AudibleAPI addresses #175 . Thank you again @mkb79 2022-01-04 11:25:49 -05:00
rmcrackan
c2418b10f6 Update README.md 2022-01-04 10:53:53 -05:00
Robert McRackan
d705c23472 Bug fix #158 : troublesome check is unnecessary anyway 2021-12-07 16:06:15 -05:00
Robert McRackan
de45d008c7 Bug fix #167 : folders with leading or trailing whitespace will break file saving. Including paths created from templates 2021-12-07 09:24:36 -05:00
Robert McRackan
c267332027 update dependencies 2021-12-06 15:12:44 -05:00
Robert McRackan
4829e85faf Bug fix #163 , #171 : pre-audible uk logins were failing. Thanks @mkb79 ! 2021-12-06 13:14:06 -05:00
Robert McRackan
2acb9ca7e5 Bug fix: Spent hours hunting down why database files weren't closing correctly. New to EF Core 6 "SQLite: Connections are pooled" " This results in database files being kept open by the process even after the ADO.NET connection object is closed." wtf microsoft?! 2021-12-06 12:00:12 -05:00
Robert McRackan
b260554a2a Bug fix #173 : error when importing the same book from multiple accounts during the same import 2021-12-06 11:05:46 -05:00
Robert McRackan
41a4055cd9 init default settings 2021-12-03 14:47:21 -05:00
Robert McRackan
c6e9ba9bf9 new user: init settings 2021-12-02 16:47:35 -05:00
Robert McRackan
5059333b38 Bug fix: First click on Liberated icon shows 'File not found: <temppath>'. Second click opens correct final path. #164 2021-11-30 09:54:32 -05:00
Robert McRackan
b4015030cf tl;dr text on alt login. Issue #160 2021-11-29 11:12:26 -05:00
Robert McRackan
7f5cf8f018 New config setting: ShowImportedStats -- "Show number of newly imported titles? When unchecked, no pop-up will appear after library scan." 2021-11-29 11:06:23 -05:00
rmcrackan
2c9ccd9c78 Update README.md
TOC
2021-11-29 09:05:52 -05:00
rmcrackan
cebf218db4 Update README.md
Add 'Installation'
2021-11-29 09:03:51 -05:00
Robert McRackan
530b44a0e6 Bug fix: in paths, double slashes are not allowed *except* at beginning. eg: \\192.168.0.1 (issue #157 ) 2021-11-24 13:42:11 -05:00
Robert McRackan
b3dc5a7054 Upgrade to .net6 2021-11-24 12:59:02 -05:00
Robert McRackan
2567ccb44c Enhancement: add if-series conditional logic to custom file naming. Issue #151 2021-11-11 16:43:44 -05:00
Robert McRackan
e67eac92fd Bug fix for issue #152 : individual episodes were ignored when targeted directly (as opposed to targeting the parent series) 2021-11-09 21:27:44 -05:00
Robert McRackan
6e84fd97f1 Accounts dialog: more user-friendly validation 2021-11-08 08:55:07 -05:00
Robert McRackan
9a458bf3dc Protect against blank settings.json 2021-11-05 15:25:31 -04:00
64 changed files with 1489 additions and 953 deletions

View File

@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean" Version="0.1.10" />
<PackageReference Include="AAXClean" Version="0.2.5" />
<PackageReference Include="AAXClean.Codecs" Version="0.1.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -24,10 +24,15 @@ namespace AaxDecrypter
AaxFile?.AppleTags.SetCoverArt(coverArt);
}
/// <summary>Optional step to run after Metadata is retrieved</summary>
public Action<AaxFile> UpdateMetadata { get; set; }
protected bool Step_GetMetadata()
{
AaxFile = new AaxFile(InputFileStream);
UpdateMetadata?.Invoke(AaxFile);
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.StepRunner;
using FileManager;
@@ -117,7 +118,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
{
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
newSplitCallback.LameConfig.ID3.Track = chapterCount.ToString();
((NAudio.Lame.LameConfig)newSplitCallback.UserState).ID3.Track = chapterCount.ToString();
});
}

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.StepRunner;
using FileManager;

View File

@@ -193,9 +193,6 @@ namespace AaxDecrypter
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
if (response.Headers.GetValues("Accept-Ranges").FirstOrDefault(r => r.EqualsInsensitive("bytes")) is null)
throw new WebException($"Server at {Uri.Host} does not support Http ranges");
//Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0-
if (WritePosition == 0)

View File

@@ -2,8 +2,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Version>6.4.2.1</Version>
<TargetFramework>net6.0-windows</TargetFramework>
<Version>6.8.1.1</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -50,16 +50,48 @@ namespace AppScaffolding
public static void RunPostConfigMigrations(Configuration config)
{
AudibleApiStorage.EnsureAccountsSettingsFileExists();
PopulateMissingConfigValues(config);
//
// migrations go below here
//
Migrations.migrate_to_v5_2_0__post_config(config);
Migrations.migrate_to_v5_7_1(config);
Migrations.migrate_to_v6_1_2(config);
Migrations.migrate_to_v6_2_0(config);
Migrations.migrate_to_v6_2_9(config);
Migrations.migrate_to_v6_6_9(config);
}
public static void PopulateMissingConfigValues(Configuration config)
{
config.InProgress ??= Configuration.WinTemp;
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
if (!config.Exists(nameof(config.BadBook)))
config.BadBook = Configuration.BadBookAction.Ask;
if (!config.Exists(nameof(config.DownloadEpisodes)))
config.DownloadEpisodes = true;
if (!config.Exists(nameof(config.ImportEpisodes)))
config.ImportEpisodes = true;
if (!config.Exists(nameof(config.SplitFilesByChapter)))
config.SplitFilesByChapter = false;
if (!config.Exists(nameof(config.FolderTemplate)))
config.FolderTemplate = Templates.Folder.DefaultTemplate;
if (!config.Exists(nameof(config.FileTemplate)))
config.FileTemplate = Templates.File.DefaultTemplate;
if (!config.Exists(nameof(config.ChapterFileTemplate)))
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
if (!config.Exists(nameof(config.ShowImportedStats)))
config.ShowImportedStats = true;
}
/// <summary>Initialize logging. Run after migration</summary>
@@ -75,29 +107,12 @@ namespace AppScaffolding
if (config.GetObject("Serilog") != null)
return;
// "Serilog": {
// "MinimumLevel": "Information"
// "WriteTo": [
// {
// "Name": "Console"
// },
// {
// "Name": "File",
// "Args": {
// "rollingInterval": "Day",
// "outputTemplate": ...
// }
// }
// ],
// "Using": [ "Dinah.Core" ],
// "Enrich": [ "WithCaller" ]
// }
var serilogObj = new JObject
{
{ "MinimumLevel", "Information" },
{ "WriteTo", new JArray
{
new JObject { {"Name", "Console" } },
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject
{
{ "Name", "File" },
@@ -112,14 +127,16 @@ namespace AppScaffolding
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" }
// {Properties:j} needed for expanded exception logging
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
}
}
}
}
},
{ "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller" } },
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetObject("Serilog", serilogObj);
}
@@ -134,9 +151,9 @@ namespace AppScaffolding
// capture most Console.WriteLine() and write to serilog. See below tests for details.
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
//
// Serilog also writes to Console so this might be asking for trouble. ie: infinite loops.
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
// Empirical testing so far has shown no issues.
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
// However, empirical testing so far has shown no issues.
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
#region Console => Serilog tests
@@ -213,10 +230,10 @@ namespace AppScaffolding
config.InProgress,
AudibleFileStorage.DownloadsInProgressDirectory,
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
DownloadsInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
});
}
@@ -312,52 +329,53 @@ namespace AppScaffolding
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
_ => path
};
public static void migrate_to_v5_2_0__post_config(Configuration config)
{
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
}
#endregion
// add config.BadBook
public static void migrate_to_v5_7_1(Configuration config)
public static void migrate_to_v6_6_9(Configuration config)
{
if (!config.Exists(nameof(config.BadBook)))
config.BadBook = Configuration.BadBookAction.Ask;
}
var writeToPath = $"Serilog.WriteTo";
// add config.DownloadEpisodes , config.ImportEpisodes
public static void migrate_to_v6_1_2(Configuration config)
{
if (!config.Exists(nameof(config.DownloadEpisodes)))
config.DownloadEpisodes = true;
// remove WriteTo[].Name == Console
{
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
{
for (var i = length1 - 1; i >= 0; i--)
{
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
if (!config.Exists(nameof(config.ImportEpisodes)))
config.ImportEpisodes = true;
}
if (exists && value == "Console")
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
}
}
}
// add config.SplitFilesByChapter
public static void migrate_to_v6_2_0(Configuration config)
{
if (!config.Exists(nameof(config.SplitFilesByChapter)))
config.SplitFilesByChapter = false;
}
// add Serilog.Exceptions -- WithExceptionDetails
{
// outputTemplate should contain "{Properties:j}"
{
// re-calculate. previous loop may have changed the length
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
{
var propertyName = "outputTemplate";
for (var i = 0; i < length2; i++)
{
var jsonPath = $"{writeToPath}[{i}].Args";
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
// add file naming templates
public static void migrate_to_v6_2_9(Configuration config)
{
if (!config.Exists(nameof(config.FolderTemplate)))
config.FolderTemplate = Templates.Folder.DefaultTemplate;
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
if (!config.Exists(nameof(config.FileTemplate)))
config.FileTemplate = Templates.File.DefaultTemplate;
if (exists && value != newValue)
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
}
}
}
if (!config.Exists(nameof(config.ChapterFileTemplate)))
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
// Serilog.Using must include "Serilog.Exceptions"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
// Serilog.Enrich must include "WithExceptionDetails"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
}
}

View File

@@ -113,6 +113,108 @@ namespace AppScaffolding
return success;
}
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
{
JToken val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
if (val?.Type == JTokenType.String)
{
value = val.Value<string>();
return true;
}
else
{
value = null;
return false;
}
}
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
{
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
return;
process_SettingsJson(jObj =>
{
var token = jObj.SelectToken(jsonPath);
if (token is null
|| token is not JObject o
|| o[propertyName] is null)
return;
var oldValue = token.Value<string>(propertyName);
if (oldValue != newValue)
token[propertyName] = newValue;
});
}
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
{
length = 0;
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
length = array.Count;
return true;
}
public static void Settings_AddToArray(string jsonPath, string newValue)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
array.Add(newValue);
});
}
/// <summary>Do not add if already exists</summary>
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
{
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
return;
for (var i = 0; i < qty; i++)
{
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
if (exists && value == newValue)
return;
}
Settings_AddToArray(arrayPath, newValue);
}
/// <summary>only remove if not exists</summary>
public static void Settings_RemoveFromArray(string jsonPath, int position)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
if (position < array.Count)
array.RemoveAt(position);
});
}
/// <summary>only insert if not exists</summary>
public static void Settings_Insert(string key, string value)
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));

View File

@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="27.1.1" />
<PackageReference Include="CsvHelper" Version="27.2.1" />
<PackageReference Include="NPOI" Version="2.5.5" />
</ItemGroup>

View File

@@ -164,25 +164,46 @@ namespace ApplicationServices
}
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
{
logTime("importIntoDbAsync -- pre db");
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 SaveChanges");
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount;
}
private static int saveChanges(LibationContext context)
{
logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext();
var libraryImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
var qtyChanges = context.SaveChanges();
logTime("importIntoDbAsync -- post SaveChanges");
try
{
return context.SaveChanges();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
{
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culpret is the "WithExceptionDetails" serilog extension
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
return newCount;
}
#endregion
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
if (ex.InnerException is null)
throw new Exception($"{msg}{format(ex)}");
throw new Exception(
$"{msg}{format(ex)}",
new Exception($"Inner Exception{format(ex.InnerException)}"));
}
}
#endregion
#region remove books
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
#region remove books
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
{
using var context = DbContexts.GetContext();

View File

@@ -44,6 +44,9 @@ namespace ApplicationServices
[Name("Length In Minutes")]
public int LengthInMinutes { get; set; }
[Name("Description")]
public string Description { get; set; }
[Name("Publisher")]
public string Publisher { get; set; }
@@ -111,6 +114,7 @@ namespace ApplicationServices
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames,
@@ -180,6 +184,7 @@ namespace ApplicationServices
nameof (ExportDto.AuthorNames),
nameof (ExportDto.NarratorNames),
nameof (ExportDto.LengthInMinutes),
nameof (ExportDto.Description),
nameof (ExportDto.Publisher),
nameof (ExportDto.HasPdf),
nameof (ExportDto.SeriesNames),
@@ -233,6 +238,7 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
row.CreateCell(col++).SetCellValue(dto.Description);
row.CreateCell(col++).SetCellValue(dto.Publisher);
row.CreateCell(col++).SetCellValue(dto.HasPdf);
row.CreateCell(col++).SetCellValue(dto.SeriesNames);

View File

@@ -127,11 +127,18 @@ namespace AudibleUtilities
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//}
#endif
Serilog.Log.Logger.Debug("Begin initial library scan");
if (!items.Any())
items = await Api.GetAllLibraryItemsAsync(responseGroups);
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
await manageEpisodesAsync(items, importEpisodes);
Serilog.Log.Logger.Debug("Episode scan complete");
#if DEBUG
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
#endif
@@ -166,8 +173,7 @@ namespace AudibleUtilities
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
// remove episode parents. even if the following stuff fails, these will still be removed from the collection.
// also must happen before processing children because children abuses this flag
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
items.RemoveAll(i => i.IsEpisodes);
if (importEpisodes)
@@ -192,6 +198,14 @@ namespace AudibleUtilities
{
var children = await getEpisodeChildrenAsync(parent);
// actual individual episode, not the parent of a series.
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
if (!children.Any())
{
results.Add(parent);
continue;
}
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
@@ -202,7 +216,8 @@ namespace AudibleUtilities
new Series
{
Asin = parent.Asin,
Sequence = parent.Relationships.Single(r => r.Asin == child.Asin).Sort.ToString(),
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin).Sort.ToString(),
Title = parent.TitleWithSubtitle
}
};

View File

@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="2.3.4.1" />
<PackageReference Include="AudibleApi" Version="2.7.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
</PropertyGroup>
<PropertyGroup>
@@ -12,13 +12,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.EntityFrameworkCore" Version="1.0.7.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.11">
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -18,7 +18,7 @@ namespace DataLayer
public class Category
{
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }

View File

@@ -7,7 +7,7 @@ namespace DataLayer
public class Contributor
{
// Empty is a special case. use private ctor w/o validation
public static Contributor GetEmpty() => new Contributor { ContributorId = -1, Name = "" };
public static Contributor GetEmpty() => new() { ContributorId = -1, Name = "" };
// contributors search links are just name with url-encoding. space can be + or %20
// author search link: /search?searchAuthor=Robert+Bevan

View File

@@ -94,7 +94,8 @@ namespace DtoImporterService
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = item
.Authors
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
.Select(a => DbContext.Contributors.Local.FirstOrDefault(c => a.Name == c.Name))
.ToList();
var narrators
@@ -104,7 +105,8 @@ namespace DtoImporterService
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
: item
.Narrators
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
.Select(n => DbContext.Contributors.Local.FirstOrDefault(c => n.Name == c.Name))
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
@@ -118,24 +120,45 @@ namespace DtoImporterService
// 2+
: item.Categories[1].CategoryId;
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
// This should properly be SingleOrDefault() not FirstOrDefault(), but FirstOrDefault is defensive
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == lastCategory);
var book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
authors,
narrators,
category,
importItem.LocaleName)
).Entity;
Book book;
try
{
book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
authors,
narrators,
category,
importItem.LocaleName)
).Entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new {
item.ProductId,
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
QtyAuthors = authors?.Count,
QtyNarrators = narrators?.Count,
Category = category?.Name,
importItem.LocaleName
});
throw;
}
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
var publisher = DbContext.Contributors.Local.FirstOrDefault(c => publisherName == c.Name);
book.ReplacePublisher(publisher);
}
@@ -152,7 +175,9 @@ namespace DtoImporterService
var item = importItem.DtoItem;
// set/update book-specific info which may have changed
book.PictureId = item.PictureId;
if (item.PictureId is not null)
book.PictureId = item.PictureId;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import

View File

@@ -35,6 +35,9 @@ namespace DtoImporterService
private void loadLocal_categories(List<string> categoryIds)
{
// must include default/empty/missing
categoryIds.Add(Category.GetEmpty().AudibleCategoryId);
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId).ToList();
var remainingCategoryIds = categoryIds
.Distinct()
@@ -42,10 +45,8 @@ namespace DtoImporterService
.ToList();
// load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingCategoryIds.Any())
DbContext.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId) || c.Name == emptyName).ToList();
DbContext.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId)).ToList();
}
// only use after loading contributors => local
@@ -73,7 +74,15 @@ namespace DtoImporterService
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == id);
if (category is null)
{
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
try
{
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category. {@DebugInfo}", new { id, name });
throw;
}
qtyNew++;
}

View File

@@ -82,8 +82,18 @@ namespace DtoImporterService
);
foreach (var name in newPeople)
{
var p = groupby.Single(g => g.Name == name).People.First();
DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
var p = groupby.FirstOrDefault(g => g.Name == name).People.First();
try
{
DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding person. {@DebugInfo}", new { p?.Name, p?.Asin });
throw;
}
}
return newPeople.Count;
@@ -99,7 +109,17 @@ namespace DtoImporterService
.ToList();
foreach (var pub in newPublishers)
DbContext.Contributors.Add(new Contributor(pub));
{
try
{
DbContext.Contributors.Add(new Contributor(pub));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding publisher. {@DebugInfo}", new { pub });
throw;
}
}
return newPublishers.Count;
}

View File

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

View File

@@ -35,18 +35,39 @@ namespace DtoImporterService
// CURRENT SOLUTION: don't re-insert
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
var newItems = importItems
.Where(dto => !currentLibraryProductIds
.Contains(dto.DtoItem.ProductId))
.ToList();
foreach (var newItem in newItems)
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
// just use the first
var groupby = newItems.GroupBy(
i => i.DtoItem.ProductId,
i => i,
(key, g) => new { ProductId = key, ImportItems = g.ToList() }
)
.ToList();
foreach (var gb in groupby)
{
var newItem = gb.ImportItems.First();
var libraryBook = new LibraryBook(
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
DbContext.Books.Local.FirstOrDefault(b => b.AudibleProductId == newItem.DtoItem.ProductId),
newItem.DtoItem.DateAdded,
newItem.AccountId);
DbContext.LibraryBooks.Add(libraryBook);
try
{
DbContext.LibraryBooks.Add(libraryBook);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
}
}
var qtyNew = newItems.Count;
var qtyNew = groupby.Count;
return qtyNew;
}
}

View File

@@ -51,7 +51,15 @@ namespace DtoImporterService
var series = DbContext.Series.Local.FirstOrDefault(c => c.AudibleSeriesId == s.SeriesId);
if (series is null)
{
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
try
{
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding series. {@DebugInfo}", new { s?.SeriesId });
throw;
}
qtyNew++;
}
series.UpdateName(s.SeriesName);

View File

@@ -2,6 +2,7 @@
using System.IO;
using System.Threading.Tasks;
using AAXClean;
using AAXClean.Codecs;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;

View File

@@ -119,9 +119,21 @@ namespace FileLiberator
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic);
else
{
AaxcDownloadConvertBase converter
= Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
outFileName, cacheDir, audiobookDlLic, outputFormat,
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook))
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
converter.UpdateMetadata = aaxFile => aaxFile.AppleTags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames);
abDownloader = converter;
}
abDownloader
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm ? new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic)
: Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
= Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
outFileName, cacheDir, audiobookDlLic, outputFormat,
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook)
)

View File

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

View File

@@ -43,7 +43,7 @@ namespace FileManager
lock (fsCacheLocker)
{
fsCache.Clear();
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
fsCache.AddRange(FileUtility.SaferEnumerateFiles(RootDirectory, SearchPattern, SearchOption));
}
}
@@ -52,7 +52,7 @@ namespace FileManager
Stop();
lock (fsCacheLocker)
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
fsCache.AddRange(FileUtility.SaferEnumerateFiles(RootDirectory, SearchPattern, SearchOption));
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
@@ -135,7 +135,7 @@ namespace FileManager
private void AddPath(string path)
{
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
AddUniqueFiles(Directory.EnumerateFiles(path, SearchPattern, SearchOption));
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
else
AddUniqueFile(path);
}

View File

@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="3.0.1.1" />
<PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="Dinah.Core" Version="4.0.4.1" />
<PackageReference Include="Polly" Version="7.2.3" />
</ItemGroup>
</Project>

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
using Polly;
using Polly.Retry;
@@ -65,6 +66,8 @@ namespace FileManager
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
fullfilename = removeInvalidWhitespace(fullfilename);
var i = 0;
while (File.Exists(fullfilename))
{
@@ -86,36 +89,80 @@ namespace FileManager
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
var invalidChars = Path.GetInvalidPathChars().Union(new[] {
path = replaceInvalidChars(path, illegalCharacterReplacements);
path = standardizeSlashes(path);
path = replaceColons(path, illegalCharacterReplacements);
path = removeDoubleSlashes(path);
return path;
}
private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] {
'*', '?',
// these are weird. If you run Path.GetInvalidPathChars() in C# interactive, these characters are included.
// 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));
var fixedPath = string
.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars))
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
private static string standardizeSlashes(string path)
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
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 < fixedPath.Length; i++)
for (var i = 0; i < path.Length; i++)
{
var c = fixedPath[i];
var c = path[i];
if (i >= 2 && c == ':')
builder.Append(illegalCharacterReplacements);
else
builder.Append(c);
}
fixedPath = builder.ToString();
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (fixedPath.Contains(dblSeparator))
fixedPath = fixedPath.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
return fixedPath;
return builder.ToString();
}
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
var remainder = path[1..];
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (remainder.Contains(dblSeparator))
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
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);
/// <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
// 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);
fullfilename = removeDoubleSlashes(fullfilename);
return fullfilename;
}
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
/// <summary>
/// Move file.
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
@@ -142,15 +189,19 @@ namespace FileManager
{
try
{
if (File.Exists(source))
if (!File.Exists(source))
{
File.Delete(source);
Serilog.Log.Logger.Information("File successfully deleted", new { source });
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
return;
}
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", new { source });
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
throw;
}
});
@@ -161,19 +212,61 @@ namespace FileManager
{
try
{
if (File.Exists(source))
if (!File.Exists(source))
{
SaferDelete(destination);
Directory.CreateDirectory(Path.GetDirectoryName(destination));
File.Move(source, destination);
Serilog.Log.Logger.Information("File successfully moved", new { source, destination });
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
return;
}
SaferDelete(destination);
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 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", new { source, destination });
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
throw;
}
});
/// <summary>
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
/// </summary>
/// <param name="rootPath">Starting directory</param>
/// <param name="patternMatch">Filename pattern match</param>
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
/// <returns>List of files</returns>
public static IEnumerable<string> SaferEnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var foundFiles = Enumerable.Empty<string>();
if (searchOption == SearchOption.AllDirectories)
{
try
{
IEnumerable<string> subDirs = Directory.EnumerateDirectories(path);
// Add files in subdirectories recursively to the list
foreach (string dir in subDirs)
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
}
catch (UnauthorizedAccessException) { }
catch (PathTooLongException) { }
}
try
{
// Add files from the current directory
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern));
}
catch (UnauthorizedAccessException) { }
return foundFiles;
}
}
}

View File

@@ -30,8 +30,7 @@ namespace FileManager
if (IsReadOnly)
return;
File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
createNewFile();
}
public string GetString(string propertyName)
@@ -209,22 +208,35 @@ namespace FileManager
{
var msg = "Unrecoverable error. Settings file cannot be found";
var ex = new FileNotFoundException(msg, Filepath);
Serilog.Log.Logger.Error(msg, ex);
Serilog.Log.Logger.Error(ex, msg);
throw ex;
}
var settingsJsonContents = File.ReadAllText(Filepath);
if (string.IsNullOrWhiteSpace(settingsJsonContents))
{
createNewFile();
settingsJsonContents = File.ReadAllText(Filepath);
}
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
if (jObject is null)
{
var msg = "Unrecoverable error. Unable to read settings from Settings file";
var ex = new NullReferenceException(msg);
Serilog.Log.Logger.Error(msg, ex);
Serilog.Log.Logger.Error(ex, msg);
throw ex;
}
return jObject;
}
private void createNewFile()
{
File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
}
}
}

View File

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

View File

@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.28803.156
# Visual Studio Version 17
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
@@ -62,7 +62,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator.Tests", "_Tes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests\FileManager.Tests\FileManager.Tests.csproj", "{F2E04270-4551-41C4-99FF-E7125BED708C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<PublishTrimmed>true</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>

View File

@@ -47,7 +47,7 @@ namespace LibationFileManager
{
// primary lookup
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
if (cachedFile != null)
if (cachedFile is not null && File.Exists(cachedFile))
return cachedFile;
// secondary lookup attempt
@@ -73,8 +73,8 @@ namespace LibationFileManager
protected override string GetFilePathCustom(string productId)
{
var regex = GetBookSearchRegex(productId);
return Directory
.EnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
return FileUtility
.SaferEnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => regex.IsMatch(s));
}

View File

@@ -133,6 +133,13 @@ namespace LibationFileManager
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
{
@@ -263,7 +270,7 @@ namespace LibationFileManager
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Information("LogLevel.set attempt. No change");
Log.Logger.Debug("LogLevel.set attempt. No change");
return;
}

View File

@@ -26,6 +26,18 @@ namespace LibationFileManager
if (File.Exists(jsonFile))
{
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
// file exists but deser is null. this will never happen when file is healthy
if (list is null)
{
lock (locker)
{
Serilog.Log.Logger.Error("Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFile });
File.Delete(jsonFile);
return;
}
}
cache = new Cache<CacheEntry>(list);
}
}

View File

@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -73,16 +73,10 @@ namespace LibationFileManager
if (!cache.ContainsKey(def) || cache[def] == null)
{
var path = getPath(def);
byte[] bytes;
if (File.Exists(path))
bytes = File.ReadAllBytes(path);
else
{
bytes = downloadBytes(def);
saveFile(def, bytes);
}
var bytes
= File.Exists(path)
? File.ReadAllBytes(path)
: downloadBytes(def);
cache[def] = bytes;
}
return cache[def];
@@ -104,7 +98,6 @@ namespace LibationFileManager
continue;
var bytes = downloadBytes(def);
saveFile(def, bytes);
lock (cacheLocker)
cache[def] = bytes;
@@ -115,14 +108,24 @@ namespace LibationFileManager
private static HttpClient imageDownloadClient { get; } = new HttpClient();
private static byte[] downloadBytes(PictureDefinition def)
{
var sz = (int)def.Size;
return imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}._SL{sz}_.jpg").Result;
}
if (def.PictureId is null)
return getDefaultImage(def.Size);
private static void saveFile(PictureDefinition def, byte[] bytes)
{
var path = getPath(def);
File.WriteAllBytes(path, bytes);
try
{
var sz = (int)def.Size;
var bytes = imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}._SL{sz}_.jpg").Result;
// save image file. make sure to not save default image
var path = getPath(def);
File.WriteAllBytes(path, bytes);
return bytes;
}
catch
{
return getDefaultImage(def.Size);
}
}
}
}

View File

@@ -6,6 +6,6 @@ namespace LibationFileManager
{
// not customizable. don't move to config
private static string databasePath => Path.Combine(Configuration.Instance.LibationFiles, "LibationContext.db");
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;";
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;Pooling=False;";
}
}

View File

@@ -54,11 +54,17 @@ namespace LibationFileManager
private static void ensureCache()
{
if (cache is null)
lock (locker)
cache = !File.Exists(TagsFile)
? new Dictionary<string, string>()
: JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
if (cache is not null)
return;
lock (locker)
{
if (File.Exists(TagsFile))
cache = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
// if file doesn't exist. or if file is corrupt and deserialize returns null
cache ??= new Dictionary<string, string>();
}
}
}
}

View File

@@ -21,8 +21,8 @@ namespace LibationFileManager
// putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true);
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter number", true);
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter number with leading zeros", true);
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true);
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true);
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title");
@@ -36,5 +36,9 @@ namespace LibationFileManager
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
public static TemplateTags Locale { get; } = new TemplateTags("locale", "Region/country");
// Special case. Isn't mapped to a replacement in Templates.cs
// Included here for display by EditTemplateDialog
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series");
}
}

View File

@@ -1,9 +1,10 @@
using Dinah.Core;
using FileManager;
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
using FileManager;
namespace LibationFileManager
{
@@ -106,12 +107,21 @@ namespace LibationFileManager
: getFileNamingTemplate(libraryBookDto, template, null, null)
.GetFilePath();
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));
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");
var t = template + FileUtility.GetStandardizedExtension(extension);
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);

View File

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

View File

@@ -112,6 +112,9 @@ namespace LibationWinForms.Dialogs
{
try
{
if (!inputIsValid())
return;
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
@@ -129,6 +132,28 @@ namespace LibationWinForms.Dialogs
}
}
private bool inputIsValid()
{
var dtos = getRowDtos();
foreach (var dto in dtos)
{
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);
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);
return false;
}
}
return true;
}
private void persist(AccountsSettings accountsSettings)
{
var existingAccounts = accountsSettings.Accounts;
@@ -152,11 +177,6 @@ namespace LibationWinForms.Dialogs
// upsert each. validation occurs through Account and AccountsSettings
foreach (var dto in dtos)
{
if (string.IsNullOrWhiteSpace(dto.AccountId))
throw new Exception("Please enter an account id for all accounts");
if (string.IsNullOrWhiteSpace(dto.LocaleName))
throw new Exception("Please select a locale (i.e.: country or region) for all accounts");
var acct = accountsSettings.Upsert(dto.AccountId, dto.LocaleName);
acct.LibraryScan = dto.LibraryScan;
acct.AccountName

View File

@@ -114,12 +114,12 @@
// columnHeader1
//
this.columnHeader1.Text = "Tag";
this.columnHeader1.Width = 90;
this.columnHeader1.Width = 137;
//
// columnHeader2
//
this.columnHeader2.Text = "Description";
this.columnHeader2.Width = 230;
this.columnHeader2.Width = 170;
//
// richTextBox1
//
@@ -137,14 +137,13 @@
// warningsLbl
//
this.warningsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.warningsLbl.AutoSize = true;
this.warningsLbl.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
this.warningsLbl.ForeColor = System.Drawing.Color.Firebrick;
this.warningsLbl.Location = new System.Drawing.Point(346, 262);
this.warningsLbl.Name = "warningsLbl";
this.warningsLbl.Size = new System.Drawing.Size(14, 15);
this.warningsLbl.TabIndex = 100;
this.warningsLbl.Text = "6";
this.warningsLbl.Size = new System.Drawing.Size(574, 77);
this.warningsLbl.TabIndex = 6;
this.warningsLbl.Text = "[warnings]";
//
// exampleLbl
//

View File

@@ -28,140 +28,152 @@
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LoginExternalDialog));
this.submitBtn = new System.Windows.Forms.Button();
this.localeLbl = new System.Windows.Forms.Label();
this.usernameLbl = new System.Windows.Forms.Label();
this.loginUrlLbl = new System.Windows.Forms.Label();
this.loginUrlTb = new System.Windows.Forms.TextBox();
this.copyBtn = new System.Windows.Forms.Button();
this.launchBrowserBtn = new System.Windows.Forms.Button();
this.instructionsLbl = new System.Windows.Forms.Label();
this.responseUrlTb = new System.Windows.Forms.TextBox();
this.SuspendLayout();
//
// submitBtn
//
this.submitBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.submitBtn.Location = new System.Drawing.Point(665, 400);
this.submitBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.submitBtn.Name = "submitBtn";
this.submitBtn.Size = new System.Drawing.Size(88, 27);
this.submitBtn.TabIndex = 8;
this.submitBtn.Text = "Submit";
this.submitBtn.UseVisualStyleBackColor = true;
this.submitBtn.Click += new System.EventHandler(this.submitBtn_Click);
//
// localeLbl
//
this.localeLbl.AutoSize = true;
this.localeLbl.Location = new System.Drawing.Point(14, 10);
this.localeLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
this.localeLbl.Name = "localeLbl";
this.localeLbl.Size = new System.Drawing.Size(61, 15);
this.localeLbl.TabIndex = 0;
this.localeLbl.Text = "Locale: {0}";
//
// usernameLbl
//
this.usernameLbl.AutoSize = true;
this.usernameLbl.Location = new System.Drawing.Point(14, 25);
this.usernameLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
this.usernameLbl.Name = "usernameLbl";
this.usernameLbl.Size = new System.Drawing.Size(80, 15);
this.usernameLbl.TabIndex = 1;
this.usernameLbl.Text = "Username: {0}";
//
// loginUrlLbl
//
this.loginUrlLbl.AutoSize = true;
this.loginUrlLbl.Location = new System.Drawing.Point(14, 61);
this.loginUrlLbl.Name = "loginUrlLbl";
this.loginUrlLbl.Size = new System.Drawing.Size(180, 15);
this.loginUrlLbl.TabIndex = 2;
this.loginUrlLbl.Text = "Paste this URL into your browser:";
//
// loginUrlTb
//
this.loginUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(LoginExternalDialog));
this.submitBtn = new System.Windows.Forms.Button();
this.localeLbl = new System.Windows.Forms.Label();
this.usernameLbl = new System.Windows.Forms.Label();
this.loginUrlLbl = new System.Windows.Forms.Label();
this.loginUrlTb = new System.Windows.Forms.TextBox();
this.copyBtn = new System.Windows.Forms.Button();
this.launchBrowserBtn = new System.Windows.Forms.Button();
this.instructionsLbl = new System.Windows.Forms.Label();
this.responseUrlTb = new System.Windows.Forms.TextBox();
this.tldrLbl = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// submitBtn
//
this.submitBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.submitBtn.Location = new System.Drawing.Point(665, 458);
this.submitBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.submitBtn.Name = "submitBtn";
this.submitBtn.Size = new System.Drawing.Size(88, 27);
this.submitBtn.TabIndex = 8;
this.submitBtn.Text = "Submit";
this.submitBtn.UseVisualStyleBackColor = true;
this.submitBtn.Click += new System.EventHandler(this.submitBtn_Click);
//
// localeLbl
//
this.localeLbl.AutoSize = true;
this.localeLbl.Location = new System.Drawing.Point(14, 10);
this.localeLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
this.localeLbl.Name = "localeLbl";
this.localeLbl.Size = new System.Drawing.Size(61, 15);
this.localeLbl.TabIndex = 0;
this.localeLbl.Text = "Locale: {0}";
//
// usernameLbl
//
this.usernameLbl.AutoSize = true;
this.usernameLbl.Location = new System.Drawing.Point(14, 25);
this.usernameLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
this.usernameLbl.Name = "usernameLbl";
this.usernameLbl.Size = new System.Drawing.Size(80, 15);
this.usernameLbl.TabIndex = 1;
this.usernameLbl.Text = "Username: {0}";
//
// loginUrlLbl
//
this.loginUrlLbl.AutoSize = true;
this.loginUrlLbl.Location = new System.Drawing.Point(14, 61);
this.loginUrlLbl.Name = "loginUrlLbl";
this.loginUrlLbl.Size = new System.Drawing.Size(180, 15);
this.loginUrlLbl.TabIndex = 2;
this.loginUrlLbl.Text = "Paste this URL into your browser:";
//
// loginUrlTb
//
this.loginUrlTb.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.loginUrlTb.Location = new System.Drawing.Point(14, 79);
this.loginUrlTb.Multiline = true;
this.loginUrlTb.Name = "loginUrlTb";
this.loginUrlTb.ReadOnly = true;
this.loginUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.loginUrlTb.Size = new System.Drawing.Size(739, 92);
this.loginUrlTb.TabIndex = 3;
//
// copyBtn
//
this.copyBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.copyBtn.Location = new System.Drawing.Point(14, 177);
this.copyBtn.Name = "copyBtn";
this.copyBtn.Size = new System.Drawing.Size(165, 23);
this.copyBtn.TabIndex = 4;
this.copyBtn.Text = "Copy URL to clipboard";
this.copyBtn.UseVisualStyleBackColor = true;
this.copyBtn.Click += new System.EventHandler(this.copyBtn_Click);
//
// launchBrowserBtn
//
this.launchBrowserBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.launchBrowserBtn.Location = new System.Drawing.Point(588, 177);
this.launchBrowserBtn.Name = "launchBrowserBtn";
this.launchBrowserBtn.Size = new System.Drawing.Size(165, 23);
this.launchBrowserBtn.TabIndex = 5;
this.launchBrowserBtn.Text = "Launch in browser";
this.launchBrowserBtn.UseVisualStyleBackColor = true;
this.launchBrowserBtn.Click += new System.EventHandler(this.launchBrowserBtn_Click);
//
// instructionsLbl
//
this.instructionsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.instructionsLbl.AutoSize = true;
this.instructionsLbl.Location = new System.Drawing.Point(14, 203);
this.instructionsLbl.Name = "instructionsLbl";
this.instructionsLbl.Size = new System.Drawing.Size(436, 90);
this.instructionsLbl.TabIndex = 6;
this.instructionsLbl.Text = resources.GetString("instructionsLbl.Text");
//
// responseUrlTb
//
this.responseUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
this.loginUrlTb.Location = new System.Drawing.Point(14, 79);
this.loginUrlTb.Multiline = true;
this.loginUrlTb.Name = "loginUrlTb";
this.loginUrlTb.ReadOnly = true;
this.loginUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.loginUrlTb.Size = new System.Drawing.Size(739, 117);
this.loginUrlTb.TabIndex = 3;
//
// copyBtn
//
this.copyBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.copyBtn.Location = new System.Drawing.Point(14, 202);
this.copyBtn.Name = "copyBtn";
this.copyBtn.Size = new System.Drawing.Size(165, 23);
this.copyBtn.TabIndex = 4;
this.copyBtn.Text = "Copy URL to clipboard";
this.copyBtn.UseVisualStyleBackColor = true;
this.copyBtn.Click += new System.EventHandler(this.copyBtn_Click);
//
// launchBrowserBtn
//
this.launchBrowserBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.launchBrowserBtn.Location = new System.Drawing.Point(589, 202);
this.launchBrowserBtn.Name = "launchBrowserBtn";
this.launchBrowserBtn.Size = new System.Drawing.Size(165, 23);
this.launchBrowserBtn.TabIndex = 5;
this.launchBrowserBtn.Text = "Launch in browser";
this.launchBrowserBtn.UseVisualStyleBackColor = true;
this.launchBrowserBtn.Click += new System.EventHandler(this.launchBrowserBtn_Click);
//
// instructionsLbl
//
this.instructionsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.instructionsLbl.AutoSize = true;
this.instructionsLbl.Location = new System.Drawing.Point(14, 261);
this.instructionsLbl.Name = "instructionsLbl";
this.instructionsLbl.Size = new System.Drawing.Size(436, 90);
this.instructionsLbl.TabIndex = 6;
this.instructionsLbl.Text = resources.GetString("instructionsLbl.Text");
//
// responseUrlTb
//
this.responseUrlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.responseUrlTb.Location = new System.Drawing.Point(14, 296);
this.responseUrlTb.Multiline = true;
this.responseUrlTb.Name = "responseUrlTb";
this.responseUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.responseUrlTb.Size = new System.Drawing.Size(739, 98);
this.responseUrlTb.TabIndex = 7;
//
// LoginExternalDialog
//
this.AcceptButton = this.submitBtn;
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(766, 440);
this.Controls.Add(this.responseUrlTb);
this.Controls.Add(this.instructionsLbl);
this.Controls.Add(this.launchBrowserBtn);
this.Controls.Add(this.copyBtn);
this.Controls.Add(this.loginUrlTb);
this.Controls.Add(this.loginUrlLbl);
this.Controls.Add(this.usernameLbl);
this.Controls.Add(this.localeLbl);
this.Controls.Add(this.submitBtn);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "LoginExternalDialog";
this.ShowIcon = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Audible External Login";
this.ResumeLayout(false);
this.PerformLayout();
this.responseUrlTb.Location = new System.Drawing.Point(14, 354);
this.responseUrlTb.Multiline = true;
this.responseUrlTb.Name = "responseUrlTb";
this.responseUrlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.responseUrlTb.Size = new System.Drawing.Size(739, 98);
this.responseUrlTb.TabIndex = 7;
//
// tldrLbl
//
this.tldrLbl.AutoSize = true;
this.tldrLbl.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
this.tldrLbl.Location = new System.Drawing.Point(14, 237);
this.tldrLbl.Name = "tldrLbl";
this.tldrLbl.Size = new System.Drawing.Size(421, 15);
this.tldrLbl.TabIndex = 9;
this.tldrLbl.Text = "tl;dr : an ERROR on Amazon is GOOD. Sorry, I can\'t control their weird login";
//
// LoginExternalDialog
//
this.AcceptButton = this.submitBtn;
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(766, 498);
this.Controls.Add(this.tldrLbl);
this.Controls.Add(this.responseUrlTb);
this.Controls.Add(this.instructionsLbl);
this.Controls.Add(this.launchBrowserBtn);
this.Controls.Add(this.copyBtn);
this.Controls.Add(this.loginUrlTb);
this.Controls.Add(this.loginUrlLbl);
this.Controls.Add(this.usernameLbl);
this.Controls.Add(this.localeLbl);
this.Controls.Add(this.submitBtn);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "LoginExternalDialog";
this.ShowIcon = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Audible External Login";
this.ResumeLayout(false);
this.PerformLayout();
}
@@ -175,5 +187,6 @@
private System.Windows.Forms.Button launchBrowserBtn;
private System.Windows.Forms.Label instructionsLbl;
private System.Windows.Forms.TextBox responseUrlTb;
}
private System.Windows.Forms.Label tldrLbl;
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ namespace LibationWinForms.Dialogs
loggingLevelCb.SelectedItem = config.LogLevel;
}
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
this.booksLocationDescLbl.Text = desc(nameof(config.Books));
@@ -46,6 +47,7 @@ namespace LibationWinForms.Dialogs
"Books");
booksSelectControl.SelectDirectory(config.Books);
showImportedStatsCb.Checked = config.ShowImportedStats;
importEpisodesCb.Checked = config.ImportEpisodes;
downloadEpisodesCb.Checked = config.DownloadEpisodes;
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
@@ -166,6 +168,7 @@ namespace LibationWinForms.Dialogs
MessageBoxVerboseLoggingWarning.ShowIfTrue();
}
config.ShowImportedStats = showImportedStatsCb.Checked;
config.ImportEpisodes = importEpisodesCb.Checked;
config.DownloadEpisodes = downloadEpisodesCb.Checked;
config.AllowLibationFixup = allowLibationFixupCbox.Checked;

View File

@@ -334,7 +334,8 @@ namespace LibationWinForms
var totalProcessed = dialog.TotalBooksProcessed;
var newAdded = dialog.NewBooksAdded;
MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}");
if (Configuration.Instance.ShowImportedStats)
MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}");
}
#endregion

View File

@@ -18,7 +18,7 @@ namespace LibationWinForms
/// </summary>
internal class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
{
#region implementation properties
#region implementation properties NOT exposed to the view
// hide from public fields from Data Source GUI with [Browsable(false)]
[Browsable(false)]
@@ -28,10 +28,52 @@ namespace LibationWinForms
#endregion
#region Model properties exposed to the view
private Image _cover;
public Image Cover
{
get => _cover;
private set
{
_cover = value;
NotifyPropertyChanged();
}
}
public string ProductRating { get; }
public string PurchaseDate { get; }
public string MyRating { get; }
public string Series { get; }
public string Title { get; }
public string Length { get; }
public string Authors { get; }
public string Narrators { get; }
public string Category { get; }
public string Misc { get; }
public string Description { get; }
public string DisplayTags
{
get => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
set => Book.UserDefinedItem.Tags = value;
}
// these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint
public (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) Liberate
{
get => (LibraryCommands.Liberated_Status(LibraryBook.Book), LibraryCommands.Pdf_Status(LibraryBook.Book));
set
{
LibraryBook.Book.UserDefinedItem.BookStatus = value.BookStatus;
LibraryBook.Book.UserDefinedItem.PdfStatus = value.PdfStatus;
}
}
#endregion
public event EventHandler Committed;
private Book Book => LibraryBook.Book;
private Image _cover;
public GridEntry(LibraryBook libraryBook)
{
@@ -145,53 +187,13 @@ namespace LibationWinForms
Committed?.Invoke(this, null);
}
#endregion
#region Model properties exposed to the view
public Image Cover
{
get
{
return _cover;
}
private set
{
_cover = value;
NotifyPropertyChanged();
}
}
public string ProductRating { get; }
public string PurchaseDate { get; }
public string MyRating { get; }
public string Series { get; }
public string Title { get; }
public string Length { get; }
public string Authors { get; }
public string Narrators { get; }
public string Category { get; }
public string Misc { get; }
public string Description { get; }
public string DisplayTags
{
get => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
set => Book.UserDefinedItem.Tags = value;
}
// these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint
public (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) Liberate
{
get => (LibraryCommands.Liberated_Status(LibraryBook.Book), LibraryCommands.Pdf_Status(LibraryBook.Book));
set
{
LibraryBook.Book.UserDefinedItem.BookStatus = value.BookStatus;
LibraryBook.Book.UserDefinedItem.PdfStatus = value.PdfStatus;
}
}
#endregion
#region Data Sorting
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by Dinah.Core.DataBinding.SortableBindingList<T> for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public virtual IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
private Dictionary<string, Func<object>> _memberValues { get; }
@@ -225,9 +227,6 @@ namespace LibationWinForms
{ typeof(LiberatedStatus), new ObjectComparer<LiberatedStatus>() },
};
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public virtual IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
private static readonly string[] _sortPrefixIgnores = { "the", "a", "an" };
private static string GetSortName(string unformattedName)
{

View File

@@ -3,12 +3,11 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net5.0-windows</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>libation.ico</ApplicationIcon>
<AssemblyName>Libation</AssemblyName>
<PublishTrimmed>true</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -29,7 +28,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="2.1.1.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="4.0.4.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -71,7 +71,7 @@ namespace LibationWinForms
}
}
private async Task Liberate_Click(GridEntry liveGridEntry)
private static async Task Liberate_Click(GridEntry liveGridEntry)
{
var libraryBook = liveGridEntry.LibraryBook;
@@ -91,7 +91,7 @@ namespace LibationWinForms
await BookLiberation.ProcessorAutomationController.BackupSingleBookAsync(libraryBook);
}
private void Details_Click(GridEntry liveGridEntry)
private static void Details_Click(GridEntry liveGridEntry)
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() != DialogResult.OK)

View File

@@ -30,9 +30,7 @@ namespace LibationWinForms
//// Only use while debugging. Acts erratically in the wild
//AllocConsole();
Application.SetHighDpiMode(HighDpiMode.SystemAware);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
ApplicationConfiguration.Initialize();
//***********************************************//
// //
@@ -140,9 +138,10 @@ namespace LibationWinForms
}
}
// INIT DEFAULT SETTINGS
// if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog
config.Books ??= Path.Combine(defaultLibationFilesDir, "Books");
config.InProgress ??= Configuration.WinTemp;
AppScaffolding.LibationScaffolding.PopulateMissingConfigValues(config);
if (new SettingsDialog().ShowDialog() != DialogResult.OK)
{

View File

@@ -12,6 +12,8 @@
- [The bad](#the-bad)
- [The ugly](#the-ugly)
2. [Getting started](#getting-started)
- [Download Libation](#download-libation-1)
- [Installation](#installation)
- [Create Accounts](#create-accounts)
- [Import your library](#import-your-library)
- [Download your books -- DRM-free!](#download-your-books----drm-free)
@@ -64,7 +66,11 @@ I made this for myself and I want to share it with the great programming and aud
## Getting started
#### [Download Libation](https://github.com/rmcrackan/Libation/releases)
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
### Installation
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
### Create Accounts
@@ -86,6 +92,26 @@ Or if you have multiple accounts, you'll get to choose whether to scan all accou
![Import which accounts](images/v40_import.png)
If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.
![Login password](images/alt-login1.png)
Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.
![Login captcha](images/alt-login2.png)
Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.
![Login alternative setup](images/alt-login3.png)
You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.
![Login alternative login result](images/alt-login4.png)
This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
You'll see this window while it's scanning:
![Import step 2](images/Import2.png)
@@ -150,7 +176,6 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
* .nfo: This is just some general info about the book and includes some technical stats about the audiofile.
### Export your library
@@ -238,7 +263,7 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
### Linux and Mac
Although Libation only currently officially supports Windows, [some users](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158) have had success with WINE.
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
### Settings
@@ -288,14 +313,3 @@ export library to file
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
```
Currently logs are written to Console and to file. This means they'll be printed in the CLI. To disable, find this in Settings.json and delete the 3 lines after `"WriteTo": [`
```
"Serilog": {
"MinimumLevel": "Information",
"WriteTo": [
{
"Name": "Console"
},
```

View File

@@ -1,18 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="coverlet.collector" Version="3.1.0">
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.9" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.9" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="coverlet.collector" Version="3.1.0">
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.9" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.9" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="coverlet.collector" Version="3.1.0">
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.9" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.9" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -113,4 +113,51 @@ namespace FileUtilityTests
public void Tests(string input, string expected)
=> FileUtility.GetStandardizedExtension(input).Should().Be(expected);
}
[TestClass]
public class GetValidFilename
{
[TestMethod]
// dot-files
[DataRow(@"C:\a bc\x y z\.f i l e.txt")]
// dot-folders
[DataRow(@"C:\a bc\.x y z\f i l e.txt")]
public void Valid(string input) => Tests(input, input);
[TestMethod]
// folder spaces
[DataRow(@"C:\ a bc \x y z ", @"C:\a bc\x y z")]
// file spaces
[DataRow(@"C:\a bc\x y z\ f i l e.txt ", @"C:\a bc\x y z\f i l e.txt")]
// eliminate beginning space and end dots and spaces
[DataRow(@"C:\a bc\ . . . x y z . . . \f i l e.txt", @"C:\a bc\. . . x y z\f i l e.txt")]
// file end dots
[DataRow(@"C:\a bc\x y z\f i l e.txt . . .", @"C:\a bc\x y z\f i l e.txt")]
public void Tests(string input, string expected)
=> FileUtility.GetValidFilename(input).Should().Be(expected);
}
[TestClass]
public class RemoveLastCharacter
{
[TestMethod]
public void is_null() => Tests(null, null);
[TestMethod]
public void empty() => Tests("", "");
[TestMethod]
public void single_space() => Tests(" ", "");
[TestMethod]
public void multiple_space() => Tests(" ", " ");
[TestMethod]
[DataRow("1", "")]
[DataRow("1 ", "1")]
[DataRow("12", "1")]
[DataRow("123", "12")]
public void Tests(string input, string expected)
=> FileUtility.RemoveLastCharacter(input).Should().Be(expected);
}
}

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="coverlet.collector" Version="3.1.0">
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.9" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.9" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -13,7 +13,7 @@ namespace TemplatesTests
{
public static class Shared
{
public static LibraryBookDto GetLibraryBook(string asin)
public static LibraryBookDto GetLibraryBook(string asin, string seriesName = "Sherlock Holmes")
=> new()
{
Account = "my account",
@@ -22,7 +22,7 @@ namespace TemplatesTests
Locale = "us",
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesName = seriesName ?? "",
SeriesNumber = "1"
};
}
@@ -75,6 +75,24 @@ namespace TemplatesTests
=> Templates.getFileNamingTemplate(GetLibraryBook(asin), template, dirFullPath, extension)
.GetFilePath()
.Should().Be(expected);
[TestMethod]
public void IfSeries_empty()
=> Templates.getFileNamingTemplate(GetLibraryBook("asin", "Sherlock Holmes"), "foo<if series-><-if series>bar", @"C:\a\b", "ext")
.GetFilePath()
.Should().Be(@"C:\a\b\foobar.ext");
[TestMethod]
public void IfSeries_no_series()
=> Templates.getFileNamingTemplate(GetLibraryBook("asin", ""), "foo<if series->-<series>-<id>-<-if series>bar", @"C:\a\b", "ext")
.GetFilePath()
.Should().Be(@"C:\a\b\foobar.ext");
[TestMethod]
public void IfSeries_with_series()
=> Templates.getFileNamingTemplate(GetLibraryBook("asin", "Sherlock Holmes"), "foo<if series->-<series>-<id>-<-if series>bar", @"C:\a\b", "ext")
.GetFilePath()
.Should().Be(@"C:\a\b\foo-Sherlock Holmes-asin-bar.ext");
}
}

View File

@@ -1,18 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="coverlet.collector" Version="3.1.0">
<PackageReference Include="FluentAssertions" Version="6.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.17.2" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.9" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.9" />
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

BIN
images/alt-login1.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
images/alt-login2.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
images/alt-login3.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
images/alt-login4.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB