mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 18:38:01 -05:00
Compare commits
9 Commits
v3.1b
...
v3.1-beta.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e759c067 | ||
|
|
6c9074169a | ||
|
|
1375da2065 | ||
|
|
d5d72a13f6 | ||
|
|
a1ba324166 | ||
|
|
b0139c47be | ||
|
|
80b0ef600d | ||
|
|
f3128b562d | ||
|
|
6734dec55c |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -328,3 +328,7 @@ ASALocalRun/
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
|
||||
# manually ignored files
|
||||
/__TODO.txt
|
||||
|
||||
@@ -23,14 +23,12 @@ namespace ApplicationServices
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
|
||||
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);
|
||||
book.UserDefinedItem.Tags = newTags;
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
|
||||
// this part is tags-specific
|
||||
if (qtyChanges > 0)
|
||||
SearchEngineCommands.UpdateBookTags(book);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace ApplicationServices
|
||||
|
||||
BIN
DataLayer/LibationContext.db
Normal file
BIN
DataLayer/LibationContext.db
Normal file
Binary file not shown.
@@ -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("20191119144803_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,14 +116,13 @@ 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");
|
||||
|
||||
@@ -140,10 +134,10 @@ namespace DataLayer.Migrations
|
||||
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 +148,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 +166,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 +194,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 +217,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 +238,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 +253,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");
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
@@ -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,14 +114,13 @@ 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");
|
||||
|
||||
@@ -138,10 +132,10 @@ namespace DataLayer.Migrations
|
||||
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 +146,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 +164,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 +192,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 +215,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 +236,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 +251,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");
|
||||
|
||||
|
||||
@@ -61,12 +61,17 @@ namespace DataLayer
|
||||
string title,
|
||||
string description,
|
||||
int lengthInMinutes,
|
||||
IEnumerable<Contributor> authors)
|
||||
IEnumerable<Contributor> authors,
|
||||
IEnumerable<Contributor> narrators)
|
||||
{
|
||||
// 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
|
||||
@@ -79,19 +84,13 @@ namespace DataLayer
|
||||
CategoryId = Category.GetEmpty().CategoryId;
|
||||
|
||||
// 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
|
||||
@@ -207,7 +206,7 @@ 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)
|
||||
{
|
||||
@@ -247,5 +246,7 @@ namespace DataLayer
|
||||
context.Entry(this).Reference(s => s.Category).Load();
|
||||
Category = category;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,7 @@ namespace DataLayer
|
||||
Role = role;
|
||||
Order = order;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Contributor} {Role} {Order}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,5 +48,7 @@ namespace DataLayer
|
||||
if (parentCategory != null)
|
||||
ParentCategory = parentCategory;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace DataLayer
|
||||
private HashSet<BookContributor> _booksLink;
|
||||
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
|
||||
|
||||
public string AudibleContributorId { get; private set; }
|
||||
|
||||
private Contributor() { }
|
||||
public Contributor(string name)
|
||||
{
|
||||
@@ -34,49 +36,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,7 @@ namespace DataLayer
|
||||
Book = book;
|
||||
DateAdded = dateAdded;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,5 +72,7 @@ namespace DataLayer
|
||||
|
||||
return string.Join("\r\n", items);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,7 @@ namespace DataLayer
|
||||
if (index.HasValue)
|
||||
Index = index.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"Series={Series} Book={Book}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,7 @@ namespace DataLayer
|
||||
Book = book;
|
||||
Url = url;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Book} {Url.Substring(Url.Length - 4)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,57 +4,35 @@ using System.Linq;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
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)
|
||||
{
|
||||
// persist tags:
|
||||
var modifiedEntities = context
|
||||
.ChangeTracker
|
||||
.Entries()
|
||||
.Where(p => p.State.In(EntityState.Modified, EntityState.Added))
|
||||
.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
|
||||
persistTags(modifiedEntities);
|
||||
}
|
||||
|
||||
//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
|
||||
}
|
||||
private static void persistTags(List<EntityEntry> modifiedEntities)
|
||||
{
|
||||
var tagSets = modifiedEntities
|
||||
.Select(e => e.Entity as UserDefinedItem)
|
||||
// filter by null but NOT by blank. blank is the valid way to show the absence of tags
|
||||
.Where(a => a != null)
|
||||
.ToList();
|
||||
foreach (var t in tagSets)
|
||||
FileManager.TagsPersistence.Save(t.Book.AudibleProductId, t.Tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,75 +51,81 @@ namespace DtoImporterService
|
||||
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;
|
||||
|
||||
book = createNewBook(item, context);
|
||||
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);
|
||||
updateBook(item, book, context);
|
||||
}
|
||||
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
private static Book createNewBook(Item item, LibationContext context)
|
||||
{
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
|
||||
var book = context.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.Title,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
authors,
|
||||
narrators)
|
||||
).Entity;
|
||||
|
||||
var publisherName = item.Publisher;
|
||||
if (!string.IsNullOrWhiteSpace(publisherName))
|
||||
{
|
||||
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -67,11 +64,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,11 +25,8 @@ namespace FileLiberator
|
||||
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
|
||||
|
||||
@@ -34,12 +34,9 @@ namespace FileLiberator
|
||||
public event EventHandler<string> DecryptCompleted;
|
||||
public event EventHandler<string> 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
|
||||
@@ -51,13 +48,13 @@ namespace FileLiberator
|
||||
|
||||
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,7 +69,7 @@ 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;
|
||||
|
||||
@@ -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)
|
||||
@@ -58,8 +58,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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ namespace FileLiberator
|
||||
|
||||
event EventHandler<string> 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);
|
||||
|
||||
@@ -9,8 +9,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,7 +17,7 @@ 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.GetNextValid();
|
||||
if (libraryBook == null)
|
||||
return null;
|
||||
|
||||
@@ -30,19 +29,19 @@ namespace FileLiberator
|
||||
return status;
|
||||
}
|
||||
|
||||
public static async Task<LibraryBook> GetNextValidAsync(this IProcessable processable)
|
||||
public static LibraryBook GetNextValid(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();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly" Version="7.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace FileManager
|
||||
public string Path { get; set; }
|
||||
}
|
||||
|
||||
static List<CacheEntry> inMemoryCache = new List<CacheEntry>();
|
||||
static List<CacheEntry> inMemoryCache { get; } = new List<CacheEntry>();
|
||||
|
||||
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -3,64 +3,57 @@ 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();
|
||||
|
||||
// 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) });
|
||||
|
||||
public static void Save(string productId, string tags)
|
||||
=> System.Threading.Tasks.Task.Run(() => save_fireAndForget(productId, tags));
|
||||
|
||||
private static void save_fireAndForget(string productId, string tags)
|
||||
{
|
||||
ensureCache();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace InternalUtilities
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(ILoginCallback callback)
|
||||
{
|
||||
var api = await EzApiCreator.GetApiAsync(AudibleApiStorage.IdentityTokensFile, callback, Configuration.Instance.LocaleCountryCode);
|
||||
var api = await getApiAsync(callback);
|
||||
var items = await AudibleApiExtensions.GetAllLibraryItemsAsync(api);
|
||||
|
||||
// remove episode parents
|
||||
@@ -62,6 +62,20 @@ namespace InternalUtilities
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task<Api> getApiAsync(ILoginCallback callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await EzApiCreator.GetApiAsync(AudibleApiStorage.IdentityTokensFile, callback, Configuration.Instance.LocaleCountryCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting Audible API");
|
||||
throw;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static List<IValidator> getValidators()
|
||||
{
|
||||
var type = typeof(IValidator);
|
||||
|
||||
@@ -27,8 +27,19 @@ namespace InternalUtilities
|
||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS
|
||||
});
|
||||
|
||||
// important! use this convert method
|
||||
var libResult = LibraryDtoV10.FromJson(page.ToString());
|
||||
string pageStr = null;
|
||||
LibraryDtoV10 libResult;
|
||||
try
|
||||
{
|
||||
pageStr = page.ToString();
|
||||
// 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;
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace LibationWinForm.BookLiberation
|
||||
|
||||
static async Task<StatusHandler> ProcessValidateLibraryBookAsync(IProcessable processable, LibraryBook libraryBook)
|
||||
{
|
||||
if (!await processable.ValidateAsync(libraryBook))
|
||||
if (!processable.Validate(libraryBook))
|
||||
return new StatusHandler { "Validation failed" };
|
||||
return await processable.ProcessAsync(libraryBook);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Windows.Forms;
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using ApplicationServices;
|
||||
|
||||
namespace LibationWinForm
|
||||
@@ -20,9 +21,11 @@ namespace LibationWinForm
|
||||
{
|
||||
(TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.IndexLibraryAsync(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 happens after 2 or 3 tries, contact administrator";
|
||||
Serilog.Log.Logger.Error(ex, msg);
|
||||
MessageBox.Show(msg, "Error importing library", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
|
||||
this.Close();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,52 +103,34 @@ namespace LibationWinForm
|
||||
#endregion
|
||||
|
||||
#region bottom: backup counts
|
||||
private async Task setBackupCountsAsync()
|
||||
private void setBackupCounts()
|
||||
{
|
||||
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 +148,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
|
||||
@@ -258,7 +223,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();
|
||||
@@ -270,7 +235,7 @@ namespace LibationWinForm
|
||||
|
||||
// update backup counts if we have new library items
|
||||
if (newAdded > 0)
|
||||
await setBackupCountsAsync();
|
||||
setBackupCounts();
|
||||
|
||||
if (totalProcessed > 0)
|
||||
reloadGrid();
|
||||
@@ -278,21 +243,21 @@ namespace LibationWinForm
|
||||
#endregion
|
||||
|
||||
#region liberate menu
|
||||
private async void setBackupCountsAsync(object _, string __) => await setBackupCountsAsync();
|
||||
private void setBackupCounts(object _, string __) => setBackupCounts();
|
||||
|
||||
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;
|
||||
backupBook.DownloadBook.Completed += setBackupCounts;
|
||||
backupBook.DecryptBook.Completed += setBackupCounts;
|
||||
backupBook.DownloadPdf.Completed += setBackupCounts;
|
||||
await BookLiberation.ProcessorAutomationController.RunAutomaticBackup(backupBook);
|
||||
}
|
||||
|
||||
private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
{
|
||||
var downloadPdf = BookLiberation.ProcessorAutomationController.GetWiredUpDownloadPdf();
|
||||
downloadPdf.Completed += setBackupCountsAsync;
|
||||
downloadPdf.Completed += setBackupCounts;
|
||||
await BookLiberation.ProcessorAutomationController.RunAutomaticDownload(downloadPdf);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -146,8 +146,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)
|
||||
@@ -168,8 +168,8 @@ namespace LibationWinForm
|
||||
get
|
||||
{
|
||||
var print
|
||||
= FileManager.AudibleFileStorage.Audio.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult() ? "Liberated"
|
||||
: FileManager.AudibleFileStorage.AAX.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult() ? "DRM"
|
||||
= FileManager.AudibleFileStorage.Audio.Exists(book.AudibleProductId) ? "Liberated"
|
||||
: FileManager.AudibleFileStorage.AAX.Exists(book.AudibleProductId) ? "DRM"
|
||||
: "NOT d/l'ed";
|
||||
|
||||
if (!book.Supplements.Any())
|
||||
@@ -178,7 +178,7 @@ namespace LibationWinForm
|
||||
print += "\r\n";
|
||||
|
||||
var downloadStatuses = book.Supplements
|
||||
.Select(d => FileManager.AudibleFileStorage.PDF.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult())
|
||||
.Select(d => FileManager.AudibleFileStorage.PDF.Exists(book.AudibleProductId))
|
||||
// break delayed execution right now!
|
||||
.ToList();
|
||||
var count = downloadStatuses.Count;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -27,10 +29,18 @@ namespace LibationWinForm
|
||||
public event EventHandler<int> VisibleCountChanged;
|
||||
|
||||
private DataGridView dataGridView;
|
||||
private LibationContext context;
|
||||
|
||||
public ProductsGrid() => InitializeComponent();
|
||||
public ProductsGrid()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
private bool hasBeenDisplayed = false;
|
||||
Disposed += (_, __) => context?.Dispose();
|
||||
|
||||
manageLiveImageUpdateSubscriptions();
|
||||
}
|
||||
|
||||
private bool hasBeenDisplayed = false;
|
||||
public void Display()
|
||||
{
|
||||
if (hasBeenDisplayed)
|
||||
@@ -87,10 +97,11 @@ namespace LibationWinForm
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// transform into sorted GridEntry.s BEFORE binding
|
||||
//
|
||||
var lib = LibraryQueries.GetLibrary_Flat_NoTracking();
|
||||
//
|
||||
// transform into sorted GridEntry.s BEFORE binding
|
||||
//
|
||||
context = LibationContext.Create();
|
||||
var lib = context.GetLibrary_Flat_WithTracking();
|
||||
|
||||
// if no data. hide all columns. return
|
||||
if (!lib.Any())
|
||||
@@ -174,8 +185,8 @@ namespace LibationWinForm
|
||||
if (editTagsForm.ShowDialog() != DialogResult.OK)
|
||||
return;
|
||||
|
||||
var qtyChanges = saveChangedTags(liveGridEntry.GetBook(), editTagsForm.NewTags);
|
||||
if (qtyChanges == 0)
|
||||
var qtyChanges = context.UpdateTags(liveGridEntry.GetBook(), editTagsForm.NewTags);
|
||||
if (qtyChanges == 0)
|
||||
return;
|
||||
|
||||
// force a re-draw, and re-apply filters
|
||||
@@ -186,14 +197,6 @@ namespace LibationWinForm
|
||||
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)
|
||||
{
|
||||
@@ -213,22 +216,6 @@ namespace LibationWinForm
|
||||
}
|
||||
#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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region filter
|
||||
string _filterSearchString;
|
||||
private void filter() => Filter(_filterSearchString);
|
||||
@@ -250,12 +237,38 @@ namespace LibationWinForm
|
||||
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
|
||||
#endregion
|
||||
|
||||
private GridEntry getGridEntry(int rowIndex) => (GridEntry)dataGridView.Rows[rowIndex].DataBoundItem;
|
||||
}
|
||||
#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 GridEntry getGridEntry(int rowIndex) => (GridEntry)dataGridView.Rows[rowIndex].DataBoundItem;
|
||||
|
||||
private int getRowId(Func<GridEntry, bool> func)
|
||||
{
|
||||
for (var r = 0; r < dataGridView.RowCount; r++)
|
||||
if (func(getGridEntry(r)))
|
||||
return r;
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using Serilog;
|
||||
|
||||
namespace LibationWinForm
|
||||
{
|
||||
@@ -14,6 +15,8 @@ namespace LibationWinForm
|
||||
if (!createSettings())
|
||||
return;
|
||||
|
||||
init();
|
||||
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
|
||||
@@ -29,7 +32,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 +42,14 @@ Go to Import > Scan Library
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void init()
|
||||
{
|
||||
var logPath = System.IO.Path.Combine(FileManager.Configuration.Instance.LibationFiles, "Log.log");
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.File(logPath, rollingInterval: RollingInterval.Month)
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
-- begin VERSIONING ---------------------------------------------------------------------------------------------------------------------
|
||||
https://github.com/rmcrackan/Libation/releases
|
||||
|
||||
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
|
||||
@@ -37,19 +41,6 @@ alternate book id (eg BK_RAND_006061) is called 'sku' , 'sku_lite' , 'prod_id' ,
|
||||
-- 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 +64,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 ---------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
213
__TODO.txt
213
__TODO.txt
@@ -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
|
||||
- Can’t 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 ---------------------------------------------------------------------------------------------------------------------
|
||||
Reference in New Issue
Block a user