Compare commits

...

27 Commits

Author SHA1 Message Date
Robert McRackan
65dc273e12 update release notes 2019-12-06 10:23:05 -05:00
Robert McRackan
7bb4853903 Clicking Liberate button on a liberated item navigates to that audio file 2019-12-06 09:53:07 -05:00
Robert McRackan
f9917d4064 edit comments 2019-12-05 16:14:39 -05:00
Robert McRackan
0f9f0d9eae New feature: liberate individual book 2019-12-05 15:55:46 -05:00
Robert McRackan
498aeaac3a Change "Download Status" column to "Liberate" button column. Displays text status. No functionality added yet 2019-12-05 12:39:38 -05:00
Robert McRackan
9534969c2d Series should sort irrespective of initial the/a/an (like Title already does) 2019-12-04 13:32:25 -05:00
Robert McRackan
b120bb8a66 Replace custom FileLogger with Serilog 2019-12-04 09:58:31 -05:00
Robert McRackan
f8a51f0882 Upgrade to Core 3.1 2019-12-03 16:47:53 -05:00
Robert McRackan
7529fdf878 Add logging 2019-12-02 15:14:19 -05:00
Robert McRackan
f1aacd92ad Bugfix: decrypt file conflict 2019-12-02 14:39:46 -05:00
Robert McRackan
b1b426427c Bugfix: initial bottom counts can throw error when a book was moved since Libation was last run 2019-11-27 16:57:35 -05:00
Robert McRackan
0683e5f55b Optimize tag persistence 2019-11-27 15:36:34 -05:00
Robert McRackan
5c81441f83 Bugfix: decrypt book with no author 2019-11-27 09:27:43 -05:00
Robert McRackan
57bc74cd23 Improved logging for file decrypt 2019-11-26 13:13:16 -05:00
Robert McRackan
1cecd4ba2e Improved logging. Updated nuget packages 2019-11-26 10:42:38 -05:00
Robert McRackan
7a4bd639fb Add notes for v3.1-beta.5 2019-11-25 14:12:07 -05:00
Robert McRackan
87e6a46808 remove db file 2019-11-25 14:09:53 -05:00
Robert McRackan
a2e30df51f Improved importing 2019-11-25 13:45:29 -05:00
Robert McRackan
c8e759c067 update notes 2019-11-24 21:47:03 -05:00
Robert McRackan
6c9074169a Added beta-specific logging 2019-11-24 21:45:35 -05:00
Robert McRackan
1375da2065 Improved performance calculating "liberated" status 2019-11-21 23:07:06 -05:00
Robert McRackan
d5d72a13f6 Login dialogs can get lost. Show on task bar 2019-11-20 13:00:13 -05:00
Robert McRackan
a1ba324166 Has PDFs => Has PDF 2019-11-19 13:34:38 -05:00
Robert McRackan
b0139c47be live update newly downloaded and cached images 2019-11-19 11:22:41 -05:00
Robert McRackan
80b0ef600d Better ToString for DataLayer objects 2019-11-19 09:54:42 -05:00
Robert McRackan
f3128b562d Fix performance issues, esp regarding saving tags 2019-11-18 14:37:17 -05:00
Robert McRackan
6734dec55c remove TODO from git 2019-11-16 21:30:52 -05:00
81 changed files with 1740 additions and 1877 deletions

5
.gitignore vendored
View File

@@ -328,3 +328,8 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# manually ignored files
/__TODO.txt
/DataLayer/LibationContext.db

View File

@@ -72,8 +72,9 @@ namespace AaxDecrypter
}
private AaxToM4bConverter(string inputFile, string decryptKey)
{
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentNullException(nameof(inputFile), "Input file may not be null or whitespace");
if (!File.Exists(inputFile)) throw new ArgumentNullException(nameof(inputFile), "File does not exist");
ArgumentValidator.EnsureNotNullOrWhiteSpace(inputFile, nameof(inputFile));
if (!File.Exists(inputFile))
throw new ArgumentNullException(nameof(inputFile), "File does not exist");
steps = new StepSequence
{
@@ -89,22 +90,24 @@ namespace AaxDecrypter
["End: Create Nfo"] = End_CreateNfo
};
this.inputFileName = inputFile;
inputFileName = inputFile;
this.decryptKey = decryptKey;
}
private async Task prelimProcessing()
{
this.tags = new Tags(this.inputFileName);
this.encodingInfo = new EncodingInfo(this.inputFileName);
this.chapters = new Chapters(this.inputFileName, this.tags.duration.TotalSeconds);
tags = new Tags(inputFileName);
encodingInfo = new EncodingInfo(inputFileName);
chapters = new Chapters(inputFileName, tags.duration.TotalSeconds);
var defaultFilename = Path.Combine(
Path.GetDirectoryName(this.inputFileName),
getASCIITag(this.tags.author),
getASCIITag(this.tags.title) + ".m4b"
Path.GetDirectoryName(inputFileName),
getASCIITag(tags.author),
getASCIITag(tags.title) + ".m4b"
);
SetOutputFilename(defaultFilename);
// set default name
SetOutputFilename(defaultFilename);
await Task.Run(() => saveCover(inputFileName));
}
@@ -118,7 +121,7 @@ namespace AaxDecrypter
private void saveCover(string aaxFile)
{
using var file = TagLib.File.Create(aaxFile, "audio/mp4", TagLib.ReadStyle.Average);
this.coverBytes = file.Tag.Pictures[0].Data.Data;
coverBytes = file.Tag.Pictures[0].Data.Data;
}
private void printPrelim()
@@ -156,21 +159,24 @@ namespace AaxDecrypter
public void SetOutputFilename(string outFileName)
{
this.outputFileName = outFileName;
outputFileName = outFileName;
if (Path.GetExtension(this.outputFileName) != ".m4b")
this.outputFileName = outputFileWithNewExt(".m4b");
if (Path.GetExtension(outputFileName) != ".m4b")
outputFileName = outputFileWithNewExt(".m4b");
this.outDir = Path.GetDirectoryName(this.outputFileName);
if (File.Exists(outputFileName))
File.Delete(outputFileName);
outDir = Path.GetDirectoryName(outputFileName);
}
private string outputFileWithNewExt(string extension)
=> Path.Combine(this.outDir, Path.GetFileNameWithoutExtension(this.outputFileName) + '.' + extension.Trim('.'));
=> Path.Combine(outDir, Path.GetFileNameWithoutExtension(outputFileName) + '.' + extension.Trim('.'));
public bool Step1_CreateDir()
{
ProcessRunner.WorkingDir = this.outDir;
Directory.CreateDirectory(this.outDir);
ProcessRunner.WorkingDir = outDir;
Directory.CreateDirectory(outDir);
return true;
}
@@ -178,7 +184,7 @@ namespace AaxDecrypter
{
DecryptProgressUpdate?.Invoke(this, 0);
var tempRipFile = Path.Combine(this.outDir, "funny.aac");
var tempRipFile = Path.Combine(outDir, "funny.aac");
var fail = "WARNING-Decrypt failure. ";
@@ -193,7 +199,7 @@ namespace AaxDecrypter
if (returnCode == -99)
{
Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}");
this.decryptKey = null;
decryptKey = null;
returnCode = getKey_decrypt(tempRipFile);
}
}
@@ -232,7 +238,7 @@ namespace AaxDecrypter
Console.WriteLine("Cracking activation bytes");
var activation_bytes = BytesCracker.GetActivationBytes(checksum);
this.decryptKey = activation_bytes;
decryptKey = activation_bytes;
Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes);
}
@@ -243,10 +249,10 @@ namespace AaxDecrypter
Console.WriteLine("Decrypting with key " + decryptKey);
var returnCode = 100;
var thread = new Thread(() => returnCode = this.ngDecrypt());
var thread = new Thread(() => returnCode = ngDecrypt());
thread.Start();
double fileLen = new FileInfo(this.inputFileName).Length;
double fileLen = new FileInfo(inputFileName).Length;
while (thread.IsAlive && returnCode == 100)
{
Thread.Sleep(500);
@@ -266,7 +272,7 @@ namespace AaxDecrypter
var info = new ProcessStartInfo
{
FileName = DecryptSupportLibraries.mp4trackdumpPath,
Arguments = "-c " + this.encodingInfo.channels + " -r " + this.encodingInfo.sampleRate + " \"" + this.inputFileName + "\""
Arguments = "-c " + encodingInfo.channels + " -r " + encodingInfo.sampleRate + " \"" + inputFileName + "\""
};
info.EnvironmentVariables["VARIABLE"] = decryptKey;
@@ -279,21 +285,22 @@ namespace AaxDecrypter
return exitCode;
}
// temp file names for steps 3, 4, 5
string tempChapsPath => Path.Combine(this.outDir, "tempChaps.mp4");
// temp file names for steps 3, 4, 5
string tempChapsGuid { get; } = Guid.NewGuid().ToString().ToUpper().Replace("-", "");
string tempChapsPath => Path.Combine(outDir, $"tempChaps_{tempChapsGuid}.mp4");
string mp4_file => outputFileWithNewExt(".mp4");
string ff_txt_file => mp4_file + ".ff.txt";
public bool Step3_Chapterize()
{
string str1 = "";
if (this.chapters.FirstChapterStart != 0.0)
if (chapters.FirstChapterStart != 0.0)
{
str1 = " -ss " + this.chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (this.chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
str1 = " -ss " + chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
}
string ffmpegTags = this.tags.GenerateFfmpegTags();
string ffmpegChapters = this.chapters.GenerateFfmpegChapters();
string ffmpegTags = tags.GenerateFfmpegTags();
string ffmpegChapters = chapters.GenerateFfmpegChapters();
File.WriteAllText(ff_txt_file, ffmpegTags + ffmpegChapters);
var tagAndChapterInfo = new ProcessStartInfo
@@ -309,8 +316,8 @@ namespace AaxDecrypter
public bool Step4_InsertCoverArt()
{
// save cover image as temp file
var coverPath = Path.Combine(this.outDir, "cover-" + Guid.NewGuid() + ".jpg");
FileExt.CreateFile(coverPath, this.coverBytes);
var coverPath = Path.Combine(outDir, "cover-" + Guid.NewGuid() + ".jpg");
FileExt.CreateFile(coverPath, coverBytes);
var insertCoverArtInfo = new ProcessStartInfo
{
@@ -329,26 +336,26 @@ namespace AaxDecrypter
{
FileExt.SafeDelete(mp4_file);
FileExt.SafeDelete(ff_txt_file);
FileExt.SafeMove(tempChapsPath, this.outputFileName);
FileExt.SafeMove(tempChapsPath, outputFileName);
return true;
}
public bool Step6_AddTags()
{
this.tags.AddAppleTags(this.outputFileName);
tags.AddAppleTags(outputFileName);
return true;
}
public bool End_CreateCue()
{
File.WriteAllText(outputFileWithNewExt(".cue"), this.chapters.GetCuefromChapters(Path.GetFileName(this.outputFileName)));
File.WriteAllText(outputFileWithNewExt(".cue"), chapters.GetCuefromChapters(Path.GetFileName(outputFileName)));
return true;
}
public bool End_CreateNfo()
{
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, this.tags, this.encodingInfo, this.chapters));
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, tags, encodingInfo, chapters));
return true;
}
}

View File

@@ -28,7 +28,7 @@ namespace AaxDecrypter
using TagLib.File tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
this.title = tagLibFile.Tag.Title.Replace(" (Unabridged)", "");
this.album = tagLibFile.Tag.Album.Replace(" (Unabridged)", "");
this.author = tagLibFile.Tag.FirstPerformer;
this.author = tagLibFile.Tag.FirstPerformer ?? "[unknown]";
this.year = tagLibFile.Tag.Year.ToString();
this.comments = tagLibFile.Tag.Comment;
this.duration = tagLibFile.Properties.Duration;

View File

@@ -9,32 +9,49 @@ namespace ApplicationServices
{
public static class LibraryCommands
{
public static async Task<(int totalCount, int newCount)> IndexLibraryAsync(ILoginCallback callback)
public static async Task<(int totalCount, int newCount)> ImportLibraryAsync(ILoginCallback callback)
{
var audibleApiActions = new AudibleApiActions();
var items = await audibleApiActions.GetAllLibraryItemsAsync(callback);
var totalCount = items.Count;
try
{
var audibleApiActions = new AudibleApiActions();
var items = await audibleApiActions.GetAllLibraryItemsAsync(callback);
var totalCount = items.Count;
Serilog.Log.Logger.Debug($"GetAllLibraryItems: Total count {totalCount}");
var libImporter = new LibraryImporter();
var newCount = await Task.Run(() => libImporter.Import(items));
var libImporter = new LibraryImporter();
var newCount = await Task.Run(() => libImporter.Import(items));
Serilog.Log.Logger.Debug($"Import: New count {newCount}");
await Task.Run(() => SearchEngineCommands.FullReIndex());
await Task.Run(() => SearchEngineCommands.FullReIndex());
Serilog.Log.Logger.Debug("FullReIndex: success");
return (totalCount, newCount);
return (totalCount, newCount);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error importing library");
throw;
}
}
public static int IndexChangedTags(Book book)
public static int UpdateTags(this LibationContext context, Book book, string newTags)
{
// update disconnected entity
using var context = LibationContext.Create();
context.Update(book);
var qtyChanges = context.SaveChanges();
try
{
book.UserDefinedItem.Tags = newTags;
// this part is tags-specific
if (qtyChanges > 0)
SearchEngineCommands.UpdateBookTags(book);
var qtyChanges = context.SaveChanges();
return qtyChanges;
if (qtyChanges > 0)
SearchEngineCommands.UpdateBookTags(book);
return qtyChanges;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error updating tags");
throw;
}
}
}
}

View File

@@ -1,5 +1,4 @@
using System.Threading.Tasks;
using DataLayer;
using DataLayer;
using LibationSearchEngine;
namespace ApplicationServices

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netcoreapp3.0;netstandard2.1</TargetFrameworks>
<TargetFrameworks>netcoreapp3.1;netstandard2.1</TargetFrameworks>
</PropertyGroup>
<PropertyGroup>
@@ -12,13 +12,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -3,54 +3,50 @@ using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20191115193402_Fresh")]
[Migration("20191125182309_Fresh")]
partial class Fresh
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasAnnotation("ProductVersion", "3.0.0");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("datetime2");
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("bit");
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<string>("PictureId")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -64,16 +60,16 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("tinyint");
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
@@ -88,17 +84,16 @@ namespace DataLayer.Migrations
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
@@ -121,29 +116,35 @@ namespace DataLayer.Migrations
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleAuthorId")
.HasColumnType("nvarchar(max)");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -154,14 +155,13 @@ namespace DataLayer.Migrations
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("SeriesId");
@@ -173,13 +173,13 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("real");
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
@@ -201,18 +201,16 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.HasKey("BookId");
@@ -226,14 +224,13 @@ namespace DataLayer.Migrations
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
@@ -248,10 +245,10 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("BookId");
@@ -263,16 +260,16 @@ namespace DataLayer.Migrations
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");

View File

@@ -12,7 +12,7 @@ namespace DataLayer.Migrations
columns: table => new
{
CategoryId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
AudibleCategoryId = table.Column<string>(nullable: true),
Name = table.Column<string>(nullable: true),
ParentCategoryCategoryId = table.Column<int>(nullable: true)
@@ -33,9 +33,9 @@ namespace DataLayer.Migrations
columns: table => new
{
ContributorId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(nullable: true),
AudibleAuthorId = table.Column<string>(nullable: true)
AudibleContributorId = table.Column<string>(nullable: true)
},
constraints: table =>
{
@@ -47,7 +47,7 @@ namespace DataLayer.Migrations
columns: table => new
{
SeriesId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
AudibleSeriesId = table.Column<string>(nullable: true),
Name = table.Column<string>(nullable: true)
},
@@ -61,7 +61,7 @@ namespace DataLayer.Migrations
columns: table => new
{
BookId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
AudibleProductId = table.Column<string>(nullable: true),
Title = table.Column<string>(nullable: true),
Description = table.Column<string>(nullable: true),
@@ -159,7 +159,7 @@ namespace DataLayer.Migrations
columns: table => new
{
SupplementId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
BookId = table.Column<int>(nullable: false),
Url = table.Column<string>(nullable: true)
},
@@ -200,6 +200,11 @@ namespace DataLayer.Migrations
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
values: new object[] { -1, "", "", null });
migrationBuilder.InsertData(
table: "Contributors",
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
values: new object[] { -1, null, "" });
migrationBuilder.CreateIndex(
name: "IX_BookContributor_BookId",
table: "BookContributor",

View File

@@ -3,7 +3,6 @@ using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
@@ -15,40 +14,37 @@ namespace DataLayer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasAnnotation("ProductVersion", "3.0.0");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("datetime2");
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("bit");
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<string>("PictureId")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -62,16 +58,16 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("tinyint");
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
@@ -86,17 +82,16 @@ namespace DataLayer.Migrations
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
@@ -119,29 +114,35 @@ namespace DataLayer.Migrations
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleAuthorId")
.HasColumnType("nvarchar(max)");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -152,14 +153,13 @@ namespace DataLayer.Migrations
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("SeriesId");
@@ -171,13 +171,13 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("real");
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
@@ -199,18 +199,16 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.HasKey("BookId");
@@ -224,14 +222,13 @@ namespace DataLayer.Migrations
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
@@ -246,10 +243,10 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("BookId");
@@ -261,16 +258,16 @@ namespace DataLayer.Migrations
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");

View File

@@ -61,12 +61,18 @@ namespace DataLayer
string title,
string description,
int lengthInMinutes,
IEnumerable<Contributor> authors)
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
Category category)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
var productId = audibleProductId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
// assign as soon as possible. stuff below relies on this
AudibleProductId = productId;
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
// non-ef-ctor init.s
@@ -75,24 +81,17 @@ namespace DataLayer
_seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>();
// since category/id is never null, nullity means it hasn't been loaded
CategoryId = Category.GetEmpty().CategoryId;
Category = category;
// simple assigns
AudibleProductId = productId;
Title = title;
Description = description;
LengthInMinutes = lengthInMinutes;
// assigns with biz logic
ReplaceAuthors(authors);
//ReplaceNarrators(narrators);
// import previously saved tags
// do this immediately. any save occurs before reloading tags will overwrite persistent tags with new blank entries; all old persisted tags will be lost
// if refactoring, DO NOT use "ProductId" before it's assigned to. to be safe, just use "productId"
UserDefinedItem = new UserDefinedItem(this) { Tags = FileManager.TagsPersistence.GetTags(productId) };
}
ReplaceNarrators(narrators);
}
#region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded
@@ -124,16 +123,10 @@ namespace DataLayer
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
// the edge cases of doing local-loaded vs remote-only got weird. just load it
if (_contributorsLink == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add contributors");
if (_contributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load();
context.Entry(this).Collection(s => s.ContributorsLink).Load();
}
var roleContributions = getContributions(role);
var roleContributions = getContributions(role);
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
if (isIdentical)
return;
@@ -141,7 +134,8 @@ namespace DataLayer
_contributorsLink.RemoveWhere(bc => bc.Role == role);
addNewContributors(newContributors, role);
}
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
{
byte order = 0;
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
@@ -156,6 +150,18 @@ namespace DataLayer
.ToList();
#endregion
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
}
#region series
private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
@@ -187,16 +193,10 @@ namespace DataLayer
// our add() is conditional upon what's already included in the collection.
// therefore if not loaded, a trip is required. might as well just load it
if (_seriesLink == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add series");
if (_seriesLink is null)
getEntry(context).Collection(s => s.SeriesLink).Load();
context.Entry(this).Collection(s => s.SeriesLink).Load();
}
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
if (singleSeriesBook == null)
_seriesLink.Add(new SeriesBook(series, this, index));
else
@@ -207,13 +207,12 @@ namespace DataLayer
#region supplements
private HashSet<Supplement> _supplements;
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
public bool HasPdfs => Supplements.Any();
public bool HasPdf => Supplements.Any();
public void AddSupplementDownloadUrl(string url)
{
// supplements are owned by Book, so no need to Load():
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
@@ -233,19 +232,14 @@ namespace DataLayer
}
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category != null || CategoryId == Category.GetEmpty().CategoryId)
{
Category = category;
return;
}
{
// since category is never null, nullity means it hasn't been loaded
if (Category is null)
getEntry(context).Reference(s => s.Category).Load();
if (context == null)
throw new Exception("need context");
context.Entry(this).Reference(s => s.Category).Load();
Category = category;
Category = category;
}
}
public override string ToString() => $"[{AudibleProductId}] {Title}";
}
}

View File

@@ -23,5 +23,7 @@ namespace DataLayer
Role = role;
Order = order;
}
}
public override string ToString() => $"{Book} {Contributor} {Role} {Order}";
}
}

View File

@@ -18,8 +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 = "", ParentCategory = null };
public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null;
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }
@@ -48,5 +47,7 @@ namespace DataLayer
if (parentCategory != null)
ParentCategory = parentCategory;
}
}
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
}
}

View File

@@ -5,26 +5,31 @@ using Dinah.Core;
namespace DataLayer
{
public class Contributor
{
// contributors search links are just name with url-encoding. space can be + or %20
// author search link: /search?searchAuthor=Robert+Bevan
// narrator search link: /search?searchNarrator=Robert+Bevan
// can also search multiples. concat with comma before url encode
{
// Empty is a special case. use private ctor w/o validation
public static Contributor GetEmpty() => new Contributor { ContributorId = -1, Name = "" };
// id.s
// ----
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
// goes to summary page
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
// contributors search links are just name with url-encoding. space can be + or %20
// author search link: /search?searchAuthor=Robert+Bevan
// narrator search link: /search?searchNarrator=Robert+Bevan
// can also search multiples. concat with comma before url encode
internal int ContributorId { get; private set; }
// id.s
// ----
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
// goes to summary page
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
internal int ContributorId { get; private set; }
public string Name { get; private set; }
private HashSet<BookContributor> _booksLink;
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
public string AudibleContributorId { get; private set; }
private Contributor() { }
public Contributor(string name)
{
@@ -34,49 +39,13 @@ namespace DataLayer
Name = name;
}
public Contributor(string name, string audibleContributorId) : this(name)
{
// don't overwrite with null or whitespace but not an error
if (!string.IsNullOrWhiteSpace(audibleContributorId))
AudibleContributorId = audibleContributorId;
}
public string AudibleAuthorId { get; private set; }
public void UpdateAudibleAuthorId(string authorId)
{
// don't overwrite with null or whitespace but not an error
if (!string.IsNullOrWhiteSpace(authorId))
AudibleAuthorId = authorId;
}
#region // AudibleAuthorId refactor: separate author-specific info. overkill for a single optional string
///// <summary>Most authors in Audible have a unique id</summary>
//public AudibleAuthorProperty AudibleAuthorProperty { get; private set; }
//public void UpdateAuthorId(string authorId, LibationContext context = null)
//{
// if (authorId == null)
// return;
// if (AudibleAuthorProperty != null)
// {
// AudibleAuthorProperty.UpdateAudibleAuthorId(authorId);
// return;
// }
// if (context == null)
// throw new ArgumentNullException(nameof(context), "You must provide a context");
// if (context.Contributors.Find(ContributorId) == null)
// throw new InvalidOperationException("Could not update audible author id.");
// var audibleAuthorProperty = new AudibleAuthorProperty();
// audibleAuthorProperty.UpdateAudibleAuthorId(authorId);
// context.AuthorProperties.Add(audibleAuthorProperty);
//}
//public class AudibleAuthorProperty
//{
// public int ContributorId { get; private set; }
// public Contributor Contributor { get; set; }
// public string AudibleAuthorId { get; private set; }
// public void UpdateAudibleAuthorId(string authorId)
// {
// if (!string.IsNullOrWhiteSpace(authorId))
// AudibleAuthorId = authorId;
// }
//}
//// ...and create EF table config
#endregion
}
public override string ToString() => Name;
}
}

View File

@@ -17,5 +17,7 @@ namespace DataLayer
Book = book;
DateAdded = dateAdded;
}
}
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

View File

@@ -72,5 +72,7 @@ namespace DataLayer
return string.Join("\r\n", items);
}
}
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
}
}

View File

@@ -66,5 +66,7 @@ namespace DataLayer
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
_booksLink.Add(new SeriesBook(this, book, index));
}
}
public override string ToString() => Name;
}
}

View File

@@ -34,5 +34,7 @@ namespace DataLayer
if (index.HasValue)
Index = index.Value;
}
}
public override string ToString() => $"Series={Series} Book={Book}";
}
}

View File

@@ -20,5 +20,7 @@ namespace DataLayer
Book = book;
Url = url;
}
}
public override string ToString() => $"{Book} {Url.Substring(Url.Length - 4)}";
}
}

View File

@@ -13,29 +13,36 @@ namespace DataLayer
private UserDefinedItem() { }
internal UserDefinedItem(Book book)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
}
// import previously saved tags
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
}
private string _tags = "";
public string Tags
{
get => _tags;
set => _tags = sanitize(value);
}
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
}
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
private static string sanitize(string input)
{
if (string.IsNullOrWhiteSpace(input))
@@ -63,8 +70,6 @@ namespace DataLayer
return string.Join(" ", unique);
}
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#endregion
// owned: not an optional one-to-one
@@ -73,5 +78,7 @@ namespace DataLayer
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
}
public override string ToString() => $"{Book} {Rating} {Tags}";
}
}

View File

@@ -56,12 +56,16 @@ namespace DataLayer
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig());
// seeds go here. examples in scratch pad
modelBuilder
.Entity<Category>()
.HasData(Category.GetEmpty());
// seeds go here. examples in scratch pad
modelBuilder
.Entity<Category>()
.HasData(Category.GetEmpty());
modelBuilder
.Entity<Contributor>()
.HasData(Contributor.GetEmpty());
// views are now supported via "query types" (instead of "entity types"): https://docs.microsoft.com/en-us/ef/core/modeling/query-types
}
}
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
}
}
}

View File

@@ -5,13 +5,19 @@ using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public static class LibraryQueries
{
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
{
public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
=> context
.Library
.GetLibrary()
.ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
{
using var context = LibationContext.Create();
return context
.Library
//.AsNoTracking()
.AsNoTracking()
.GetLibrary()
.ToList();
}
@@ -21,7 +27,7 @@ namespace DataLayer
using var context = LibationContext.Create();
return context
.Library
//.AsNoTracking()
.AsNoTracking()
.GetLibraryBook(productId);
}

View File

@@ -9,52 +9,22 @@ namespace DataLayer
{
internal class TagPersistenceInterceptor : IDbInterceptor
{
public void Executing(DbContext context)
{
doWork__EFCore(context);
}
public void Executed(DbContext context) { }
static void doWork__EFCore(DbContext context)
{
// persist tags:
var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State.In(EntityState.Modified, EntityState.Added)).ToList();
var tagSets = modifiedEntities.Select(e => e.Entity as UserDefinedItem).Where(a => a != null).ToList();
foreach (var t in tagSets)
FileManager.TagsPersistence.Save(t.Book.AudibleProductId, t.Tags);
}
public void Executing(DbContext context)
{
var tagsCollection
= context
.ChangeTracker
.Entries()
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
.Select(e => e.Entity as UserDefinedItem)
.Where(udi => udi != null)
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
.Select(t => (t.Book.AudibleProductId, t.Tags))
.ToList();
#region // notes: working with proxies, esp EF 6
// EF 6: entities are proxied with lazy loading when collections are virtual
// EF Core: lazy loading is supported in 2.1 (there is a version of lazy loading with proxy-wrapping and a proxy-less version with DI) but not on by default and are not supported here
//static void doWork_EF6(DbContext context)
//{
// var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State == EntityState.Modified).ToList();
// var unproxiedEntities = modifiedEntities.Select(me => UnProxy(context, me.Entity)).ToList();
// // persist tags
// var tagSets = unproxiedEntities.Select(ue => ue as UserDefinedItem).Where(a => a != null).ToList();
// foreach (var t in tagSets)
// FileManager.TagsPersistence.Save(t.ProductId, t.TagsRaw);
//}
//// https://stackoverflow.com/a/25774651
//private static T UnProxy<T>(DbContext context, T proxyObject) where T : class
//{
// // alternative: https://docs.microsoft.com/en-us/ef/ef6/fundamentals/proxies
// var proxyCreationEnabled = context.Configuration.ProxyCreationEnabled;
// try
// {
// context.Configuration.ProxyCreationEnabled = false;
// return context.Entry(proxyObject).CurrentValues.ToObject() as T;
// }
// finally
// {
// context.Configuration.ProxyCreationEnabled = proxyCreationEnabled;
// }
//}
#endregion
}
FileManager.TagsPersistence.Save(tagsCollection);
}
}
}

View File

@@ -7,15 +7,22 @@ nuget
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
Microsoft.EntityFrameworkCore.Sqlite
MIGRATIONS require standard, not core
using standard instead of core. edit 3 things in csproj
1of3: pluralize xml TargetFramework tag to TargetFrameworks
2of2: TargetFrameworks from: netstandard2.1
to: netcoreapp3.0;netstandard2.1
3of3: add
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
MIGRATIONS
require core, not standard
this can be a problem b/c standard and framework can only reference standard, not core
TO USE MIGRATIONS (core and/or standard)
add to csproj
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
TO USE MIGRATIONS AS *BOTH* CORE AND STANDARD
edit csproj
pluralize this xml tag
from: TargetFramework
to: TargetFrameworks
inside of TargetFrameworks
from: netstandard2.1
to: netcoreapp3.1;netstandard2.1
run. error
SQLite Error 1: 'no such table: Blogs'.

View File

@@ -3,7 +3,10 @@
"LibationContext_sqlserver": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;",
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;",
"// on windows sqlite paths accept windows and/or unix slashes": "",
"// sqlite notes": "",
"// absolute path example": "Data Source=C:/foo/bar/sample.db",
"// relative path example": "Data Source=sample.db",
"// on windows: sqlite paths accept windows and/or unix slashes": "",
"MyTestContext": "Data Source=%DESKTOP%/sample.db"
}
}

View File

@@ -1,125 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using DataLayer;
using InternalUtilities;
namespace DtoImporterService
{
public class BookImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new BookValidator().Validate(items);
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
{
// pre-req.s
new ContributorImporter().Import(items, context);
new SeriesImporter().Import(items, context);
new CategoryImporter().Import(items, context);
// get distinct
var productIds = items.Select(i => i.ProductId).ToList();
// load db existing => .Local
loadLocal_books(productIds, context);
// upsert
var qtyNew = upsertBooks(items, context);
return qtyNew;
}
private void loadLocal_books(List<string> productIds, LibationContext context)
{
var localProductIds = context.Books.Local.Select(b => b.AudibleProductId);
var remainingProductIds = productIds
.Distinct()
.Except(localProductIds)
.ToList();
// GetBooks() eager loads Series, category, et al
if (remainingProductIds.Any())
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
}
private int upsertBooks(IEnumerable<Item> items, LibationContext context)
{
var qtyNew = 0;
foreach (var item in items)
{
var book = context.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.ProductId);
if (book is null)
{
// 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 => context.Contributors.Local.Single(c => a.Name == c.Name))
.ToList();
book = context.Books.Add(new Book(
new AudibleProductId(item.ProductId), item.Title, item.Description, item.LengthInMinutes, authors))
.Entity;
qtyNew++;
}
// if no narrators listed, author is the narrator
if (item.Narrators is null || !item.Narrators.Any())
item.Narrators = item.Authors;
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var narrators = item
.Narrators
.Select(n => context.Contributors.Local.Single(c => n.Name == c.Name))
.ToList();
// not all books have narrators. these will already be using author as narrator. don't undo this
if (narrators.Any())
book.ReplaceNarrators(narrators);
// set/update book-specific info which may have changed
book.PictureId = item.PictureId;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
book.AddSupplementDownloadUrl(item.SupplementUrl);
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
book.ReplacePublisher(publisher);
}
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
//
// this was round 1 when it was a 2 step process
//
//// update series even for existing books. these are occasionally updated
//var seriesIds = item.Series.Select(kvp => kvp.SeriesId).ToList();
//var allSeries = context.Series.Local.Where(c => seriesIds.Contains(c.AudibleSeriesId)).ToList();
//foreach (var series in allSeries)
// book.UpsertSeries(series);
// these will upsert over library-scraped series, but will not leave orphans
if (item.Series != null)
{
foreach (var seriesEntry in item.Series)
{
var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
book.UpsertSeries(series, seriesEntry.Index);
}
}
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == item.Categories.LastOrDefault().CategoryId);
if (category != null)
book.UpdateCategory(category, context);
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
}
return qtyNew;
}
}
}

View File

@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using DataLayer;
using InternalUtilities;
namespace DtoImporterService
{
public class BookImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new BookValidator().Validate(items);
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
{
// pre-req.s
new ContributorImporter().Import(items, context);
new SeriesImporter().Import(items, context);
new CategoryImporter().Import(items, context);
// get distinct
var productIds = items.Select(i => i.ProductId).ToList();
// load db existing => .Local
loadLocal_books(productIds, context);
// upsert
var qtyNew = upsertBooks(items, context);
return qtyNew;
}
private void loadLocal_books(List<string> productIds, LibationContext context)
{
var localProductIds = context.Books.Local.Select(b => b.AudibleProductId);
var remainingProductIds = productIds
.Distinct()
.Except(localProductIds)
.ToList();
// GetBooks() eager loads Series, category, et al
if (remainingProductIds.Any())
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
}
private int upsertBooks(IEnumerable<Item> items, LibationContext context)
{
var qtyNew = 0;
foreach (var item in items)
{
var book = context.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.ProductId);
if (book is null)
{
book = createNewBook(item, context);
qtyNew++;
}
updateBook(item, book, context);
}
return qtyNew;
}
private static Book createNewBook(Item item, LibationContext context)
{
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
item.Authors = new[] { new Person { Name = "", Asin = null } };
// 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 => context.Contributors.Local.Single(c => a.Name == c.Name))
.ToList();
var narrators
= item.Narrators is null || !item.Narrators.Any()
// if no narrators listed, author is the narrator
? authors
// 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 => context.Contributors.Local.Single(c => n.Name == c.Name))
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is very rare, but possible
var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
var book = context.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.Title,
item.Description,
item.LengthInMinutes,
authors,
narrators,
category)
).Entity;
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
book.ReplacePublisher(publisher);
}
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
book.AddSupplementDownloadUrl(item.SupplementUrl);
return book;
}
private static void updateBook(Item item, Book book, LibationContext context)
{
// set/update book-specific info which may have changed
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
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
// update series even for existing books. these are occasionally updated
// these will upsert over library-scraped series, but will not leave orphans
if (item.Series != null)
{
foreach (var seriesEntry in item.Series)
{
var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
book.UpsertSeries(series, seriesEntry.Index);
}
}
}
}
}

View File

@@ -33,8 +33,11 @@ namespace DtoImporterService
.Except(localIds)
.ToList();
// load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingCategoryIds.Any())
context.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId)).ToList();
context.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId) || c.Name == emptyName).ToList();
}
// only use after loading contributors => local

View File

@@ -19,10 +19,10 @@ namespace DtoImporterService
var publishers = items.GetPublishersDistinct().ToList();
// load db existing => .Local
var allNames = authors
.Select(a => a.Name)
var allNames = publishers
.Union(authors.Select(n => n.Name))
.Union(narrators.Select(n => n.Name))
.Union(publishers)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList();
loadLocal_contributors(allNames, context);
@@ -36,9 +36,6 @@ namespace DtoImporterService
private void loadLocal_contributors(List<string> contributorNames, LibationContext context)
{
contributorNames.Remove(null);
contributorNames.Remove("");
//// BAD: very inefficient
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
@@ -50,11 +47,10 @@ namespace DtoImporterService
.ToList();
// load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingContribNames.Any())
context.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
// _________________________________^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// i tried to extract this pattern, but this part prohibits doing so
// wouldn't work anyway for Books.GetBooks()
context.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList();
}
// only use after loading contributors => local
@@ -67,11 +63,9 @@ namespace DtoImporterService
var person = context.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
if (person == null)
{
person = context.Contributors.Add(new Contributor(p.Name)).Entity;
person = context.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
qtyNew++;
}
person.UpdateAudibleAuthorId(p.Asin);
}
return qtyNew;

View File

@@ -20,12 +20,28 @@ namespace DtoImporterService
}
}
var exceptions = Validate(param);
if (exceptions != null && exceptions.Any())
throw new AggregateException($"Device Jobs Service configuration validation failed", exceptions);
try
{
var exceptions = Validate(param);
if (exceptions != null && exceptions.Any())
throw new AggregateException($"Importer validation failed", exceptions);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Import error: validation");
throw;
}
var result = func(param, context);
return result;
try
{
var result = func(param, context);
return result;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Import error: post-validation importing");
throw;
}
}
IEnumerable<Exception> Validate(T param);
}

View File

@@ -17,17 +17,17 @@ namespace DtoImporterService
var series = items.GetSeriesDistinct().ToList();
// load db existing => .Local
var seriesIds = series.Select(s => s.SeriesId).ToList();
loadLocal_series(seriesIds, context);
loadLocal_series(series, context);
// upsert
var qtyNew = upsertSeries(series, context);
return qtyNew;
}
private void loadLocal_series(List<string> seriesIds, LibationContext context)
private void loadLocal_series(List<AudibleApiDTOs.Series> series, LibationContext context)
{
var localIds = context.Series.Local.Select(s => s.AudibleSeriesId);
var seriesIds = series.Select(s => s.SeriesId).ToList();
var localIds = context.Series.Local.Select(s => s.AudibleSeriesId).ToList();
var remainingSeriesIds = seriesIds
.Distinct()
.Except(localIds)

View File

@@ -17,28 +17,22 @@ namespace FileLiberator
/// </summary>
public class BackupBook : IProcessable
{
public event EventHandler<string> Begin;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<string> Completed;
public event EventHandler<LibraryBook> Completed;
public DownloadBook DownloadBook { get; } = new DownloadBook();
public DecryptBook DecryptBook { get; } = new DecryptBook();
public DownloadPdf DownloadPdf { get; } = new DownloadPdf();
// ValidateAsync() doesn't need UI context
public async Task<bool> ValidateAsync(LibraryBook libraryBook)
=> await validateAsync_ConfigureAwaitFalse(libraryBook.Book.AudibleProductId).ConfigureAwait(false);
private async Task<bool> validateAsync_ConfigureAwaitFalse(string productId)
=> !await AudibleFileStorage.Audio.ExistsAsync(productId);
public bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
// do NOT use ConfigureAwait(false) on ProcessAsync()
// often calls events which prints to forms in the UI context
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
var productId = libraryBook.Book.AudibleProductId;
var displayMessage = $"[{productId}] {libraryBook.Book.Title}";
Begin?.Invoke(this, displayMessage);
Begin?.Invoke(this, libraryBook);
try
{
@@ -64,7 +58,7 @@ namespace FileLiberator
}
finally
{
Completed?.Invoke(this, displayMessage);
Completed?.Invoke(this, libraryBook);
}
}
}

View File

@@ -21,7 +21,7 @@ namespace FileLiberator
/// </summary>
public class DecryptBook : IDecryptable
{
public event EventHandler<string> Begin;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<string> DecryptBegin;
@@ -32,32 +32,27 @@ namespace FileLiberator
public event EventHandler<int> UpdateProgress;
public event EventHandler<string> DecryptCompleted;
public event EventHandler<string> Completed;
public event EventHandler<LibraryBook> Completed;
// ValidateAsync() doesn't need UI context
public async Task<bool> ValidateAsync(LibraryBook libraryBook)
=> await validateAsync_ConfigureAwaitFalse(libraryBook.Book.AudibleProductId).ConfigureAwait(false);
private async Task<bool> validateAsync_ConfigureAwaitFalse(string productId)
=> await AudibleFileStorage.AAX.ExistsAsync(productId)
&& !await AudibleFileStorage.Audio.ExistsAsync(productId);
public bool Validate(LibraryBook libraryBook)
=> AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
&& !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
// do NOT use ConfigureAwait(false) on ProcessAsync()
// often calls events which prints to forms in the UI context
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
var displayMessage = $"[{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}";
Begin?.Invoke(this, displayMessage);
Begin?.Invoke(this, libraryBook);
try
{
var aaxFilename = await AudibleFileStorage.AAX.GetAsync(libraryBook.Book.AudibleProductId);
var aaxFilename = AudibleFileStorage.AAX.GetPath(libraryBook.Book.AudibleProductId);
if (aaxFilename == null)
return new StatusHandler { "aaxFilename parameter is null" };
if (!FileUtility.FileExists(aaxFilename))
return new StatusHandler { $"Cannot find AAX file: {aaxFilename}" };
if (await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId))
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
var proposedOutputFile = Path.Combine(AudibleFileStorage.DecryptInProgress, $"[{libraryBook.Book.AudibleProductId}].m4b");
@@ -72,14 +67,14 @@ namespace FileLiberator
Dinah.Core.IO.FileExt.SafeDelete(aaxFilename);
var statusHandler = new StatusHandler();
var finalAudioExists = await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId);
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
if (!finalAudioExists)
statusHandler.AddError("Cannot find final audio file after decryption");
return statusHandler;
}
finally
{
Completed?.Invoke(this, displayMessage);
Completed?.Invoke(this, libraryBook);
}
}
@@ -97,17 +92,16 @@ namespace FileLiberator
NarratorsDiscovered?.Invoke(this, converter.tags.narrator);
CoverImageFilepathDiscovered?.Invoke(this, converter.coverBytes);
// override default which was set in CreateAsync
converter.SetOutputFilename(proposedOutputFile);
converter.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
// REAL WORK DONE HERE
var success = await Task.Run(() => converter.Run());
// decrypt failed
if (!success)
{
Console.WriteLine("decrypt failed");
return null;
}
Configuration.Instance.DecryptKey = converter.decryptKey;
@@ -120,46 +114,58 @@ namespace FileLiberator
}
private static void moveFilesToBooksDir(Book product, string outputAudioFilename)
{
// files are: temp path\author\[asin].ext
var m4bDir = new FileInfo(outputAudioFilename).Directory;
var files = m4bDir
.EnumerateFiles()
.Where(f => f.Name.ContainsInsensitive(product.AudibleProductId))
.ToList();
{
// create final directory. move each file into it. MOVE AUDIO FILE LAST
// new dir: safetitle_limit50char + " [" + productId + "]"
// create final directory. move each file into it. MOVE AUDIO FILE LAST
// new dir: safetitle_limit50char + " [" + productId + "]"
var destinationDir = getDestDir(product);
Directory.CreateDirectory(destinationDir);
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
var underscoreIndex = product.Title.IndexOf(':');
var titleDir = (underscoreIndex < 4) ? product.Title : product.Title.Substring(0, underscoreIndex);
var finalDir = FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, product.AudibleProductId);
Directory.CreateDirectory(finalDir);
var sortedFiles = getProductFilesSorted(product, outputAudioFilename);
// move audio files to the end of the collection so these files are moved last
var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f));
files = files
.Except(musicFiles)
.Concat(musicFiles)
.ToList();
var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.');
var musicFileExt = musicFiles
.Select(f => f.Extension)
.Distinct()
.Single()
.Trim('.');
foreach (var f in sortedFiles)
{
var dest = AudibleFileStorage.Audio.IsFileTypeMatch(f)
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
? FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId)
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
: FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
foreach (var f in files)
{
var dest = AudibleFileStorage.Audio.IsFileTypeMatch(f)
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
? FileUtility.GetValidFilename(finalDir, product.Title, musicFileExt, product.AudibleProductId)
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
: FileUtility.GetValidFilename(finalDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
File.Move(f.FullName, dest);
}
}
File.Move(f.FullName, dest);
}
}
}
private static string getDestDir(Book product)
{
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
var underscoreIndex = product.Title.IndexOf(':');
var titleDir
= underscoreIndex < 4
? product.Title
: product.Title.Substring(0, underscoreIndex);
var finalDir = FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, product.AudibleProductId);
return finalDir;
}
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
{
// files are: temp path\author\[asin].ext
var m4bDir = new FileInfo(outputAudioFilename).Directory;
var files = m4bDir
.EnumerateFiles()
.Where(f => f.Name.ContainsInsensitive(product.AudibleProductId))
.ToList();
// move audio files to the end of the collection so these files are moved last
var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f));
var sortedFiles = files
.Except(musicFiles)
.Concat(musicFiles)
.ToList();
return sortedFiles;
}
}
}

View File

@@ -17,16 +17,16 @@ namespace FileLiberator
/// </summary>
public class DownloadBook : DownloadableBase
{
public override async Task<bool> ValidateAsync(LibraryBook libraryBook)
=> !await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId)
&& !await AudibleFileStorage.AAX.ExistsAsync(libraryBook.Book.AudibleProductId);
public override bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId)
&& !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId);
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var tempAaxFilename = getDownloadPath(libraryBook);
var actualFilePath = await downloadBookAsync(libraryBook, tempAaxFilename);
moveBook(libraryBook, actualFilePath);
return await verifyDownloadAsync(libraryBook);
return verifyDownload(libraryBook);
}
private static string getDownloadPath(LibraryBook libraryBook)
@@ -44,6 +44,14 @@ namespace FileLiberator
tempAaxFilename,
(p) => api.DownloadAaxWorkaroundAsync(libraryBook.Book.AudibleProductId, tempAaxFilename, p));
// if bad file download, a 0-33 byte file will be created
System.Threading.Thread.Sleep(100);
if (new FileInfo(actualFilePath).Length < 100)
{
File.Delete(actualFilePath);
throw new Exception("Error downloading file");
}
return actualFilePath;
}
@@ -58,8 +66,8 @@ namespace FileLiberator
Invoke_StatusUpdate($"Successfully downloaded. Moved to: {newAaxFilename}");
}
private static async Task<StatusHandler> verifyDownloadAsync(LibraryBook libraryBook)
=> !await AudibleFileStorage.AAX.ExistsAsync(libraryBook.Book.AudibleProductId)
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
? new StatusHandler { "Downloaded AAX file cannot be found" }
: new StatusHandler();
}

View File

@@ -12,26 +12,26 @@ namespace FileLiberator
{
public class DownloadPdf : DownloadableBase
{
public override async Task<bool> ValidateAsync(LibraryBook libraryBook)
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !await AudibleFileStorage.PDF.ExistsAsync(libraryBook.Book.AudibleProductId);
&& !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId);
private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var proposedDownloadFilePath = await getProposedDownloadFilePathAsync(libraryBook);
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
return await verifyDownloadAsync(libraryBook);
return verifyDownload(libraryBook);
}
private static async Task<string> getProposedDownloadFilePathAsync(LibraryBook libraryBook)
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
{
// if audio file exists, get it's dir. else return base Book dir
var destinationDir =
// this is safe b/c GetDirectoryName(null) == null
Path.GetDirectoryName(await AudibleFileStorage.Audio.GetAsync(libraryBook.Book.AudibleProductId))
Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
?? AudibleFileStorage.PDF.StorageDirectory;
return Path.Combine(destinationDir, Path.GetFileName(getdownloadUrl(libraryBook)));
@@ -45,8 +45,8 @@ namespace FileLiberator
(p) => client.DownloadFileAsync(getdownloadUrl(libraryBook), proposedDownloadFilePath, p));
}
private static async Task<StatusHandler> verifyDownloadAsync(LibraryBook libraryBook)
=> !await AudibleFileStorage.PDF.ExistsAsync(libraryBook.Book.AudibleProductId)
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId)
? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler();
}

View File

@@ -8,8 +8,8 @@ namespace FileLiberator
{
public abstract class DownloadableBase : IDownloadable
{
public event EventHandler<string> Begin;
public event EventHandler<string> Completed;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<LibraryBook> Completed;
public event EventHandler<string> DownloadBegin;
public event EventHandler<DownloadProgress> DownloadProgressChanged;
@@ -18,7 +18,7 @@ namespace FileLiberator
public event EventHandler<string> StatusUpdate;
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
public abstract Task<bool> ValidateAsync(LibraryBook libraryBook);
public abstract bool Validate(LibraryBook libraryBook);
public abstract Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook);
@@ -26,9 +26,7 @@ namespace FileLiberator
// often calls events which prints to forms in the UI context
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
var displayMessage = $"[{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}";
Begin?.Invoke(this, displayMessage);
Begin?.Invoke(this, libraryBook);
try
{
@@ -36,7 +34,7 @@ namespace FileLiberator
}
finally
{
Completed?.Invoke(this, displayMessage);
Completed?.Invoke(this, libraryBook);
}
}

View File

@@ -1,11 +1,12 @@
using System;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public interface IDownloadable : IProcessable
{
event EventHandler<string> DownloadBegin;
event EventHandler<Dinah.Core.Net.Http.DownloadProgress> DownloadProgressChanged;
event EventHandler<DownloadProgress> DownloadProgressChanged;
event EventHandler<string> DownloadCompleted;
}
}

View File

@@ -7,15 +7,15 @@ namespace FileLiberator
{
public interface IProcessable
{
event EventHandler<string> Begin;
event EventHandler<LibraryBook> Begin;
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
event EventHandler<string> StatusUpdate;
event EventHandler<string> Completed;
event EventHandler<LibraryBook> Completed;
/// <returns>True == Valid</returns>
Task<bool> ValidateAsync(LibraryBook libraryBook);
/// <returns>True == Valid</returns>
bool Validate(LibraryBook libraryBook);
/// <returns>True == success</returns>
Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);

View File

@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
@@ -9,8 +10,7 @@ namespace FileLiberator
{
//
// DO NOT USE ConfigureAwait(false) WITH ProcessAsync() unless ensuring ProcessAsync() implementation is cross-thread compatible
// - ValidateAsync() doesn't need UI context. however, each class already uses ConfigureAwait(false)
// - ProcessAsync() often does a lot with forms in the UI context
// ProcessAsync() often does a lot with forms in the UI context
//
@@ -18,31 +18,54 @@ namespace FileLiberator
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns>
public static async Task<StatusHandler> ProcessFirstValidAsync(this IProcessable processable)
{
var libraryBook = await processable.GetNextValidAsync();
var libraryBook = processable.getNextValidBook();
if (libraryBook == null)
return null;
// this should never happen. check anyway. ProcessFirstValidAsync returning null is the signal that we're done. we can't let another IProcessable accidentally send this commans
var status = await processable.ProcessAsync(libraryBook);
return await processBookAsync(processable, libraryBook);
}
/// <summary>Process the first valid product. Create default context</summary>
/// <returns>Returns either the status handler from the process, or null if all books have been processed</returns>
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, string productId)
{
using var context = LibationContext.Create();
var libraryBook = context
.Library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
if (libraryBook == null)
return null;
if (!processable.Validate(libraryBook))
return new StatusHandler { "Validation failed" };
return await processBookAsync(processable, libraryBook);
}
private static async Task<StatusHandler> processBookAsync(IProcessable processable, LibraryBook libraryBook)
{
// this should never happen. check anyway. ProcessFirstValidAsync returning null is the signal that we're done. we can't let another IProcessable accidentally send this command
var status = await processable.ProcessAsync(libraryBook);
if (status == null)
throw new Exception("Processable should never return a null status");
return status;
}
public static async Task<LibraryBook> GetNextValidAsync(this IProcessable processable)
private static LibraryBook getNextValidBook(this IProcessable processable)
{
var libraryBooks = LibraryQueries.GetLibrary_Flat_NoTracking();
foreach (var libraryBook in libraryBooks)
if (await processable.ValidateAsync(libraryBook))
if (processable.Validate(libraryBook))
return libraryBook;
return null;
}
}
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
=> await processable.ValidateAsync(libraryBook)
=> processable.Validate(libraryBook)
? await processable.ProcessAsync(libraryBook)
: new StatusHandler();
}

View File

@@ -4,6 +4,10 @@
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Polly" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
</ItemGroup>

View File

@@ -2,7 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
@@ -21,23 +21,6 @@ namespace FileManager
public sealed class AudibleFileStorage : Enumeration<AudibleFileStorage>
{
#region static
// centralize filetype mappings to ensure uniqueness
private static Dictionary<string, FileType> extensionMap => new Dictionary<string, FileType>
{
[".m4b"] = FileType.Audio,
[".mp3"] = FileType.Audio,
[".aac"] = FileType.Audio,
[".mp4"] = FileType.Audio,
[".m4a"] = FileType.Audio,
[".ogg"] = FileType.Audio,
[".flac"] = FileType.Audio,
[".aax"] = FileType.AAX,
[".pdf"] = FileType.PDF,
[".zip"] = FileType.PDF,
};
public static AudibleFileStorage Audio { get; }
public static AudibleFileStorage AAX { get; }
public static AudibleFileStorage PDF { get; }
@@ -81,9 +64,9 @@ namespace FileManager
// must do this in static ctor, not w/inline properties
// static properties init before static ctor so these dir.s would still be null
Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory);
AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal);
PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory);
Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory, "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac");
AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal, "aax");
PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory, "pdf", "zip");
}
#endregion
@@ -92,9 +75,14 @@ namespace FileManager
public string StorageDirectory => DisplayName;
public IEnumerable<string> Extensions => extensionMap.Where(kvp => kvp.Value == FileType).Select(kvp => kvp.Key);
private IEnumerable<string> extensions_noDots { get; }
private string extAggr { get; }
private AudibleFileStorage(FileType fileType, string storageDirectory) : base((int)fileType, storageDirectory) { }
private AudibleFileStorage(FileType fileType, string storageDirectory, params string[] extensions) : base((int)fileType, storageDirectory)
{
extensions_noDots = extensions.Select(ext => ext.Trim('.')).ToList();
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
}
/// <summary>
/// Example for full books:
@@ -102,78 +90,30 @@ namespace FileManager
/// - a directory name has the product id and an audio file is immediately inside
/// - any audio filename contains the product id
/// </summary>
public async Task<bool> ExistsAsync(string productId)
=> (await GetAsync(productId).ConfigureAwait(false)) != null;
public bool Exists(string productId)
=> GetPath(productId) != null;
public async Task<string> GetAsync(string productId)
=> await getAsync(productId).ConfigureAwait(false);
private async Task<string> getAsync(string productId)
public string GetPath(string productId)
{
{
{
var cachedFile = FilePathCache.GetPath(productId, FileType);
if (cachedFile != null)
return cachedFile;
}
// this is how files are saved by default. check this method first
{
var diskFile_byDirName = (await Task.Run(() => getFile_checkDirName(productId)).ConfigureAwait(false));
if (diskFile_byDirName != null)
{
FilePathCache.Upsert(productId, FileType, diskFile_byDirName);
return diskFile_byDirName;
}
}
var firstOrNull =
Directory
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase));
{
var diskFile_byFileName = (await Task.Run(() => getFile_checkFileName(productId, StorageDirectory, SearchOption.AllDirectories)).ConfigureAwait(false));
if (diskFile_byFileName != null)
{
FilePathCache.Upsert(productId, FileType, diskFile_byFileName);
return diskFile_byFileName;
}
}
return null;
if (firstOrNull is null)
return null;
FilePathCache.Upsert(productId, FileType, firstOrNull);
return firstOrNull;
}
// returns audio file if there is a directory where both are true
// - the directory name contains the productId
// - the directory contains an audio file in it's top dir (not recursively)
private string getFile_checkDirName(string productId)
{
foreach (var d in Directory.EnumerateDirectories(StorageDirectory, "*.*", SearchOption.AllDirectories))
{
if (!fileHasId(d, productId))
continue;
var firstAudio = Directory
.EnumerateFiles(d, "*.*", SearchOption.TopDirectoryOnly)
.FirstOrDefault(f => IsFileTypeMatch(f));
if (firstAudio != null)
return firstAudio;
}
return null;
}
// returns audio file if there is an file where both are true
// - the file name contains the productId
// - the file is an audio type
private string getFile_checkFileName(string productId, string dir, SearchOption searchOption)
=> Directory
.EnumerateFiles(dir, "*.*", searchOption)
.FirstOrDefault(f => fileHasId(f, productId) && IsFileTypeMatch(f));
public bool IsFileTypeMatch(string filename)
=> Extensions.ContainsInsensative(Path.GetExtension(filename));
public bool IsFileTypeMatch(FileInfo fileInfo)
=> Extensions.ContainsInsensative(fileInfo.Extension);
// use GetFileName, NOT GetFileNameWithoutExtension. This tests files AND directories. if the dir has a dot in the final part of the path, it will be treated like the file extension
private static bool fileHasId(string file, string productId)
=> Path.GetFileName(file).ContainsInsensitive(productId);
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
#endregion
}
}

View File

@@ -141,10 +141,10 @@ namespace FileManager
private static string getNonDevelopmentDir(string path)
{
// examples:
// \Libation\Core2_0\bin\Debug\netcoreapp3.0
// \Libation\Core2_0\bin\Debug\netcoreapp3.1
// \Libation\StndLib\bin\Debug\netstandard2.1
// \Libation\MyWnfrm\bin\Debug
// \Libation\Core2_0\bin\Release\netcoreapp3.0
// \Libation\Core2_0\bin\Release\netcoreapp3.1
// \Libation\StndLib\bin\Release\netstandard2.1
// \Libation\MyWnfrm\bin\Release

View File

@@ -2,11 +2,12 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core.Collections.Immutable;
using Newtonsoft.Json;
namespace FileManager
{
public static class FilePathCache
public static class FilePathCache
{
internal class CacheEntry
{
@@ -15,22 +16,25 @@ namespace FileManager
public string Path { get; set; }
}
static List<CacheEntry> inMemoryCache = new List<CacheEntry>();
static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
static FilePathCache()
{
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (FileUtility.FileExists(JsonFile))
inMemoryCache = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(JsonFile));
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (FileUtility.FileExists(JsonFile))
{
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(JsonFile));
cache = new Cache<CacheEntry>(list);
}
}
public static bool Exists(string id, FileType type) => GetPath(id, type) != null;
public static string GetPath(string id, FileType type)
{
var entry = inMemoryCache.SingleOrDefault(i => i.Id == id && i.FileType == type);
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
if (entry == null)
return null;
@@ -44,51 +48,47 @@ namespace FileManager
return entry.Path;
}
private static object locker { get; } = new object();
private static void remove(CacheEntry entry)
{
lock (locker)
{
inMemoryCache.Remove(entry);
save();
}
}
{
cache.Remove(entry);
save();
}
public static void Upsert(string id, FileType type, string path)
{
if (!FileUtility.FileExists(path))
throw new FileNotFoundException("Cannot add path to cache. File not found");
lock (locker)
{
var entry = inMemoryCache.SingleOrDefault(i => i.Id == id && i.FileType == type);
if (entry != null)
entry.Path = path;
else
{
entry = new CacheEntry { Id = id, FileType = type, Path = path };
inMemoryCache.Add(entry);
}
save();
}
}
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
// ONLY call this within lock()
private static void save()
{
// create json if not exists
void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(inMemoryCache, Formatting.Indented));
try { resave(); }
catch (IOException)
{
try { resave(); }
catch (IOException)
{
Console.WriteLine("...that's not good");
throw;
}
}
if (entry is null)
cache.Add(new CacheEntry { Id = id, FileType = type, Path = path });
else
entry.Path = path;
save();
}
// cache is thread-safe and lock free. but file saving is not
private static object locker { get; } = new object();
private static void save()
{
// create json if not exists
static void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
lock (locker)
{
try { resave(); }
catch (IOException)
{
try { resave(); }
catch (IOException ex)
{
Serilog.Log.Logger.Error(ex, "Error saving FilePaths.json");
throw;
}
}
}
}
}
}

View File

@@ -38,6 +38,8 @@ namespace FileManager
timer.Elapsed += (_, __) => timerDownload();
}
public static event EventHandler<string> PictureCached;
private static Dictionary<PictureDefinition, byte[]> cache { get; } = new Dictionary<PictureDefinition, byte[]>();
public static (bool isDefault, byte[] bytes) GetPicture(PictureDefinition def)
{
@@ -86,6 +88,8 @@ namespace FileManager
var bytes = downloadBytes(def);
saveFile(def, bytes);
cache[def] = bytes;
PictureCached?.Invoke(nameof(PictureStorage), def.PictureId);
}
finally
{

View File

@@ -105,12 +105,12 @@ namespace FileManager
catch (IOException)
{
try { resave(); }
catch (IOException)
{
Console.WriteLine("...that's not good");
throw;
}
}
catch (IOException ex)
{
Serilog.Log.Logger.Error(ex, "Error saving QuickFilters.json");
throw;
}
}
}
}
}

View File

@@ -3,64 +3,59 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Polly;
using Polly.Retry;
namespace FileManager
{
/// <summary>
/// Tags must also be stored in db for search performance. Stored in json file to survive a db reset.
/// json is only read when a product is first loaded
/// json is only read when a product is first loaded into the db
/// json is only written to when tags are edited
/// json access is infrequent and one-off
/// all other reads happen against db. No volitile storage
/// </summary>
public static class TagsPersistence
{
public static string TagsFile => Path.Combine(Configuration.Instance.LibationFiles, "BookTags.json");
private static string TagsFile => Path.Combine(Configuration.Instance.LibationFiles, "BookTags.json");
private static object locker { get; } = new object();
public static void Save(string productId, string tags)
=> System.Threading.Tasks.Task.Run(() => save_fireAndForget(productId, tags));
// if failed, retry only 1 time after a wait of 100 ms
// 1st save attempt sometimes fails with
// The requested operation cannot be performed on a file with a user-mapped section open.
private static RetryPolicy policy { get; }
= Policy.Handle<Exception>()
.WaitAndRetry(new[] { TimeSpan.FromMilliseconds(100) });
private static void save_fireAndForget(string productId, string tags)
public static void Save(IEnumerable<(string productId, string tags)> tagsCollection)
{
ensureCache();
// on initial reload, there's a huge benefit to adding to cache individually then updating the file only once
foreach ((string productId, string tags) in tagsCollection)
cache[productId] = tags;
lock (locker)
{
// get all
var allDictionary = retrieve();
// update/upsert tag list
allDictionary[productId] = tags;
// re-save:
// this often fails the first time with
// The requested operation cannot be performed on a file with a user-mapped section open.
// 2nd immediate attempt failing was rare. So I added sleep. We'll see...
void resave() => File.WriteAllText(TagsFile, JsonConvert.SerializeObject(allDictionary, Formatting.Indented));
try { resave(); }
catch (IOException debugEx)
{
// 1000 was always reliable but very slow. trying other values
var waitMs = 100;
System.Threading.Thread.Sleep(waitMs);
resave();
}
}
policy.Execute(() => File.WriteAllText(TagsFile, JsonConvert.SerializeObject(cache, Formatting.Indented)));
}
private static Dictionary<string, string> cache;
public static string GetTags(string productId)
{
var dic = retrieve();
return dic.ContainsKey(productId) ? dic[productId] : null;
ensureCache();
cache.TryGetValue(productId, out string value);
return value;
}
private static Dictionary<string, string> retrieve()
{
if (!FileUtility.FileExists(TagsFile))
return new Dictionary<string, string>();
lock (locker)
return JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
}
}
private static void ensureCache()
{
if (cache is null)
lock (locker)
cache = !FileUtility.FileExists(TagsFile)
? new Dictionary<string, string>()
: JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
}
}
}

View File

@@ -4,10 +4,6 @@
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Polly" Version="7.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />

View File

@@ -27,11 +27,24 @@ namespace InternalUtilities
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS
});
// important! use this convert method
var libResult = LibraryDtoV10.FromJson(page.ToString());
var pageStr = page.ToString();
LibraryDtoV10 libResult;
try
{
// important! use this convert method
libResult = LibraryDtoV10.FromJson(pageStr);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error converting library for importing use. Full library:\r\n" + pageStr);
throw;
}
if (!libResult.Items.Any())
break;
else
Serilog.Log.Logger.Debug($"Page {i}: {libResult.Items.Length} results");
allItems.AddRange(libResult.Items);
}

View File

@@ -29,12 +29,12 @@ namespace InternalUtilities
{
var exceptions = new List<Exception>();
// a book having no authors is rare but allowed
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
if (items.Any(i => string.IsNullOrWhiteSpace(i.Title)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.Title)}", nameof(items)));
if (items.Any(i => i.Authors is null))
exceptions.Add(new ArgumentException($"Collection contains item(s) with null {nameof(Item.Authors)}", nameof(items)));
return exceptions;
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>libation.ico</ApplicationIcon>
<AssemblyName>Libation</AssemblyName>

View File

@@ -6,5 +6,5 @@
cause the file to be unrecognizable by the program.
-->
<GenericObjectDataSource DisplayName="GridEntry" Version="1.0" xmlns="urn:schemas-microsoft-com:xml-msdatasource">
<TypeInfo>WinFormsDesigner.GridEntry, LibationWinForm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
<TypeInfo>LibationWinForm.GridEntry, LibationWinForm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
</GenericObjectDataSource>

View File

@@ -6,17 +6,38 @@ namespace LibationWinForm.BookLiberation
{
public partial class AutomatedBackupsForm : Form
{
public bool KeepGoingIsChecked => keepGoingCb.Checked;
public bool KeepGoingVisible
{
get => keepGoingCb.Visible;
set => keepGoingCb.Visible = value;
}
public bool KeepGoingChecked => keepGoingCb.Checked;
public bool KeepGoing
=> keepGoingCb.Visible
&& keepGoingCb.Enabled
&& keepGoingCb.Checked;
public AutomatedBackupsForm()
{
InitializeComponent();
}
public void AppendError(Exception ex) => AppendText("ERROR: " + ex.Message);
public void AppendText(string text) => logTb.UIThread(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}"));
public void AppendError(Exception ex)
{
Serilog.Log.Logger.Error(ex, "Automated backup: error");
appendText("ERROR: " + ex.Message);
}
public void AppendText(string text)
{
Serilog.Log.Logger.Debug($"Automated backup: {text}");
appendText(text);
}
private void appendText(string text)
=> logTb.UIThread(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}"));
public void FinalizeUI()
public void FinalizeUI()
{
keepGoingCb.Enabled = false;
logTb.AppendText("");

View File

@@ -8,18 +8,19 @@ namespace LibationWinForm.BookLiberation
{
public partial class DecryptForm : Form
{
public DecryptForm()
public DecryptForm()
{
InitializeComponent();
}
System.IO.TextWriter origOut = Console.Out;
System.IO.TextWriter origOut { get; } = Console.Out;
private void DecryptForm_Load(object sender, EventArgs e)
{
// redirect Console.WriteLine to console, textbox
System.IO.TextWriter origOut = Console.Out;
var controlWriter = new RichTextBoxTextWriter(this.rtbLog);
var multiLogger = new MultiTextWriter(origOut, controlWriter);
var multiLogger = new MultiTextWriter(
origOut,
new RichTextBoxTextWriter(this.rtbLog),
new SerilogTextWriter());
Console.SetOut(multiLogger);
}
@@ -58,13 +59,6 @@ namespace LibationWinForm.BookLiberation
public void SetCoverImage(byte[] coverBytes)
=> pictureBox1.UIThread(() => pictureBox1.Image = ImageReader.ToImage(coverBytes));
public static void AppendError(Exception ex) => AppendText("ERROR: " + ex.Message);
public static void AppendText(string text) =>
// redirected to log textbox
Console.WriteLine($"{DateTime.Now} {text}")
//logTb.UIThread(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}"))
;
public void UpdateProgress(int percentage) => progressBar1.UIThread(() => progressBar1.Value = percentage);
public void UpdateProgress(int percentage) => progressBar1.UIThread(() => progressBar1.Value = percentage);
}
}

View File

@@ -1,65 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
using FileLiberator;
namespace LibationWinForm.BookLiberation
{
public class BookLiberatorControllerExamples
{
async Task BackupBookAsync(string productId)
{
using var context = LibationContext.Create();
var libraryBook = context
.Library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
if (libraryBook == null)
return;
var backupBook = new BackupBook();
backupBook.DownloadBook.Completed += SetBackupCountsAsync;
backupBook.DecryptBook.Completed += SetBackupCountsAsync;
await ProcessValidateLibraryBookAsync(backupBook, libraryBook);
}
static async Task<StatusHandler> ProcessValidateLibraryBookAsync(IProcessable processable, LibraryBook libraryBook)
{
if (!await processable.ValidateAsync(libraryBook))
return new StatusHandler { "Validation failed" };
return await processable.ProcessAsync(libraryBook);
}
// Download First Book (Download encrypted/DRM file)
async Task DownloadFirstBookAsync()
{
var downloadBook = ProcessorAutomationController.GetWiredUpDownloadBook();
downloadBook.Completed += SetBackupCountsAsync;
await downloadBook.ProcessFirstValidAsync();
}
// Decrypt First Book (Remove DRM from downloaded file)
async Task DecryptFirstBookAsync()
{
var decryptBook = ProcessorAutomationController.GetWiredUpDecryptBook();
decryptBook.Completed += SetBackupCountsAsync;
await decryptBook.ProcessFirstValidAsync();
}
// Backup First Book (Decrypt a non-liberated book. Download if needed)
async Task BackupFirstBookAsync()
{
var backupBook = ProcessorAutomationController.GetWiredUpBackupBook();
backupBook.DownloadBook.Completed += SetBackupCountsAsync;
backupBook.DecryptBook.Completed += SetBackupCountsAsync;
await backupBook.ProcessFirstValidAsync();
}
async void SetBackupCountsAsync(object obj, string str) => throw new NotImplementedException();
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
using FileLiberator;
namespace LibationWinForm.BookLiberation
@@ -12,37 +14,38 @@ namespace LibationWinForm.BookLiberation
// 1) we can't forget to do it
// 2) we can't accidentally do it mult times becaues we lost track of complexity
//
public static BackupBook GetWiredUpBackupBook()
public static BackupBook GetWiredUpBackupBook(EventHandler<LibraryBook> completedAction = null)
{
var backupBook = new BackupBook();
backupBook.DownloadBook.Begin += (_, __) => wireUpDownloadable(backupBook.DownloadBook);
backupBook.DecryptBook.Begin += (_, __) => wireUpDecryptable(backupBook.DecryptBook);
backupBook.DownloadPdf.Begin += (_, __) => wireUpDecryptable(backupBook.DecryptBook);
backupBook.DownloadBook.Begin += (_, __) => wireUpEvents(backupBook.DownloadBook);
backupBook.DecryptBook.Begin += (_, __) => wireUpEvents(backupBook.DecryptBook);
backupBook.DownloadPdf.Begin += (_, __) => wireUpEvents(backupBook.DownloadPdf);
return backupBook;
if (completedAction != null)
{
backupBook.DownloadBook.Completed += completedAction;
backupBook.DecryptBook.Completed += completedAction;
backupBook.DownloadPdf.Completed += completedAction;
}
return backupBook;
}
public static DecryptBook GetWiredUpDecryptBook()
{
var decryptBook = new DecryptBook();
decryptBook.Begin += (_, __) => wireUpDecryptable(decryptBook);
return decryptBook;
}
public static DownloadBook GetWiredUpDownloadBook()
{
var downloadBook = new DownloadBook();
downloadBook.Begin += (_, __) => wireUpDownloadable(downloadBook);
return downloadBook;
}
public static DownloadPdf GetWiredUpDownloadPdf()
public static DownloadPdf GetWiredUpDownloadPdf(EventHandler<LibraryBook> completedAction = null)
{
var downloadPdf = new DownloadPdf();
downloadPdf.Begin += (_, __) => wireUpDownloadable(downloadPdf);
downloadPdf.Begin += (_, __) => wireUpEvents(downloadPdf);
if (completedAction != null)
downloadPdf.Completed += completedAction;
return downloadPdf;
}
// subscribed to Begin event because a new form should be created+processed+closed on each iteration
private static void wireUpDownloadable(IDownloadable downloadable)
private static void wireUpEvents(IDownloadable downloadable)
{
#region create form
var downloadDialog = new DownloadForm();
@@ -82,7 +85,7 @@ namespace LibationWinForm.BookLiberation
// unless we dispose, if the form is created but un-used/never-shown then weird UI stuff can happen
// also, since event unsubscribe occurs on FormClosing and an unused form is never closed, then the events will never be unsubscribed
void dialogDispose(object _, string __)
void dialogDispose(object _, object __)
{
if (!downloadDialog.IsDisposed)
downloadDialog.Dispose();
@@ -106,7 +109,7 @@ namespace LibationWinForm.BookLiberation
}
// subscribed to Begin event because a new form should be created+processed+closed on each iteration
private static void wireUpDecryptable(IDecryptable decryptBook)
private static void wireUpEvents(IDecryptable decryptBook)
{
#region create form
var decryptDialog = new DecryptForm();
@@ -153,17 +156,23 @@ namespace LibationWinForm.BookLiberation
#endregion
}
public static async Task RunAutomaticDownload(IDownloadable downloadable)
public static async Task RunAutomaticDownloadAsync(IDownloadable downloadable)
{
AutomatedBackupsForm automatedBackupsForm = attachToBackupsForm(downloadable);
await runBackupLoopAsync(downloadable, automatedBackupsForm);
}
private static AutomatedBackupsForm attachToBackupsForm(IDownloadable downloadable)
{
#region create form
var automatedBackupsForm = new AutomatedBackupsForm();
#endregion
#region define how model actions will affect form behavior
void begin(object _, string str) => automatedBackupsForm.AppendText("Begin: " + str);
void begin(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"Begin: {libraryBook.Book}");
void statusUpdate(object _, string str) => automatedBackupsForm.AppendText("- " + str);
// extra line after book is completely finished
void completed(object _, string str) => automatedBackupsForm.AppendText("Completed: " + str + Environment.NewLine);
void completed(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"Completed: {libraryBook.Book}{Environment.NewLine}");
#endregion
#region subscribe new form to model's events
@@ -182,42 +191,55 @@ namespace LibationWinForm.BookLiberation
};
#endregion
await runBackupLoop(downloadable, automatedBackupsForm);
return automatedBackupsForm;
}
public static async Task RunAutomaticBackup(BackupBook backupBook)
public static async Task RunAutomaticBackupAsync(BackupBook backupBook)
{
var automatedBackupsForm = attachToBackupsForm(backupBook);
await runBackupLoopAsync(backupBook, automatedBackupsForm);
}
public static async Task RunSingleBackupAsync(BackupBook backupBook, string productId)
{
var automatedBackupsForm = attachToBackupsForm(backupBook);
automatedBackupsForm.KeepGoingVisible = false;
await runSingleBackupAsync(backupBook, automatedBackupsForm, productId);
}
private static AutomatedBackupsForm attachToBackupsForm(BackupBook backupBook)
{
#region create form
var automatedBackupsForm = new AutomatedBackupsForm();
#endregion
#region define how model actions will affect form behavior
void downloadBookBegin(object _, string str) => automatedBackupsForm.AppendText("DownloadStep_Begin: " + str);
void downloadBookBegin(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"Download Step, Begin: {libraryBook.Book}");
void statusUpdate(object _, string str) => automatedBackupsForm.AppendText("- " + str);
void downloadBookCompleted(object _, string str) => automatedBackupsForm.AppendText("DownloadStep_Completed: " + str);
void decryptBookBegin(object _, string str) => automatedBackupsForm.AppendText("DecryptStep_Begin: " + str);
void downloadBookCompleted(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"Download Step, Completed: {libraryBook.Book}");
void decryptBookBegin(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"Decrypt Step, Begin: {libraryBook.Book}");
// extra line after book is completely finished
void decryptBookCompleted(object _, string str) => automatedBackupsForm.AppendText("DecryptStep_Completed: " + str + Environment.NewLine);
void downloadPdfBegin(object _, string str) => automatedBackupsForm.AppendText("PdfStep_Begin: " + str);
// extra line after book is completely finished
void downloadPdfCompleted(object _, string str) => automatedBackupsForm.AppendText("PdfStep_Completed: " + str + Environment.NewLine);
#endregion
void decryptBookCompleted(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"Decrypt Step, Completed: {libraryBook.Book}{Environment.NewLine}");
void downloadPdfBegin(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"PDF Step, Begin: {libraryBook.Book}");
// extra line after book is completely finished
void downloadPdfCompleted(object _, LibraryBook libraryBook) => automatedBackupsForm.AppendText($"PDF Step, Completed: {libraryBook.Book}{Environment.NewLine}");
#endregion
#region subscribe new form to model's events
backupBook.DownloadBook.Begin += downloadBookBegin;
#region subscribe new form to model's events
backupBook.DownloadBook.Begin += downloadBookBegin;
backupBook.DownloadBook.StatusUpdate += statusUpdate;
backupBook.DownloadBook.Completed += downloadBookCompleted;
backupBook.DecryptBook.Begin += decryptBookBegin;
backupBook.DecryptBook.StatusUpdate += statusUpdate;
backupBook.DecryptBook.Completed += decryptBookCompleted;
backupBook.DownloadPdf.Begin += downloadPdfBegin;
backupBook.DownloadPdf.StatusUpdate += statusUpdate;
backupBook.DownloadPdf.Completed += downloadPdfCompleted;
#endregion
backupBook.DownloadPdf.Begin += downloadPdfBegin;
backupBook.DownloadPdf.StatusUpdate += statusUpdate;
backupBook.DownloadPdf.Completed += downloadPdfCompleted;
#endregion
#region when form closes, unsubscribe from model's events
// unsubscribe so disposed forms aren't still trying to receive notifications
automatedBackupsForm.FormClosing += (_, __) =>
#region when form closes, unsubscribe from model's events
// unsubscribe so disposed forms aren't still trying to receive notifications
automatedBackupsForm.FormClosing += (_, __) =>
{
backupBook.DownloadBook.Begin -= downloadBookBegin;
backupBook.DownloadBook.StatusUpdate -= statusUpdate;
@@ -225,48 +247,29 @@ namespace LibationWinForm.BookLiberation
backupBook.DecryptBook.Begin -= decryptBookBegin;
backupBook.DecryptBook.StatusUpdate -= statusUpdate;
backupBook.DecryptBook.Completed -= decryptBookCompleted;
backupBook.DownloadPdf.Begin -= downloadPdfBegin;
backupBook.DownloadPdf.StatusUpdate -= statusUpdate;
backupBook.DownloadPdf.Completed -= downloadPdfCompleted;
};
backupBook.DownloadPdf.Begin -= downloadPdfBegin;
backupBook.DownloadPdf.StatusUpdate -= statusUpdate;
backupBook.DownloadPdf.Completed -= downloadPdfCompleted;
};
#endregion
await runBackupLoop(backupBook, automatedBackupsForm);
return automatedBackupsForm;
}
// automated backups looper feels like a composible IProcessable: logic, UI, begin + process child + end
// however the process step doesn't follow the pattern: Validate(product) + Process(product)
private static async Task runBackupLoop(IProcessable processable, AutomatedBackupsForm automatedBackupsForm)
private static async Task runBackupLoopAsync(IProcessable processable, AutomatedBackupsForm automatedBackupsForm)
{
automatedBackupsForm.Show();
try
{
do
var shouldContinue = true;
while (shouldContinue)
{
var statusHandler = await processable.ProcessFirstValidAsync();
if (statusHandler == null)
{
automatedBackupsForm.AppendText("Done. All books have been processed");
break;
}
if (statusHandler.HasErrors)
{
automatedBackupsForm.AppendText("ERROR. All books have not been processed. Most recent valid book: processing failed");
foreach (var errorMessage in statusHandler.Errors)
automatedBackupsForm.AppendText(errorMessage);
break;
}
if (!automatedBackupsForm.KeepGoingIsChecked)
{
automatedBackupsForm.AppendText("'Keep going' is unchecked");
break;
}
shouldContinue = validateStatus(statusHandler, automatedBackupsForm);
}
while (automatedBackupsForm.KeepGoingIsChecked);
}
catch (Exception ex)
{
@@ -275,5 +278,48 @@ namespace LibationWinForm.BookLiberation
automatedBackupsForm.FinalizeUI();
}
private static async Task runSingleBackupAsync(IProcessable processable, AutomatedBackupsForm automatedBackupsForm, string productId)
{
automatedBackupsForm.Show();
try
{
var statusHandler = await processable.ProcessSingleAsync(productId);
validateStatus(statusHandler, automatedBackupsForm);
}
catch (Exception ex)
{
automatedBackupsForm.AppendError(ex);
}
automatedBackupsForm.FinalizeUI();
}
private static bool validateStatus(StatusHandler statusHandler, AutomatedBackupsForm automatedBackupsForm)
{
if (statusHandler == null)
{
automatedBackupsForm.AppendText("Done. All books have been processed");
return false;
}
if (statusHandler.HasErrors)
{
automatedBackupsForm.AppendText("ERROR. All books have not been processed. Most recent valid book: processing failed");
foreach (var errorMessage in statusHandler.Errors)
automatedBackupsForm.AppendText(errorMessage);
return false;
}
if (!automatedBackupsForm.KeepGoing)
{
if (automatedBackupsForm.KeepGoingVisible && !automatedBackupsForm.KeepGoingChecked)
automatedBackupsForm.AppendText("'Keep going' is unchecked");
return false;
}
return true;
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Windows.Forms;
using System;
using System.Windows.Forms;
using ApplicationServices;
namespace LibationWinForm
@@ -18,11 +19,12 @@ namespace LibationWinForm
{
try
{
(TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.IndexLibraryAsync(new Login.WinformResponder());
(TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportLibraryAsync(new Login.WinformResponder());
}
catch
catch (Exception ex)
{
MessageBox.Show("Error importing library. Please try again. If this happens after 2 or 3 tries, contact administrator", "Error importing library", MessageBoxButtons.OK, MessageBoxIcon.Error);
var msg = "Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator";
MessageBox.Show(msg, "Error importing library", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
this.Close();

View File

@@ -94,7 +94,6 @@
this.MinimizeBox = false;
this.Name = "AudibleLoginDialog";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Audible Login";
this.ResumeLayout(false);

View File

@@ -84,7 +84,6 @@
this.MinimizeBox = false;
this.Name = "CaptchaDialog";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "CAPTCHA";
((System.ComponentModel.ISupportInitialize)(this.captchaPb)).EndInit();

View File

@@ -74,7 +74,6 @@
this.MinimizeBox = false;
this.Name = "_2faCodeDialog";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "2FA Code";
this.ResumeLayout(false);

View File

@@ -37,7 +37,7 @@ namespace LibationWinForm
beginPdfBackupsToolStripMenuItem_format = beginPdfBackupsToolStripMenuItem.Text;
}
private async void Form1_Load(object sender, EventArgs e)
private void Form1_Load(object sender, EventArgs e)
{
// call static ctor. There are bad race conditions if static ctor is first executed when we're running in parallel in setBackupCountsAsync()
var foo = FilePathCache.JsonFile;
@@ -59,7 +59,7 @@ namespace LibationWinForm
backupsCountsLbl.Text = "[Calculating backed up book quantities]";
pdfsCountsLbl.Text = "[Calculating backed up PDFs]";
await setBackupCountsAsync();
setBackupCounts(null, null);
}
}
@@ -86,69 +86,53 @@ namespace LibationWinForm
{
gridPanel.Controls.Remove(currProductsGrid);
currProductsGrid.VisibleCountChanged -= setVisibleCount;
currProductsGrid.BackupCountsChanged -= setBackupCounts;
currProductsGrid.Dispose();
}
currProductsGrid = new ProductsGrid { Dock = DockStyle.Fill };
currProductsGrid.VisibleCountChanged += setVisibleCount;
currProductsGrid.BackupCountsChanged += setBackupCounts;
gridPanel.Controls.Add(currProductsGrid);
currProductsGrid.Display();
}
ResumeLayout();
}
#endregion
#endregion
#region bottom: qty books visible
private void setVisibleCount(object _, int qty) => visibleCountLbl.Text = string.Format(visibleCountLbl_Format, qty);
#endregion
#endregion
#region bottom: backup counts
private async Task setBackupCountsAsync()
#region bottom: backup counts
private void setBackupCounts(object _, object __)
{
var books = LibraryQueries.GetLibrary_Flat_NoTracking()
.Select(sp => sp.Book)
.ToList();
await setBookBackupCountsAsync(books).ConfigureAwait(false);
await setPdfBackupCountsAsync(books).ConfigureAwait(false);
}
setBookBackupCounts(books);
setPdfBackupCounts(books);
}
enum AudioFileState { full, aax, none }
private async Task setBookBackupCountsAsync(IEnumerable<Book> books)
private void setBookBackupCounts(IEnumerable<Book> books)
{
var libraryProductIds = books
.Select(b => b.AudibleProductId)
.ToList();
var noProgress = 0;
var downloadedOnly = 0;
var fullyBackedUp = 0;
//// serial
//foreach (var productId in libraryProductIds)
//{
// if (await AudibleFileStorage.Audio.ExistsAsync(productId))
// fullyBackedUp++;
// else if (await AudibleFileStorage.AAX.ExistsAsync(productId))
// downloadedOnly++;
// else
// noProgress++;
//}
// parallel
async Task<AudioFileState> getAudioFileStateAsync(string productId)
AudioFileState getAudioFileState(string productId)
{
if (await AudibleFileStorage.Audio.ExistsAsync(productId))
if (AudibleFileStorage.Audio.Exists(productId))
return AudioFileState.full;
if (await AudibleFileStorage.AAX.ExistsAsync(productId))
if (AudibleFileStorage.AAX.Exists(productId))
return AudioFileState.aax;
return AudioFileState.none;
}
var tasks = libraryProductIds.Select(productId => getAudioFileStateAsync(productId));
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
fullyBackedUp = results.Count(r => r == AudioFileState.full);
downloadedOnly = results.Count(r => r == AudioFileState.aax);
noProgress = results.Count(r => r == AudioFileState.none);
}
var results = books
.AsParallel()
.Select(b => getAudioFileState(b.AudibleProductId))
.ToList();
var fullyBackedUp = results.Count(r => r == AudioFileState.full);
var downloadedOnly = results.Count(r => r == AudioFileState.aax);
var noProgress = results.Count(r => r == AudioFileState.none);
// update bottom numbers
var pending = noProgress + downloadedOnly;
@@ -166,32 +150,15 @@ namespace LibationWinForm
menuStrip1.UIThread(() => beginBookBackupsToolStripMenuItem.Enabled = pending > 0);
menuStrip1.UIThread(() => beginBookBackupsToolStripMenuItem.Text = string.Format(beginBookBackupsToolStripMenuItem_format, menuItemText));
}
private async Task setPdfBackupCountsAsync(IEnumerable<Book> books)
private void setPdfBackupCounts(IEnumerable<Book> books)
{
var libraryProductIds = books
.Where(b => b.Supplements.Any())
.Select(b => b.AudibleProductId)
.ToList();
int notDownloaded;
int downloaded;
//// serial
//notDownloaded = 0;
//downloaded = 0;
//foreach (var productId in libraryProductIds)
//{
// if (await AudibleFileStorage.PDF.ExistsAsync(productId))
// downloaded++;
// else
// notDownloaded++;
//}
// parallel
var tasks = libraryProductIds.Select(productId => AudibleFileStorage.PDF.ExistsAsync(productId));
var boolResults = await Task.WhenAll(tasks).ConfigureAwait(false);
downloaded = boolResults.Count(r => r);
notDownloaded = boolResults.Count(r => !r);
var boolResults = books
.AsParallel()
.Where(b => b.Supplements.Any())
.Select(b => AudibleFileStorage.PDF.Exists(b.AudibleProductId))
.ToList();
var downloaded = boolResults.Count(r => r);
var notDownloaded = boolResults.Count(r => !r);
// update bottom numbers
var text
@@ -210,8 +177,8 @@ namespace LibationWinForm
}
#endregion
#region filter
private void filterHelpBtn_Click(object sender, EventArgs e) => new Dialogs.SearchSyntaxDialog().ShowDialog();
#region filter
private void filterHelpBtn_Click(object sender, EventArgs e) => new Dialogs.SearchSyntaxDialog().ShowDialog();
private void AddFilterBtn_Click(object sender, EventArgs e)
{
@@ -258,7 +225,7 @@ namespace LibationWinForm
#endregion
#region index menu
private async void scanLibraryToolStripMenuItem_Click(object sender, EventArgs e)
private void scanLibraryToolStripMenuItem_Click(object sender, EventArgs e)
{
using var dialog = new IndexLibraryDialog();
dialog.ShowDialog();
@@ -268,33 +235,25 @@ namespace LibationWinForm
MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}");
// update backup counts if we have new library items
if (newAdded > 0)
await setBackupCountsAsync();
if (totalProcessed > 0)
reloadGrid();
}
#endregion
#region liberate menu
private async void setBackupCountsAsync(object _, string __) => await setBackupCountsAsync();
#endregion
#region liberate menu
private async void beginBookBackupsToolStripMenuItem_Click(object sender, EventArgs e)
{
var backupBook = BookLiberation.ProcessorAutomationController.GetWiredUpBackupBook();
backupBook.DownloadBook.Completed += setBackupCountsAsync;
backupBook.DecryptBook.Completed += setBackupCountsAsync;
backupBook.DownloadPdf.Completed += setBackupCountsAsync;
await BookLiberation.ProcessorAutomationController.RunAutomaticBackup(backupBook);
var backupBook = BookLiberation.ProcessorAutomationController.GetWiredUpBackupBook(updateGridRow);
await BookLiberation.ProcessorAutomationController.RunAutomaticBackupAsync(backupBook);
}
private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e)
{
var downloadPdf = BookLiberation.ProcessorAutomationController.GetWiredUpDownloadPdf();
downloadPdf.Completed += setBackupCountsAsync;
await BookLiberation.ProcessorAutomationController.RunAutomaticDownload(downloadPdf);
var downloadPdf = BookLiberation.ProcessorAutomationController.GetWiredUpDownloadPdf(updateGridRow);
await BookLiberation.ProcessorAutomationController.RunAutomaticDownloadAsync(downloadPdf);
}
private void updateGridRow(object _, LibraryBook libraryBook) => currProductsGrid.RefreshRow(libraryBook.Book.AudibleProductId);
#endregion
#region quick filters menu

View File

@@ -26,15 +26,54 @@ namespace LibationWinForm
[Browsable(false)]
public IEnumerable<string> TagsEnumerated => book.UserDefinedItem.TagsEnumerated;
// formatReplacements is what gets displayed
[Browsable(false)]
public string Download_Status
{
get
{
var print
= FileManager.AudibleFileStorage.Audio.Exists(book.AudibleProductId) ? "Liberated"
: FileManager.AudibleFileStorage.AAX.Exists(book.AudibleProductId) ? "DRM"
: "NOT d/l'ed";
if (!book.Supplements.Any())
return print;
print += "\r\n";
var downloadStatuses = book.Supplements
.Select(d => FileManager.AudibleFileStorage.PDF.Exists(book.AudibleProductId))
// break delayed execution right now!
.ToList();
var count = downloadStatuses.Count;
if (count == 1)
{
print += downloadStatuses[0]
? "PDF d/l'ed"
: "PDF NOT d/l'ed";
}
else
{
var downloadedCount = downloadStatuses.Count(s => s);
print
+= downloadedCount == count ? $"{count} PDFs d/l'ed"
: downloadedCount == 0 ? $"{count} PDFs NOT d/l'ed"
: $"{downloadedCount} of {count} PDFs d/l'ed";
}
return print;
}
}
// displayValues is what gets displayed
// the value that gets returned from the property is the cell's value
// this allows for the value to be sorted one way and displayed another
// eg:
// orig title: The Computer
// formatReplacement: The Computer
// value for sorting: Computer
private Dictionary<string, string> formatReplacements { get; } = new Dictionary<string, string>();
public bool TryGetFormatted(string key, out string value) => formatReplacements.TryGetValue(key, out value);
private Dictionary<string, string> displayValues { get; } = new Dictionary<string, string>();
public bool TryDisplayValue(string key, out string value) => displayValues.TryGetValue(key, out value);
public Image Cover =>
WindowsDesktopUtilities.WinAudibleImageServer.GetImage(book.PictureId, FileManager.PictureSize._80x80);
@@ -43,16 +82,8 @@ namespace LibationWinForm
{
get
{
formatReplacements[nameof(Title)] = book.Title;
var sortName = book.Title
.Replace("|", "")
.Replace(":", "")
.ToLowerInvariant();
if (sortName.StartsWith("the ") || sortName.StartsWith("a ") || sortName.StartsWith("an "))
sortName = sortName.Substring(sortName.IndexOf(" ") + 1);
return sortName;
displayValues[nameof(Title)] = book.Title;
return getSortName(book.Title);
}
}
@@ -63,7 +94,7 @@ namespace LibationWinForm
{
get
{
formatReplacements[nameof(Length)]
displayValues[nameof(Length)]
= book.LengthInMinutes == 0
? ""
: $"{book.LengthInMinutes / 60} hr {book.LengthInMinutes % 60} min";
@@ -72,7 +103,29 @@ namespace LibationWinForm
}
}
public string Series => book.SeriesNames;
public string Series
{
get
{
displayValues[nameof(Series)] = book.SeriesNames;
return getSortName(book.SeriesNames);
}
}
private static string[] sortPrefixIgnores { get; } = new[] { "the", "a", "an" };
private static string getSortName(string unformattedName)
{
var sortName = unformattedName
.Replace("|", "")
.Replace(":", "")
.ToLowerInvariant()
.Trim();
if (sortPrefixIgnores.Any(prefix => sortName.StartsWith(prefix + " ")))
sortName = sortName.Substring(sortName.IndexOf(" ") + 1).TrimStart();
return sortName;
}
private string descriptionCache = null;
public string Description
@@ -111,7 +164,7 @@ namespace LibationWinForm
{
get
{
formatReplacements[nameof(Product_Rating)] = starString(book.Rating);
displayValues[nameof(Product_Rating)] = starString(book.Rating);
return firstScore(book.Rating);
}
}
@@ -120,7 +173,7 @@ namespace LibationWinForm
{
get
{
formatReplacements[nameof(Purchase_Date)] = libraryBook.DateAdded.ToString("d");
displayValues[nameof(Purchase_Date)] = libraryBook.DateAdded.ToString("d");
return libraryBook.DateAdded.ToString("yyyy-MM-dd HH:mm:ss");
}
}
@@ -129,7 +182,7 @@ namespace LibationWinForm
{
get
{
formatReplacements[nameof(My_Rating)] = starString(book.UserDefinedItem.Rating);
displayValues[nameof(My_Rating)] = starString(book.UserDefinedItem.Rating);
return firstScore(book.UserDefinedItem.Rating);
}
}
@@ -146,8 +199,8 @@ namespace LibationWinForm
get
{
var details = new List<string>();
if (book.HasPdfs)
details.Add("Has PDFs");
if (book.HasPdf)
details.Add("Has PDF");
if (book.IsAbridged)
details.Add("Abridged");
if (book.DatePublished.HasValue)
@@ -162,43 +215,5 @@ namespace LibationWinForm
return string.Join("\r\n", details);
}
}
public string Download_Status
{
get
{
var print
= FileManager.AudibleFileStorage.Audio.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult() ? "Liberated"
: FileManager.AudibleFileStorage.AAX.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult() ? "DRM"
: "NOT d/l'ed";
if (!book.Supplements.Any())
return print;
print += "\r\n";
var downloadStatuses = book.Supplements
.Select(d => FileManager.AudibleFileStorage.PDF.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult())
// break delayed execution right now!
.ToList();
var count = downloadStatuses.Count;
if (count == 1)
{
print += downloadStatuses[0]
? "PDF d/l'ed"
: "PDF NOT d/l'ed";
}
else
{
var downloadedCount = downloadStatuses.Count(s => s);
print
+= downloadedCount == count ? $"{count} PDFs d/l'ed"
: downloadedCount == 0 ? $"{count} PDFs NOT d/l'ed"
: $"{downloadedCount} of {count} PDFs d/l'ed";
}
return print;
}
}
}
}

View File

@@ -1,201 +1,191 @@
namespace LibationWinForm
{
partial class ProductsGrid
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
partial class ProductsGrid
{
/// <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);
}
/// <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 Component Designer generated code
#region Component 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();
this.gridEntryBindingSource = new System.Windows.Forms.BindingSource(this.components);
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.dataGridViewImageColumn1 = new System.Windows.Forms.DataGridViewImageColumn();
this.dataGridViewTextBoxColumn1 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn2 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn3 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn4 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn5 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn6 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn7 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn8 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn9 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn10 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn11 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn12 = new System.Windows.Forms.DataGridViewTextBoxColumn();
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).BeginInit();
this.SuspendLayout();
//
// gridEntryBindingSource
//
this.gridEntryBindingSource.DataSource = typeof(LibationWinForm.GridEntry);
//
// gridEntryDataGridView
//
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.dataGridViewImageColumn1,
this.dataGridViewTextBoxColumn1,
this.dataGridViewTextBoxColumn2,
this.dataGridViewTextBoxColumn3,
this.dataGridViewTextBoxColumn4,
this.dataGridViewTextBoxColumn5,
this.dataGridViewTextBoxColumn6,
this.dataGridViewTextBoxColumn7,
this.dataGridViewTextBoxColumn8,
this.dataGridViewTextBoxColumn9,
this.dataGridViewTextBoxColumn10,
this.dataGridViewTextBoxColumn11,
this.dataGridViewTextBoxColumn12});
this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource;
this.gridEntryDataGridView.Location = new System.Drawing.Point(54, 58);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.Size = new System.Drawing.Size(300, 220);
this.gridEntryDataGridView.TabIndex = 0;
//
// dataGridViewImageColumn1
//
this.dataGridViewImageColumn1.DataPropertyName = "Cover";
this.dataGridViewImageColumn1.HeaderText = "Cover";
this.dataGridViewImageColumn1.Name = "dataGridViewImageColumn1";
this.dataGridViewImageColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn1
//
this.dataGridViewTextBoxColumn1.DataPropertyName = "Title";
this.dataGridViewTextBoxColumn1.HeaderText = "Title";
this.dataGridViewTextBoxColumn1.Name = "dataGridViewTextBoxColumn1";
this.dataGridViewTextBoxColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn2
//
this.dataGridViewTextBoxColumn2.DataPropertyName = "Authors";
this.dataGridViewTextBoxColumn2.HeaderText = "Authors";
this.dataGridViewTextBoxColumn2.Name = "dataGridViewTextBoxColumn2";
this.dataGridViewTextBoxColumn2.ReadOnly = true;
//
// dataGridViewTextBoxColumn3
//
this.dataGridViewTextBoxColumn3.DataPropertyName = "Narrators";
this.dataGridViewTextBoxColumn3.HeaderText = "Narrators";
this.dataGridViewTextBoxColumn3.Name = "dataGridViewTextBoxColumn3";
this.dataGridViewTextBoxColumn3.ReadOnly = true;
//
// dataGridViewTextBoxColumn4
//
this.dataGridViewTextBoxColumn4.DataPropertyName = "Length";
this.dataGridViewTextBoxColumn4.HeaderText = "Length";
this.dataGridViewTextBoxColumn4.Name = "dataGridViewTextBoxColumn4";
this.dataGridViewTextBoxColumn4.ReadOnly = true;
//
// dataGridViewTextBoxColumn5
//
this.dataGridViewTextBoxColumn5.DataPropertyName = "Series";
this.dataGridViewTextBoxColumn5.HeaderText = "Series";
this.dataGridViewTextBoxColumn5.Name = "dataGridViewTextBoxColumn5";
this.dataGridViewTextBoxColumn5.ReadOnly = true;
//
// dataGridViewTextBoxColumn6
//
this.dataGridViewTextBoxColumn6.DataPropertyName = "Description";
this.dataGridViewTextBoxColumn6.HeaderText = "Description";
this.dataGridViewTextBoxColumn6.Name = "dataGridViewTextBoxColumn6";
this.dataGridViewTextBoxColumn6.ReadOnly = true;
//
// dataGridViewTextBoxColumn7
//
this.dataGridViewTextBoxColumn7.DataPropertyName = "Category";
this.dataGridViewTextBoxColumn7.HeaderText = "Category";
this.dataGridViewTextBoxColumn7.Name = "dataGridViewTextBoxColumn7";
this.dataGridViewTextBoxColumn7.ReadOnly = true;
//
// dataGridViewTextBoxColumn8
//
this.dataGridViewTextBoxColumn8.DataPropertyName = "Product_Rating";
this.dataGridViewTextBoxColumn8.HeaderText = "Product_Rating";
this.dataGridViewTextBoxColumn8.Name = "dataGridViewTextBoxColumn8";
this.dataGridViewTextBoxColumn8.ReadOnly = true;
//
// dataGridViewTextBoxColumn9
//
this.dataGridViewTextBoxColumn9.DataPropertyName = "Purchase_Date";
this.dataGridViewTextBoxColumn9.HeaderText = "Purchase_Date";
this.dataGridViewTextBoxColumn9.Name = "dataGridViewTextBoxColumn9";
this.dataGridViewTextBoxColumn9.ReadOnly = true;
//
// dataGridViewTextBoxColumn10
//
this.dataGridViewTextBoxColumn10.DataPropertyName = "My_Rating";
this.dataGridViewTextBoxColumn10.HeaderText = "My_Rating";
this.dataGridViewTextBoxColumn10.Name = "dataGridViewTextBoxColumn10";
this.dataGridViewTextBoxColumn10.ReadOnly = true;
//
// dataGridViewTextBoxColumn11
//
this.dataGridViewTextBoxColumn11.DataPropertyName = "Misc";
this.dataGridViewTextBoxColumn11.HeaderText = "Misc";
this.dataGridViewTextBoxColumn11.Name = "dataGridViewTextBoxColumn11";
this.dataGridViewTextBoxColumn11.ReadOnly = true;
//
// dataGridViewTextBoxColumn12
//
this.dataGridViewTextBoxColumn12.DataPropertyName = "Download_Status";
this.dataGridViewTextBoxColumn12.HeaderText = "Download_Status";
this.dataGridViewTextBoxColumn12.Name = "dataGridViewTextBoxColumn12";
this.dataGridViewTextBoxColumn12.ReadOnly = true;
//
// ProductsGrid
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.Controls.Add(this.gridEntryDataGridView);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(434, 329);
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
this.ResumeLayout(false);
/// <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();
this.gridEntryBindingSource = new System.Windows.Forms.BindingSource(this.components);
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.dataGridViewImageColumn1 = new System.Windows.Forms.DataGridViewImageColumn();
this.dataGridViewTextBoxColumn1 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn2 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn3 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn4 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn5 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn6 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn7 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn8 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn9 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn10 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn11 = new System.Windows.Forms.DataGridViewTextBoxColumn();
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).BeginInit();
this.SuspendLayout();
//
// gridEntryBindingSource
//
this.gridEntryBindingSource.DataSource = typeof(LibationWinForm.GridEntry);
//
// gridEntryDataGridView
//
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.dataGridViewImageColumn1,
this.dataGridViewTextBoxColumn1,
this.dataGridViewTextBoxColumn2,
this.dataGridViewTextBoxColumn3,
this.dataGridViewTextBoxColumn4,
this.dataGridViewTextBoxColumn5,
this.dataGridViewTextBoxColumn6,
this.dataGridViewTextBoxColumn7,
this.dataGridViewTextBoxColumn8,
this.dataGridViewTextBoxColumn9,
this.dataGridViewTextBoxColumn10,
this.dataGridViewTextBoxColumn11});
this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource;
this.gridEntryDataGridView.Location = new System.Drawing.Point(54, 58);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.Size = new System.Drawing.Size(300, 220);
this.gridEntryDataGridView.TabIndex = 0;
//
// dataGridViewImageColumn1
//
this.dataGridViewImageColumn1.DataPropertyName = "Cover";
this.dataGridViewImageColumn1.HeaderText = "Cover";
this.dataGridViewImageColumn1.Name = "dataGridViewImageColumn1";
this.dataGridViewImageColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn1
//
this.dataGridViewTextBoxColumn1.DataPropertyName = "Title";
this.dataGridViewTextBoxColumn1.HeaderText = "Title";
this.dataGridViewTextBoxColumn1.Name = "dataGridViewTextBoxColumn1";
this.dataGridViewTextBoxColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn2
//
this.dataGridViewTextBoxColumn2.DataPropertyName = "Authors";
this.dataGridViewTextBoxColumn2.HeaderText = "Authors";
this.dataGridViewTextBoxColumn2.Name = "dataGridViewTextBoxColumn2";
this.dataGridViewTextBoxColumn2.ReadOnly = true;
//
// dataGridViewTextBoxColumn3
//
this.dataGridViewTextBoxColumn3.DataPropertyName = "Narrators";
this.dataGridViewTextBoxColumn3.HeaderText = "Narrators";
this.dataGridViewTextBoxColumn3.Name = "dataGridViewTextBoxColumn3";
this.dataGridViewTextBoxColumn3.ReadOnly = true;
//
// dataGridViewTextBoxColumn4
//
this.dataGridViewTextBoxColumn4.DataPropertyName = "Length";
this.dataGridViewTextBoxColumn4.HeaderText = "Length";
this.dataGridViewTextBoxColumn4.Name = "dataGridViewTextBoxColumn4";
this.dataGridViewTextBoxColumn4.ReadOnly = true;
//
// dataGridViewTextBoxColumn5
//
this.dataGridViewTextBoxColumn5.DataPropertyName = "Series";
this.dataGridViewTextBoxColumn5.HeaderText = "Series";
this.dataGridViewTextBoxColumn5.Name = "dataGridViewTextBoxColumn5";
this.dataGridViewTextBoxColumn5.ReadOnly = true;
//
// dataGridViewTextBoxColumn6
//
this.dataGridViewTextBoxColumn6.DataPropertyName = "Description";
this.dataGridViewTextBoxColumn6.HeaderText = "Description";
this.dataGridViewTextBoxColumn6.Name = "dataGridViewTextBoxColumn6";
this.dataGridViewTextBoxColumn6.ReadOnly = true;
//
// dataGridViewTextBoxColumn7
//
this.dataGridViewTextBoxColumn7.DataPropertyName = "Category";
this.dataGridViewTextBoxColumn7.HeaderText = "Category";
this.dataGridViewTextBoxColumn7.Name = "dataGridViewTextBoxColumn7";
this.dataGridViewTextBoxColumn7.ReadOnly = true;
//
// dataGridViewTextBoxColumn8
//
this.dataGridViewTextBoxColumn8.DataPropertyName = "Product_Rating";
this.dataGridViewTextBoxColumn8.HeaderText = "Product_Rating";
this.dataGridViewTextBoxColumn8.Name = "dataGridViewTextBoxColumn8";
this.dataGridViewTextBoxColumn8.ReadOnly = true;
//
// dataGridViewTextBoxColumn9
//
this.dataGridViewTextBoxColumn9.DataPropertyName = "Purchase_Date";
this.dataGridViewTextBoxColumn9.HeaderText = "Purchase_Date";
this.dataGridViewTextBoxColumn9.Name = "dataGridViewTextBoxColumn9";
this.dataGridViewTextBoxColumn9.ReadOnly = true;
//
// dataGridViewTextBoxColumn10
//
this.dataGridViewTextBoxColumn10.DataPropertyName = "My_Rating";
this.dataGridViewTextBoxColumn10.HeaderText = "My_Rating";
this.dataGridViewTextBoxColumn10.Name = "dataGridViewTextBoxColumn10";
this.dataGridViewTextBoxColumn10.ReadOnly = true;
//
// dataGridViewTextBoxColumn11
//
this.dataGridViewTextBoxColumn11.DataPropertyName = "Misc";
this.dataGridViewTextBoxColumn11.HeaderText = "Misc";
this.dataGridViewTextBoxColumn11.Name = "dataGridViewTextBoxColumn11";
this.dataGridViewTextBoxColumn11.ReadOnly = true;
//
// ProductsGrid
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.Controls.Add(this.gridEntryDataGridView);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(434, 329);
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
this.ResumeLayout(false);
}
}
#endregion
#endregion
private System.Windows.Forms.BindingSource gridEntryBindingSource;
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.DataGridViewImageColumn dataGridViewImageColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn2;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn3;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn4;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn5;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn6;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn7;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn8;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn9;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn10;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn11;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn12;
}
private System.Windows.Forms.BindingSource gridEntryBindingSource;
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.DataGridViewImageColumn dataGridViewImageColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn2;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn3;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn4;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn5;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn6;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn7;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn8;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn9;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn10;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn11;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
@@ -6,6 +7,7 @@ using ApplicationServices;
using DataLayer;
using Dinah.Core.Collections.Generic;
using Dinah.Core.DataBinding;
using Dinah.Core.Windows.Forms;
namespace LibationWinForm
{
@@ -23,22 +25,32 @@ namespace LibationWinForm
// - click on Data Sources > ProductItem. drowdown: DataGridView
// - drag/drop ProductItem on design surface
public partial class ProductsGrid : UserControl
{
public event EventHandler<int> VisibleCountChanged;
{
public event EventHandler<int> VisibleCountChanged;
public event EventHandler BackupCountsChanged;
private DataGridView dataGridView;
private const string EDIT_TAGS = "Edit Tags";
private const string LIBERATE = "Liberate";
public ProductsGrid() => InitializeComponent();
// alias
private DataGridView dataGridView => gridEntryDataGridView;
private bool hasBeenDisplayed = false;
public void Display()
private LibationContext context;
public ProductsGrid()
{
InitializeComponent();
formatDataGridView();
addLiberateButtons();
addEditTagsButtons();
formatColumns();
Disposed += (_, __) => context?.Dispose();
manageLiveImageUpdateSubscriptions();
}
private void formatDataGridView()
{
if (hasBeenDisplayed)
return;
hasBeenDisplayed = true;
dataGridView = gridEntryDataGridView;
dataGridView.Dock = DockStyle.Fill;
dataGridView.AllowUserToAddRows = false;
dataGridView.AllowUserToDeleteRows = false;
@@ -47,26 +59,179 @@ namespace LibationWinForm
dataGridView.DefaultCellStyle.WrapMode = DataGridViewTriState.True;
dataGridView.ReadOnly = true;
dataGridView.RowHeadersVisible = false;
// adjust height for 80x80 pictures.
// this must be done before databinding. or can alter later by iterating through rows
dataGridView.RowTemplate.Height = 82;
dataGridView.CellFormatting += replaceFormatted;
dataGridView.CellFormatting += hiddenFormatting;
// sorting breaks filters. must reapply filters after sorting
dataGridView.Sorted += (_, __) => filter();
}
{ // add tag buttons
var editUserTagsButton = new DataGridViewButtonColumn { HeaderText = "Edit Tags" };
dataGridView.Columns.Add(editUserTagsButton);
#region format text cells. ie: not buttons
private void replaceFormatted(object sender, DataGridViewCellFormattingEventArgs e)
{
var col = ((DataGridView)sender).Columns[e.ColumnIndex];
if (col is DataGridViewTextBoxColumn textCol && GetGridEntry(e.RowIndex).TryDisplayValue(textCol.Name, out string value))
e.Value = value;
}
// add image and handle click
dataGridView.CellPainting += paintEditTag_TextAndImage;
dataGridView.CellContentClick += dataGridView_GridButtonClick;
private void hiddenFormatting(object sender, DataGridViewCellFormattingEventArgs e)
{
var dgv = (DataGridView)sender;
// no action needed for buttons
if (e.RowIndex < 0 || dgv.Columns[e.ColumnIndex] is DataGridViewButtonColumn)
return;
var isHidden = GetGridEntry(e.RowIndex).TagsEnumerated.Contains("hidden");
dgv.Rows[e.RowIndex].Cells[e.ColumnIndex].Style
= isHidden
? new DataGridViewCellStyle { ForeColor = Color.LightGray }
: dgv.DefaultCellStyle;
}
#endregion
#region liberation buttons
private void addLiberateButtons()
{
dataGridView.Columns.Insert(0, new DataGridViewButtonColumn { HeaderText = LIBERATE });
dataGridView.CellPainting += liberate_Paint;
dataGridView.CellContentClick += liberate_Click;
}
private void liberate_Paint(object sender, DataGridViewCellPaintingEventArgs e)
{
var dgv = (DataGridView)sender;
if (!isColumnValid(dgv, e.RowIndex, e.ColumnIndex, LIBERATE))
return;
dgv.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = GetGridEntry(e.RowIndex).Download_Status;
}
private async void liberate_Click(object sender, DataGridViewCellEventArgs e)
{
var dgv = (DataGridView)sender;
if (!isColumnValid(dgv, e.RowIndex, e.ColumnIndex, LIBERATE))
return;
var productId = GetGridEntry(e.RowIndex).GetBook().AudibleProductId;
// if liberated, open explorer to file
if (FileManager.AudibleFileStorage.Audio.Exists(productId))
{
var filePath = FileManager.AudibleFileStorage.Audio.GetPath(productId);
System.Diagnostics.Process.Start("explorer.exe", $"/select, \"{filePath}\"");
}
// else liberate
else
{
var backupBook = BookLiberation.ProcessorAutomationController.GetWiredUpBackupBook((_, __) => RefreshRow(productId));
await BookLiberation.ProcessorAutomationController.RunSingleBackupAsync(backupBook, productId);
}
}
#endregion
public void RefreshRow(string productId)
{
var rowId = GetRowId((ge) => ge.GetBook().AudibleProductId == productId);
// update cells incl Liberate button text
dataGridView.InvalidateRow(rowId);
BackupCountsChanged?.Invoke(this, EventArgs.Empty);
}
#region tag buttons
private void addEditTagsButtons()
{
dataGridView.Columns.Add(new DataGridViewButtonColumn { HeaderText = EDIT_TAGS });
dataGridView.CellPainting += editTags_Paint;
dataGridView.CellContentClick += editTags_Click;
}
private void editTags_Paint(object sender, DataGridViewCellPaintingEventArgs e)
{
// DataGridView Image for Button Column: https://stackoverflow.com/a/36253883
var dgv = (DataGridView)sender;
if (!isColumnValid(dgv, e.RowIndex, e.ColumnIndex, EDIT_TAGS))
return;
var displayTags = GetGridEntry(e.RowIndex).TagsEnumerated.ToList();
var cell = dgv.Rows[e.RowIndex].Cells[e.ColumnIndex];
if (displayTags.Any())
cell.Value = string.Join("\r\n", displayTags);
else // no tags: use image
{
// clear tag text
cell.Value = "";
var image = Properties.Resources.edit_tags_25x25;
e.Paint(e.CellBounds, DataGridViewPaintParts.All);
var w = image.Width;
var h = image.Height;
var x = e.CellBounds.Left + (e.CellBounds.Width - w) / 2;
var y = e.CellBounds.Top + (e.CellBounds.Height - h) / 2;
e.Graphics.DrawImage(image, new Rectangle(x, y, w, h));
e.Handled = true;
}
}
private void editTags_Click(object sender, DataGridViewCellEventArgs e)
{
// handle grid button click: https://stackoverflow.com/a/13687844
var dgv = (DataGridView)sender;
if (!isColumnValid(dgv, e.RowIndex, e.ColumnIndex, EDIT_TAGS))
return;
var liveGridEntry = GetGridEntry(e.RowIndex);
// EditTagsDialog should display better-formatted title
liveGridEntry.TryDisplayValue(nameof(liveGridEntry.Title), out string value);
var editTagsForm = new EditTagsDialog(value, liveGridEntry.Tags);
if (editTagsForm.ShowDialog() != DialogResult.OK)
return;
var qtyChanges = context.UpdateTags(liveGridEntry.GetBook(), editTagsForm.NewTags);
if (qtyChanges == 0)
return;
// force a re-draw, and re-apply filters
// needed to update text colors
dgv.InvalidateRow(e.RowIndex);
filter();
}
#endregion
private static bool isColumnValid(DataGridView dgv, int rowIndex, int colIndex, string colName)
{
var col = dgv.Columns[colIndex];
return rowIndex >= 0 && col.HeaderText == colName && col is DataGridViewButtonColumn;
}
private void formatColumns()
{
for (var i = dataGridView.ColumnCount - 1; i >= 0; i--)
{
DataGridViewColumn col = dataGridView.Columns[i];
var col = dataGridView.Columns[i];
// initial HeaderText is the lookup name from GridEntry class. any formatting below won't change this
col.Name = col.HeaderText;
@@ -76,34 +241,59 @@ namespace LibationWinForm
col.HeaderText = col.HeaderText.Replace("_", " ");
col.Width = col.Name switch
{
nameof(GridEntry.Cover) => 80,
nameof(GridEntry.Title) => col.Width * 2,
nameof(GridEntry.Misc) => (int)(col.Width * 1.35),
var n when n.In(nameof(GridEntry.My_Rating), nameof(GridEntry.Product_Rating)) => col.Width + 8,
_ => col.Width
};
col.Width = col.Name switch
{
nameof(GridEntry.Cover) => 80,
nameof(GridEntry.Title) => col.Width * 2,
nameof(GridEntry.Misc) => (int)(col.Width * 1.35),
var n when n.In(nameof(GridEntry.My_Rating), nameof(GridEntry.Product_Rating)) => col.Width + 8,
_ => col.Width
};
}
}
#region live update newly downloaded and cached images
private void manageLiveImageUpdateSubscriptions()
{
FileManager.PictureStorage.PictureCached += crossThreadImageUpdate;
Disposed += (_, __) => FileManager.PictureStorage.PictureCached -= crossThreadImageUpdate;
}
private void crossThreadImageUpdate(object _, string pictureId)
=> dataGridView.UIThread(() => updateRowImage(pictureId));
private void updateRowImage(string pictureId)
{
var rowId = GetRowId((ge) => ge.GetBook().PictureId == pictureId);
if (rowId > -1)
dataGridView.InvalidateRow(rowId);
}
#endregion
private bool hasBeenDisplayed = false;
public void Display()
{
if (hasBeenDisplayed)
return;
hasBeenDisplayed = true;
//
// transform into sorted GridEntry.s BEFORE binding
//
var lib = LibraryQueries.GetLibrary_Flat_NoTracking();
context = LibationContext.Create();
var lib = context.GetLibrary_Flat_WithTracking();
// if no data. hide all columns. return
if (!lib.Any())
{
for (var i = dataGridView.ColumnCount - 1; i >= 0; i--)
dataGridView.Columns.RemoveAt(i);
return;
}
// if no data. hide all columns. return
if (!lib.Any())
{
for (var i = dataGridView.ColumnCount - 1; i >= 0; i--)
dataGridView.Columns.RemoveAt(i);
return;
}
var orderedGridEntries = lib
var orderedGridEntries = lib
.Select(lb => new GridEntry(lb)).ToList()
// default load order
.OrderByDescending(ge => ge.Purchase_Date)
// default load order
.OrderByDescending(ge => ge.Purchase_Date)
//// more advanced example: sort by author, then series, then title
//.OrderBy(ge => ge.Authors)
// .ThenBy(ge => ge.Series)
@@ -119,114 +309,8 @@ namespace LibationWinForm
// FILTER
//
filter();
}
private void paintEditTag_TextAndImage(object sender, DataGridViewCellPaintingEventArgs e)
{
// DataGridView Image for Button Column: https://stackoverflow.com/a/36253883
if (e.RowIndex < 0 || !(((DataGridView)sender).Columns[e.ColumnIndex] is DataGridViewButtonColumn))
return;
var gridEntry = getGridEntry(e.RowIndex);
var displayTags = gridEntry.TagsEnumerated.ToList();
if (displayTags.Any())
dataGridView.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = string.Join("\r\n", displayTags);
else // no tags: use image
{
// clear tag text
dataGridView.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = "";
// images from: icons8.com -- search: tags
var image = Properties.Resources.edit_tags_25x25;
e.Paint(e.CellBounds, DataGridViewPaintParts.All);
var w = image.Width;
var h = image.Height;
var x = e.CellBounds.Left + (e.CellBounds.Width - w) / 2;
var y = e.CellBounds.Top + (e.CellBounds.Height - h) / 2;
e.Graphics.DrawImage(image, new Rectangle(x, y, w, h));
e.Handled = true;
}
}
private void dataGridView_GridButtonClick(object sender, DataGridViewCellEventArgs e)
{
// handle grid button click: https://stackoverflow.com/a/13687844
if (e.RowIndex < 0)
return;
if (sender != dataGridView)
throw new Exception($"{nameof(dataGridView_GridButtonClick)} has incorrect sender ...somehow");
if (!(dataGridView.Columns[e.ColumnIndex] is DataGridViewButtonColumn))
return;
var liveGridEntry = getGridEntry(e.RowIndex);
// EditTagsDialog should display better-formatted title
liveGridEntry.TryGetFormatted(nameof(liveGridEntry.Title), out string value);
var editTagsForm = new EditTagsDialog(value, liveGridEntry.Tags);
if (editTagsForm.ShowDialog() != DialogResult.OK)
return;
var qtyChanges = saveChangedTags(liveGridEntry.GetBook(), editTagsForm.NewTags);
if (qtyChanges == 0)
return;
// force a re-draw, and re-apply filters
// needed to update text colors
dataGridView.InvalidateRow(e.RowIndex);
filter();
}
private static int saveChangedTags(Book book, string newTags)
{
book.UserDefinedItem.Tags = newTags;
var qtyChanges = LibraryCommands.IndexChangedTags(book);
return qtyChanges;
}
#region Cell Formatting
private void replaceFormatted(object sender, DataGridViewCellFormattingEventArgs e)
{
var col = ((DataGridView)sender).Columns[e.ColumnIndex];
if (col is DataGridViewTextBoxColumn textCol && getGridEntry(e.RowIndex).TryGetFormatted(textCol.Name, out string value))
e.Value = value;
}
private void hiddenFormatting(object sender, DataGridViewCellFormattingEventArgs e)
{
var isHidden = getGridEntry(e.RowIndex).TagsEnumerated.Contains("hidden");
dataGridView.Rows[e.RowIndex].Cells[e.ColumnIndex].Style
= isHidden
? new DataGridViewCellStyle { ForeColor = Color.LightGray }
: dataGridView.DefaultCellStyle;
}
#endregion
public void UpdateRow(string productId)
{
for (var r = dataGridView.RowCount - 1; r >= 0; r--)
{
var gridEntry = getGridEntry(r);
if (gridEntry.GetBook().AudibleProductId == productId)
{
var libBook = LibraryQueries.GetLibraryBook_Flat_NoTracking(productId);
gridEntry.REPLACE_Library_Book(libBook);
dataGridView.InvalidateRow(r);
return;
}
}
BackupCountsChanged?.Invoke(this, EventArgs.Empty);
}
#region filter
@@ -247,15 +331,17 @@ namespace LibationWinForm
currencyManager.SuspendBinding();
{
for (var r = dataGridView.RowCount - 1; r >= 0; r--)
dataGridView.Rows[r].Visible = productIds.Contains(getGridEntry(r).GetBook().AudibleProductId);
dataGridView.Rows[r].Visible = productIds.Contains(GetGridEntry(r).GetBook().AudibleProductId);
}
currencyManager.ResumeBinding();
VisibleCountChanged?.Invoke(this, dataGridView.Rows.Cast<DataGridViewRow>().Count(r => r.Visible));
VisibleCountChanged?.Invoke(this, dataGridView.AsEnumerable().Count(r => r.Visible));
var luceneSearchString_debug = searchResults.SearchString;
}
#endregion
private GridEntry getGridEntry(int rowIndex) => (GridEntry)dataGridView.Rows[rowIndex].DataBoundItem;
}
private int GetRowId(Func<GridEntry, bool> func) => dataGridView.GetRowIdOfBoundItem(func);
private GridEntry GetGridEntry(int rowIndex) => dataGridView.GetBoundItem<GridEntry>(rowIndex);
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Windows.Forms;
using Dinah.Core.Logging;
using Serilog;
namespace LibationWinForm
{
@@ -14,6 +16,8 @@ namespace LibationWinForm
if (!createSettings())
return;
init();
Application.Run(new Form1());
}
@@ -29,7 +33,7 @@ Please fill in a few settings on the following page. You can also change these s
After you make your selections, get started by importing your library.
Go to Import > Scan Library
".Trim();
MessageBox.Show(welcomeText, "Welcom to Libation", MessageBoxButtons.OK);
MessageBox.Show(welcomeText, "Welcome to Libation", MessageBoxButtons.OK);
var dialogResult = new SettingsDialog().ShowDialog();
if (dialogResult != DialogResult.OK)
{
@@ -39,5 +43,32 @@ Go to Import > Scan Library
return true;
}
private static void init()
{
// default. for reference. output example:
// 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
var default_outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
// with class and method info. output example:
// 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForm.Program.init()) Begin Libation
var code_outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
var logPath = System.IO.Path.Combine(FileManager.Configuration.Instance.LibationFiles, "Log.log");
Log.Logger = new LoggerConfiguration()
.Enrich.WithCaller()
.MinimumLevel.Debug()
.WriteTo.File(logPath,
rollingInterval: RollingInterval.Month,
outputTemplate: code_outputTemplate)
.CreateLogger();
Log.Logger.Debug("Begin Libation");
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
}
}

View File

@@ -1,6 +1,16 @@
-- begin VERSIONING ---------------------------------------------------------------------------------------------------------------------
https://github.com/rmcrackan/Libation/releases
v3.1-beta.10 : New feature: clicking Liberate button on a liberated item navigates to that audio file
v3.1-beta.9 : New feature: liberate individual book
v3.1-beta.8 : Bugfix: decrypt file conflict
v3.1-beta.7 : Bugfix: decrypt book with no author
v3.1-beta.6 : Improved logging
v3.1-beta.5 : Improved importing
v3.1-beta.4 : Added beta-specific logging
v3.1-beta.3 : fixed known performance issue: Full-screen grid is slow to respond loading when books aren't liberated
v3.1-beta.2 : fixed known performance issue: Tag add/edit
v3.1-beta.1 : RELEASE TO BETA
v3.0.3 : Switch to SQLite. No longer relies on LocalDB, which must be installed separately
v3.0.2 : Final using LocalDB
v3.0.1 : Legacy inAudible wire-up code is still present but is commented out. All future check-ins are not guaranteed to have inAudible wire-up code
@@ -32,24 +42,17 @@ publish win64 platform, single-file
dotnet publish -r win-x64 -c Release /p:PublishSingleFile=true
-- end HOW TO PUBLISH ---------------------------------------------------------------------------------------------------------------------
-- begin IMAGES ---------------------------------------------------------------------------------------------------------------------
edit tags icon images from:
icons8.com
search: tags
-- end IMAGES ---------------------------------------------------------------------------------------------------------------------
-- begin AUDIBLE DETAILS ---------------------------------------------------------------------------------------------------------------------
alternate book id (eg BK_RAND_006061) is called 'sku' , 'sku_lite' , 'prod_id' , 'product_id' in different parts of the site
-- end AUDIBLE DETAILS ---------------------------------------------------------------------------------------------------------------------
-- begin SOLUTION LAYOUT ---------------------------------------------------------------------------------------------------------------------
core libraries
extend Standard Libraries
additional simple libraries for general purpose programming
utils: domain ignorant
domain: biz logic
db ignorant
db, domain objects
domain internal utilities: domain aware. db ignorant
domain utilities: domain aware. db aware
incl non-db persistence. unless it needs to be tied into a db intercepter
all user-provided data must be backed up to json
application
do NOT combine jsons for
- audible-scraped persistence: library, book details
- libation-generated persistence: FilePaths.json
@@ -73,31 +76,4 @@ aggregate root is transactional boundary
// // test with and without : using TransactionScope scope = new TransactionScope();
//System.Transactions.Transaction.Current.TransactionCompleted += (sender, e) => { };
// also : https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction.enlistvolatile
pattern when using 1 db context per form
public Ctor()
{
InitializeComponent();
// dispose context here only. DO NOT dispose in OnParentChanged(). parent form will call dispose after this one has been switched.
// disposing context prematurely can result in:
// The ObjectContext instance has been disposed and can no longer be used for operations that require a connection.
this.Disposed += (_, __) => context?.Dispose();
}
-- end EF CORE ---------------------------------------------------------------------------------------------------------------------
-- begin ASYNC/AWAIT ---------------------------------------------------------------------------------------------------------------------
Using Async and Await to update the UI Thread <20> Stephen Haunts { Coding in the Trenches }
https://stephenhaunts.com/2014/10/14/using-async-and-await-to-update-the-ui-thread/
Using Async and Await to update the UI Thread Part 2 <20> Stephen Haunts { Coding in the Trenches }
https://stephenhaunts.com/2014/10/16/using-async-and-await-to-update-the-ui-thread-part-2/
Simple Async Await Example for Asynchronous Programming <20> Stephen Haunts { Coding in the Trenches }
https://stephenhaunts.com/2014/10/10/simple-async-await-example-for-asynchronous-programming/
Async and Await -- Stephen Cleary's famous intro
https://blog.stephencleary.com/2012/02/async-and-await.html
Async-Await - Best Practices in Asynchronous Programming -- Stephen Cleary
https://msdn.microsoft.com/en-us/magazine/jj991977.aspx
-- end ASYNC/AWAIT ---------------------------------------------------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
namespace LibationWinForm_Framework.Dialogs
namespace WinFormsDesigner.Dialogs
{
partial class IndexLibraryDialog
{

View File

@@ -8,7 +8,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForm_Framework.Dialogs
namespace WinFormsDesigner.Dialogs
{
public partial class IndexLibraryDialog : Form
{

View File

@@ -1,4 +1,4 @@
namespace LibationWinForm_Framework.Dialogs.Login
namespace WinFormsDesigner.Dialogs.Login
{
partial class AudibleLoginDialog
{
@@ -93,7 +93,6 @@
this.MinimizeBox = false;
this.Name = "AudibleLoginDialog";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Audible Login";
this.ResumeLayout(false);

View File

@@ -8,7 +8,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForm_Framework.Dialogs.Login
namespace WinFormsDesigner.Dialogs.Login
{
public partial class AudibleLoginDialog : Form
{

View File

@@ -1,4 +1,4 @@
namespace LibationWinForm_Framework.Dialogs.Login
namespace WinFormsDesigner.Dialogs.Login
{
partial class CaptchaDialog
{
@@ -83,7 +83,6 @@
this.MinimizeBox = false;
this.Name = "CaptchaDialog";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "CAPTCHA";
((System.ComponentModel.ISupportInitialize)(this.captchaPb)).EndInit();

View File

@@ -8,7 +8,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForm_Framework.Dialogs.Login
namespace WinFormsDesigner.Dialogs.Login
{
public partial class CaptchaDialog : Form
{

View File

@@ -73,7 +73,6 @@
this.MinimizeBox = false;
this.Name = "_2faCodeDialog";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "2FA Code";
this.ResumeLayout(false);

View File

@@ -12,6 +12,9 @@ namespace WinFormsDesigner
[Browsable(false)]
public IEnumerable<string> TagsEnumerated { get; set; }
[Browsable(false)]
public string Download_Status { get; set; }
public Image Cover { get; set; }
public string Title { get; set; }
public string Authors { get; set; }
@@ -24,6 +27,5 @@ namespace WinFormsDesigner
public DateTime? Purchase_Date { get; set; }
public string My_Rating { get; set; }
public string Misc { get; set; }
public string Download_Status { get; set; }
}
}

View File

@@ -28,35 +28,34 @@
/// </summary>
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
this.gridEntryBindingSource = new System.Windows.Forms.BindingSource(this.components);
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.dataGridViewImageColumn1 = new System.Windows.Forms.DataGridViewImageColumn();
this.dataGridViewTextBoxColumn1 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn2 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn3 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn4 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn5 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn6 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn7 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn8 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn9 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn10 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn11 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn12 = new System.Windows.Forms.DataGridViewTextBoxColumn();
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).BeginInit();
this.SuspendLayout();
//
// gridEntryBindingSource
//
this.gridEntryBindingSource.DataSource = typeof(WinFormsDesigner.GridEntry);
//
// gridEntryDataGridView
//
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.components = new System.ComponentModel.Container();
this.gridEntryBindingSource = new System.Windows.Forms.BindingSource(this.components);
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.dataGridViewImageColumn1 = new System.Windows.Forms.DataGridViewImageColumn();
this.dataGridViewTextBoxColumn1 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn2 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn3 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn4 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn5 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn6 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn7 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn8 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn9 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn10 = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.dataGridViewTextBoxColumn11 = new System.Windows.Forms.DataGridViewTextBoxColumn();
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).BeginInit();
this.SuspendLayout();
//
// gridEntryBindingSource
//
this.gridEntryBindingSource.DataSource = typeof(WinFormsDesigner.GridEntry);
//
// gridEntryDataGridView
//
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.dataGridViewImageColumn1,
this.dataGridViewTextBoxColumn1,
this.dataGridViewTextBoxColumn2,
@@ -68,134 +67,125 @@
this.dataGridViewTextBoxColumn8,
this.dataGridViewTextBoxColumn9,
this.dataGridViewTextBoxColumn10,
this.dataGridViewTextBoxColumn11,
this.dataGridViewTextBoxColumn12});
this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource;
this.gridEntryDataGridView.Location = new System.Drawing.Point(54, 58);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.Size = new System.Drawing.Size(300, 220);
this.gridEntryDataGridView.TabIndex = 0;
//
// dataGridViewImageColumn1
//
this.dataGridViewImageColumn1.DataPropertyName = "Cover";
this.dataGridViewImageColumn1.HeaderText = "Cover";
this.dataGridViewImageColumn1.Name = "dataGridViewImageColumn1";
this.dataGridViewImageColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn1
//
this.dataGridViewTextBoxColumn1.DataPropertyName = "Title";
this.dataGridViewTextBoxColumn1.HeaderText = "Title";
this.dataGridViewTextBoxColumn1.Name = "dataGridViewTextBoxColumn1";
this.dataGridViewTextBoxColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn2
//
this.dataGridViewTextBoxColumn2.DataPropertyName = "Authors";
this.dataGridViewTextBoxColumn2.HeaderText = "Authors";
this.dataGridViewTextBoxColumn2.Name = "dataGridViewTextBoxColumn2";
this.dataGridViewTextBoxColumn2.ReadOnly = true;
//
// dataGridViewTextBoxColumn3
//
this.dataGridViewTextBoxColumn3.DataPropertyName = "Narrators";
this.dataGridViewTextBoxColumn3.HeaderText = "Narrators";
this.dataGridViewTextBoxColumn3.Name = "dataGridViewTextBoxColumn3";
this.dataGridViewTextBoxColumn3.ReadOnly = true;
//
// dataGridViewTextBoxColumn4
//
this.dataGridViewTextBoxColumn4.DataPropertyName = "Length";
this.dataGridViewTextBoxColumn4.HeaderText = "Length";
this.dataGridViewTextBoxColumn4.Name = "dataGridViewTextBoxColumn4";
this.dataGridViewTextBoxColumn4.ReadOnly = true;
//
// dataGridViewTextBoxColumn5
//
this.dataGridViewTextBoxColumn5.DataPropertyName = "Series";
this.dataGridViewTextBoxColumn5.HeaderText = "Series";
this.dataGridViewTextBoxColumn5.Name = "dataGridViewTextBoxColumn5";
this.dataGridViewTextBoxColumn5.ReadOnly = true;
//
// dataGridViewTextBoxColumn6
//
this.dataGridViewTextBoxColumn6.DataPropertyName = "Description";
this.dataGridViewTextBoxColumn6.HeaderText = "Description";
this.dataGridViewTextBoxColumn6.Name = "dataGridViewTextBoxColumn6";
this.dataGridViewTextBoxColumn6.ReadOnly = true;
//
// dataGridViewTextBoxColumn7
//
this.dataGridViewTextBoxColumn7.DataPropertyName = "Category";
this.dataGridViewTextBoxColumn7.HeaderText = "Category";
this.dataGridViewTextBoxColumn7.Name = "dataGridViewTextBoxColumn7";
this.dataGridViewTextBoxColumn7.ReadOnly = true;
//
// dataGridViewTextBoxColumn8
//
this.dataGridViewTextBoxColumn8.DataPropertyName = "Product_Rating";
this.dataGridViewTextBoxColumn8.HeaderText = "Product_Rating";
this.dataGridViewTextBoxColumn8.Name = "dataGridViewTextBoxColumn8";
this.dataGridViewTextBoxColumn8.ReadOnly = true;
//
// dataGridViewTextBoxColumn9
//
this.dataGridViewTextBoxColumn9.DataPropertyName = "Purchase_Date";
this.dataGridViewTextBoxColumn9.HeaderText = "Purchase_Date";
this.dataGridViewTextBoxColumn9.Name = "dataGridViewTextBoxColumn9";
this.dataGridViewTextBoxColumn9.ReadOnly = true;
//
// dataGridViewTextBoxColumn10
//
this.dataGridViewTextBoxColumn10.DataPropertyName = "My_Rating";
this.dataGridViewTextBoxColumn10.HeaderText = "My_Rating";
this.dataGridViewTextBoxColumn10.Name = "dataGridViewTextBoxColumn10";
this.dataGridViewTextBoxColumn10.ReadOnly = true;
//
// dataGridViewTextBoxColumn11
//
this.dataGridViewTextBoxColumn11.DataPropertyName = "Misc";
this.dataGridViewTextBoxColumn11.HeaderText = "Misc";
this.dataGridViewTextBoxColumn11.Name = "dataGridViewTextBoxColumn11";
this.dataGridViewTextBoxColumn11.ReadOnly = true;
//
// dataGridViewTextBoxColumn12
//
this.dataGridViewTextBoxColumn12.DataPropertyName = "Download_Status";
this.dataGridViewTextBoxColumn12.HeaderText = "Download_Status";
this.dataGridViewTextBoxColumn12.Name = "dataGridViewTextBoxColumn12";
this.dataGridViewTextBoxColumn12.ReadOnly = true;
//
// ProductsGrid
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.Controls.Add(this.gridEntryDataGridView);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(434, 329);
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
this.ResumeLayout(false);
this.dataGridViewTextBoxColumn11});
this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource;
this.gridEntryDataGridView.Location = new System.Drawing.Point(54, 58);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.Size = new System.Drawing.Size(300, 220);
this.gridEntryDataGridView.TabIndex = 0;
//
// dataGridViewImageColumn1
//
this.dataGridViewImageColumn1.DataPropertyName = "Cover";
this.dataGridViewImageColumn1.HeaderText = "Cover";
this.dataGridViewImageColumn1.Name = "dataGridViewImageColumn1";
this.dataGridViewImageColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn1
//
this.dataGridViewTextBoxColumn1.DataPropertyName = "Title";
this.dataGridViewTextBoxColumn1.HeaderText = "Title";
this.dataGridViewTextBoxColumn1.Name = "dataGridViewTextBoxColumn1";
this.dataGridViewTextBoxColumn1.ReadOnly = true;
//
// dataGridViewTextBoxColumn2
//
this.dataGridViewTextBoxColumn2.DataPropertyName = "Authors";
this.dataGridViewTextBoxColumn2.HeaderText = "Authors";
this.dataGridViewTextBoxColumn2.Name = "dataGridViewTextBoxColumn2";
this.dataGridViewTextBoxColumn2.ReadOnly = true;
//
// dataGridViewTextBoxColumn3
//
this.dataGridViewTextBoxColumn3.DataPropertyName = "Narrators";
this.dataGridViewTextBoxColumn3.HeaderText = "Narrators";
this.dataGridViewTextBoxColumn3.Name = "dataGridViewTextBoxColumn3";
this.dataGridViewTextBoxColumn3.ReadOnly = true;
//
// dataGridViewTextBoxColumn4
//
this.dataGridViewTextBoxColumn4.DataPropertyName = "Length";
this.dataGridViewTextBoxColumn4.HeaderText = "Length";
this.dataGridViewTextBoxColumn4.Name = "dataGridViewTextBoxColumn4";
this.dataGridViewTextBoxColumn4.ReadOnly = true;
//
// dataGridViewTextBoxColumn5
//
this.dataGridViewTextBoxColumn5.DataPropertyName = "Series";
this.dataGridViewTextBoxColumn5.HeaderText = "Series";
this.dataGridViewTextBoxColumn5.Name = "dataGridViewTextBoxColumn5";
this.dataGridViewTextBoxColumn5.ReadOnly = true;
//
// dataGridViewTextBoxColumn6
//
this.dataGridViewTextBoxColumn6.DataPropertyName = "Description";
this.dataGridViewTextBoxColumn6.HeaderText = "Description";
this.dataGridViewTextBoxColumn6.Name = "dataGridViewTextBoxColumn6";
this.dataGridViewTextBoxColumn6.ReadOnly = true;
//
// dataGridViewTextBoxColumn7
//
this.dataGridViewTextBoxColumn7.DataPropertyName = "Category";
this.dataGridViewTextBoxColumn7.HeaderText = "Category";
this.dataGridViewTextBoxColumn7.Name = "dataGridViewTextBoxColumn7";
this.dataGridViewTextBoxColumn7.ReadOnly = true;
//
// dataGridViewTextBoxColumn8
//
this.dataGridViewTextBoxColumn8.DataPropertyName = "Product_Rating";
this.dataGridViewTextBoxColumn8.HeaderText = "Product_Rating";
this.dataGridViewTextBoxColumn8.Name = "dataGridViewTextBoxColumn8";
this.dataGridViewTextBoxColumn8.ReadOnly = true;
//
// dataGridViewTextBoxColumn9
//
this.dataGridViewTextBoxColumn9.DataPropertyName = "Purchase_Date";
this.dataGridViewTextBoxColumn9.HeaderText = "Purchase_Date";
this.dataGridViewTextBoxColumn9.Name = "dataGridViewTextBoxColumn9";
this.dataGridViewTextBoxColumn9.ReadOnly = true;
//
// dataGridViewTextBoxColumn10
//
this.dataGridViewTextBoxColumn10.DataPropertyName = "My_Rating";
this.dataGridViewTextBoxColumn10.HeaderText = "My_Rating";
this.dataGridViewTextBoxColumn10.Name = "dataGridViewTextBoxColumn10";
this.dataGridViewTextBoxColumn10.ReadOnly = true;
//
// dataGridViewTextBoxColumn11
//
this.dataGridViewTextBoxColumn11.DataPropertyName = "Misc";
this.dataGridViewTextBoxColumn11.HeaderText = "Misc";
this.dataGridViewTextBoxColumn11.Name = "dataGridViewTextBoxColumn11";
this.dataGridViewTextBoxColumn11.ReadOnly = true;
//
// ProductsGrid
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.Controls.Add(this.gridEntryDataGridView);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(434, 329);
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.BindingSource gridEntryBindingSource;
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.DataGridViewImageColumn dataGridViewImageColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn2;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn3;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn4;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn5;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn6;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn7;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn8;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn9;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn10;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn11;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn12;
}
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.DataGridViewImageColumn dataGridViewImageColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn1;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn2;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn3;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn4;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn5;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn6;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn7;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn8;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn9;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn10;
private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn11;
}
}

View File

@@ -6,5 +6,5 @@
cause the file to be unrecognizable by the program.
-->
<GenericObjectDataSource DisplayName="GridEntry" Version="1.0" xmlns="urn:schemas-microsoft-com:xml-msdatasource">
<TypeInfo>LibationWinForm.ProductGrids.GridEntry, LibationWinForm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
<TypeInfo>WinFormsDesigner.GridEntry, WinFormsDesigner, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
</GenericObjectDataSource>

View File

@@ -166,7 +166,7 @@
<EmbeddedResource Include="ProductsGrid.resx">
<DependentUpon>ProductsGrid.cs</DependentUpon>
</EmbeddedResource>
<None Include="Properties\DataSources\LibationWinForm_Framework.ProductGrids.GridEntry.datasource" />
<None Include="Properties\DataSources\WinFormsDesigner.GridEntry.datasource" />
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon />
<StartupObject />

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
<RootNamespace>ffmpeg_decrypt</RootNamespace>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>

View File

@@ -5,7 +5,9 @@ using System.Threading.Tasks;
using System.Windows.Forms;
using AaxDecrypter;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using Dinah.Core.Windows.Forms;
using Serilog;
namespace inAudibleLite
{
@@ -23,18 +25,44 @@ namespace inAudibleLite
InitializeComponent();
this.btnConvert.Enabled = false;
initLogging();
initSerilog();
redirectWriteLine();
}
private void initLogging()
private static void initSerilog()
{
// default. for reference. output example:
// 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
var default_outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
// with class and method info. output example:
// 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForm.Program.init()) Begin Libation
var code_outputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
var logPath = Path.Combine(Path.GetTempPath(), "Log.log");
Log.Logger = new LoggerConfiguration()
.Enrich.WithCaller()
.MinimumLevel.Debug()
.WriteTo.File(logPath,
rollingInterval: RollingInterval.Month,
outputTemplate: code_outputTemplate)
.CreateLogger();
Log.Logger.Debug("Begin Libation");
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
private void redirectWriteLine()
{
// redirect Console.WriteLine to console, log file, textbox
var origOut = Console.Out;
var controlWriter = new RichTextBoxTextWriter(this.rtbLog);
var tempPath = Path.GetTempPath();
var logger1 = new FileLogger(Path.Combine(tempPath, APP_NAME));
var logger2 = new FileLoggerTextWriter(logger1);
var multiLogger = new MultiTextWriter(origOut, controlWriter, logger2);
var multiLogger = new MultiTextWriter(
Console.Out,
new RichTextBoxTextWriter(this.rtbLog),
new SerilogTextWriter());
Console.SetOut(multiLogger);
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>

View File

@@ -1,213 +0,0 @@
-- begin BETA ---------------------------------------------------------------------------------------------------------------------
FINAL PRE-BETA TEST
create release
v3.1 beta
update REFERENCE.txt with this release
publish exe and attach it to the beta release
start beta: contact beta members
CREATE INSTALLER
see REFERENCE.txt > HOW TO PUBLISH
RELEASE TO BETA
Note: run Libation.exe -- icon is a black wine glass
I recommend making a shortcut. I'm working on a more manageable install but it's low priority
Warn of known performance issues
- Library import
- Tag add/edit
- Grid is slow to respond loading when books aren't liberated
- get decrypt key -- unavoidable
- images can take a bit to initially load. downloading is throttled as to not get the IP blocked by audible
-- end BETA ---------------------------------------------------------------------------------------------------------------------
-- begin SINGLE FILE PUBLISH ---------------------------------------------------------------------------------------------------------------------
SINGLE FILE. FUTURE FIX
<!-- <PublishSingleFile>true</PublishSingleFile> -->
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
Runs from weird temp location
- Weird default location for files
- Cant find json
- don't have external exe.s
-- end SINGLE FILE PUBLISH ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, IMPORT UI ---------------------------------------------------------------------------------------------------------------------
scan library in background?
can include a notice somewhere that a scan is in-process
why block the UI at all?
what to do if new books? don't want to refresh grid when user isn't expecting it
-- end ENHANCEMENT, IMPORT UI ---------------------------------------------------------------------------------------------------------------------
-- begin BUG, FILE DOWNLOAD ---------------------------------------------------------------------------------------------------------------------
reproduce: try to do the api download with a bad codec
result: DownloadsFinal dir .aax file 1 kb
this resulted from an exception. we should not be keeping a file after exception
if error: show error. DownloadBook delete bad file
-- end BUG, FILE DOWNLOAD ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, PERFORMANCE: IMPORT ---------------------------------------------------------------------------------------------------------------------
imports are PAINFULLY slow for just a few hundred items. wtf is taking so long?
-- end ENHANCEMENT, PERFORMANCE: IMPORT ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, PERFORMANCE: GRID ---------------------------------------------------------------------------------------------------------------------
when a book/pdf is NOT liberated, calculating the grid's [Liberated][NOT d/l'ed] label is very slow. use something similar to PictureStorage's timer to run on a separate thread
https://stackoverflow.com/a/12046333
https://codereview.stackexchange.com/a/135074
// do NOT use lock() or Monitor with async/await
private static int _lockFlag = 0; // 0 - free
if (Interlocked.CompareExchange(ref _lockFlag, 1, 0) != 0) return;
// only 1 thread will enter here without locking the object/put the other threads to sleep
try { await DoWorkAsync(); }
// free the lock
finally { Interlocked.Decrement(ref _lockFlag); }
use stop light icons for liberated state: red=none, yellow=downloaded encrypted, green=liberated
need a way to liberate ad hoc books and pdf.s
use pdf icon with and without and X over it to indicate status
-- end ENHANCEMENT, PERFORMANCE: GRID ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, GET LIBRARY ---------------------------------------------------------------------------------------------------------------------
Audible API. GET /1.0/library , GET /1.0/library/{asin}
TONS of expensive conversion: GetLibraryAsync > string > JObject > string > LibraryDtoV10
same for GetLibraryBookAsync > ... > BookDtoV10
-- end ENHANCEMENT, GET LIBRARY ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, DEBUGGING ---------------------------------------------------------------------------------------------------------------------
datalayer stuff (eg: Book) need better ToString
-- end ENHANCEMENT, DEBUGGING ---------------------------------------------------------------------------------------------------------------------
-- begin BUG, MOVING FILES ---------------------------------------------------------------------------------------------------------------------
with libation closed, move files
start libation
can get error below
fixed on restart
Form1_Load ... await setBackupCountsAsync();
Collection was modified; enumeration operation may not execute.
stack trace
at System.ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion()
at System.Collections.Generic.List`1.Enumerator.MoveNextRare()
at System.Linq.Enumerable.SingleOrDefault[TSource](IEnumerable`1 source, Func`2 predicate)
at FileManager.FilePathCache.GetPath(String id, FileType type) in \Libation\FileManager\UNTESTED\FilePathCache.cs:line 33
at FileManager.AudibleFileStorage.<getAsync>d__32.MoveNext() in \Libation\FileManager\UNTESTED\AudibleFileStorage.cs:line 112
at FileManager.AudibleFileStorage.<GetAsync>d__31.MoveNext() in \Libation\FileManager\UNTESTED\AudibleFileStorage.cs:line 107
at FileManager.AudibleFileStorage.<ExistsAsync>d__30.MoveNext() in \Libation\FileManager\UNTESTED\AudibleFileStorage.cs:line 104
at LibationWinForm.Form1.<<setBookBackupCountsAsync>g__getAudioFileStateAsync|15_1>d.MoveNext() in \Libation\LibationWinForm\UNTESTED\Form1.cs:line 110
at LibationWinForm.Form1.<setBookBackupCountsAsync>d__15.MoveNext() in \Libation\LibationWinForm\UNTESTED\Form1.cs:line 117
at LibationWinForm.Form1.<setBackupCountsAsync>d__13.MoveNext() in \Libation\LibationWinForm\UNTESTED\Form1.cs:line 81
at LibationWinForm.Form1.<Form1_Load>d__11.MoveNext() in \Libation\LibationWinForm\UNTESTED\Form1.cs:line 60
-- end BUG, MOVING FILES ---------------------------------------------------------------------------------------------------------------------
-- begin CONFIG FILES ---------------------------------------------------------------------------------------------------------------------
.\appsettings.json should only be a pointer to the real settings file location: LibationSettings.json
replace complex config saving throughout with new way in my ConsoleDependencyInjection solution
all settings should be strongly typed
re-create my shortcuts and bak
for appsettings.json to get copied in the single-file release, project must incl <ExcludeFromSingleFile>true
multiple files named "appsettings.json" will overwrite each other
libraries should avoid this generic name. in general: ok for applications to use them
there are exceptions: datalayer has appsettings which is copied to winform. if winform uses appsettings also, it will override datalayer's
Audible API
\AudibleApi\_Tests\AudibleApi.Tests\bin\Debug\netcoreapp3.0\L1
\AudibleApi\_Tests\AudibleApi.Tests\bin\Debug\netcoreapp3.0\ComputedTestValues
14+ json files
these can go in a shared solution folder
BasePath => recursively search directories upward-only until fild dir with .sln
from here can set up a shared dir anywhere. use recursive upward search to find it. store shared files here. eg: identityTokens.json, ComputedTestValues
-- end CONFIG FILES ---------------------------------------------------------------------------------------------------------------------
-- begin TAGS ---------------------------------------------------------------------------------------------------------------------
pulling previous tags into new Books. think: reloading db
move out of Book and into DtoMapper?
Extract file and tag stuff from domain objects. This should exist only in data layer. If domain objects are able to call EF context, it should go through data layer
Why are tags in file AND database?
why use a relational db? i'm treating it like a nosql db. use LiteDB instead?
extract FileManager dependency from data layer
-- end TAGS ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, PERFORMANCE: TAGS ---------------------------------------------------------------------------------------------------------------------
tag edits still take forever and block UI
unlikely to be an issue with file write. in fact, should probably roll back this change
also touches parts of code which: db write via a hook, search engine re-index
-- end ENHANCEMENT, PERFORMANCE: TAGS ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT, CATEGORIES ---------------------------------------------------------------------------------------------------------------------
add support for multiple categories
when i do this, learn about the different CategoryLadder.Root enums. probably only need Root.Genres
-- end ENHANCEMENT, CATEGORIES ---------------------------------------------------------------------------------------------------------------------
-- begin CLEAN UP ARCHITECTURE ---------------------------------------------------------------------------------------------------------------------
my ui sucks. it's also tightly coupled with biz logic. can't replace ui until biz logic is extracted and loosely. remove all biz logic from presentation/winforms layer
-- end CLEAN UP ARCHITECTURE ---------------------------------------------------------------------------------------------------------------------
-- begin UNIT TESTS ---------------------------------------------------------------------------------------------------------------------
all "UNTESTED" code needs unit tests
Turn into unit tests or demos
TextBoxBaseTextWriter.cs
EnumerationExamples.cs
EnumExt.cs
IEnumerable[T]Ext.cs
MultiTextWriter.cs
Selenium.Examples.cs
ScratchPad.cs
ProcessorAutomationController.Examples.cs
ScraperRules.Examples.cs
ByFactory.Example.cs
search 'example code' on: LibationWinForm\...\Form1.cs
EnumerationFlagsExtensions.EXAMPLES()
// examples
scratchpad
scratch pad
scratch_pad
-- end UNIT TESTS ---------------------------------------------------------------------------------------------------------------------
-- begin DECRYPTING ---------------------------------------------------------------------------------------------------------------------
replace inaudible/inaudible lite with pure ffmpeg
benefits of inaudible:
highly configurable
embedded cover image
chapter-ized
cue and nfo files
can hopefully get most of this with simple decrypt. possibly including the new chapter titles
better chapters in many m4b files. to see, try re-downloading. examples: bobiverse, sharp objects
raw ffmpeg decrypting is significantly faster than inAudible/AaxDecrypter method:
inAudible/AaxDecrypter
tiny file
40 sec decrypt
40 sec chapterize + tag
huge file
60 sec decrypt
120 sec chapterize + tag
directly call ffmpeg (decrypt only)
tiny file
17 sec decrypt
huge file
39 sec decrypt
-- end DECRYPTING ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT: REMOVE BOOK ---------------------------------------------------------------------------------------------------------------------
how to remove a book?
previously difficult due to implementation details regarding scraping and importing. should now be trivial
-- end ENHANCEMENT: REMOVE BOOK ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT: NEW VIEWS ---------------------------------------------------------------------------------------------------------------------
menu views. filter could work for grid display; just use the lucene query language
1) menu to show all tags and count of each. click on tag so see only those books
2) tree to show all categories and subcategories. click on category so see only those books
-- end ENHANCEMENT: NEW VIEWS ---------------------------------------------------------------------------------------------------------------------
-- begin ENHANCEMENT: LOGGING, ERROR HANDLING ---------------------------------------------------------------------------------------------------------------------
LibationWinForm and Audible API need better logging and error handling
incl log levels, db query logging
see AaxDecryptorWinForms.initLogging()
-- end ENHANCEMENT: LOGGING ---------------------------------------------------------------------------------------------------------------------