Compare commits

...

10 Commits

Author SHA1 Message Date
Robert McRackan
7a4bd639fb Add notes for v3.1-beta.5 2019-11-25 14:12:07 -05:00
Robert McRackan
87e6a46808 remove db file 2019-11-25 14:09:53 -05:00
Robert McRackan
a2e30df51f Improved importing 2019-11-25 13:45:29 -05:00
Robert McRackan
c8e759c067 update notes 2019-11-24 21:47:03 -05:00
Robert McRackan
6c9074169a Added beta-specific logging 2019-11-24 21:45:35 -05:00
Robert McRackan
1375da2065 Improved performance calculating "liberated" status 2019-11-21 23:07:06 -05:00
Robert McRackan
d5d72a13f6 Login dialogs can get lost. Show on task bar 2019-11-20 13:00:13 -05:00
Robert McRackan
a1ba324166 Has PDFs => Has PDF 2019-11-19 13:34:38 -05:00
Robert McRackan
b0139c47be live update newly downloaded and cached images 2019-11-19 11:22:41 -05:00
Robert McRackan
80b0ef600d Better ToString for DataLayer objects 2019-11-19 09:54:42 -05:00
46 changed files with 479 additions and 536 deletions

1
.gitignore vendored
View File

@@ -332,3 +332,4 @@ ASALocalRun/
# manually ignored files
/__TODO.txt
/DataLayer/LibationContext.db

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,8 @@ namespace DataLayer
string description,
int lengthInMinutes,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators)
IEnumerable<Contributor> narrators,
Category category)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
@@ -80,8 +81,7 @@ namespace DataLayer
_seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>();
// since category/id is never null, nullity means it hasn't been loaded
CategoryId = Category.GetEmpty().CategoryId;
Category = category;
// simple assigns
Title = title;
@@ -91,7 +91,7 @@ namespace DataLayer
// assigns with biz logic
ReplaceAuthors(authors);
ReplaceNarrators(narrators);
}
}
#region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded
@@ -206,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)
{
@@ -233,8 +233,8 @@ namespace DataLayer
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category != null || CategoryId == Category.GetEmpty().CategoryId)
// since category is never null, nullity means it hasn't been loaded. non null means we're correctly loaded. just overwrite
if (Category != null)
{
Category = category;
return;
@@ -246,5 +246,7 @@ namespace DataLayer
context.Entry(this).Reference(s => s.Category).Load();
Category = category;
}
}
public override string ToString() => $"[{AudibleProductId}] {Title}";
}
}

View File

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

View File

@@ -18,8 +18,7 @@ namespace DataLayer
public class Category
{
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "", ParentCategory = null };
public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null;
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }
@@ -48,5 +47,7 @@ namespace DataLayer
if (parentCategory != null)
ParentCategory = parentCategory;
}
}
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -78,5 +78,7 @@ namespace DataLayer
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
}
public override string ToString() => $"{Book} {Rating} {Tags}";
}
}

View File

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

View File

@@ -51,77 +51,87 @@ 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();
// 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();
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);
}
book = createNewBook(item, context);
qtyNew++;
}
// 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);
// 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)
{
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
item.Authors = new[] { new Person { Name = "", Asin = null } };
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = item
.Authors
.Select(a => context.Contributors.Local.Single(c => a.Name == c.Name))
.ToList();
var narrators
= item.Narrators is null || !item.Narrators.Any()
// if no narrators listed, author is the narrator
? authors
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
: item
.Narrators
.Select(n => context.Contributors.Local.Single(c => n.Name == c.Name))
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is very rare, but possible
var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
var book = context.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.Title,
item.Description,
item.LengthInMinutes,
authors,
narrators,
category)
).Entity;
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
book.ReplacePublisher(publisher);
}
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
book.AddSupplementDownloadUrl(item.SupplementUrl);
return book;
}
private static void updateBook(Item item, Book book, LibationContext context)
{
// set/update book-specific info which may have changed
book.PictureId = item.PictureId;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
// update series even for existing books. these are occasionally updated
// these will upsert over library-scraped series, but will not leave orphans
if (item.Series != null)
{
foreach (var seriesEntry in item.Series)
{
var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
book.UpsertSeries(series, seriesEntry.Index);
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,16 +17,16 @@ namespace FileLiberator
/// </summary>
public class DownloadBook : DownloadableBase
{
public override async Task<bool> ValidateAsync(LibraryBook libraryBook)
=> !await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId)
&& !await AudibleFileStorage.AAX.ExistsAsync(libraryBook.Book.AudibleProductId);
public override bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId)
&& !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId);
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var tempAaxFilename = getDownloadPath(libraryBook);
var actualFilePath = await downloadBookAsync(libraryBook, tempAaxFilename);
moveBook(libraryBook, actualFilePath);
return await verifyDownloadAsync(libraryBook);
return verifyDownload(libraryBook);
}
private static string getDownloadPath(LibraryBook libraryBook)
@@ -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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ namespace LibationWinForm
beginPdfBackupsToolStripMenuItem_format = beginPdfBackupsToolStripMenuItem.Text;
}
private async void Form1_Load(object sender, EventArgs e)
private void Form1_Load(object sender, EventArgs e)
{
// call static ctor. There are bad race conditions if static ctor is first executed when we're running in parallel in setBackupCountsAsync()
var foo = FilePathCache.JsonFile;
@@ -59,7 +59,7 @@ namespace LibationWinForm
backupsCountsLbl.Text = "[Calculating backed up book quantities]";
pdfsCountsLbl.Text = "[Calculating backed up PDFs]";
await setBackupCountsAsync();
setBackupCounts();
}
}
@@ -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

View File

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

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
@@ -6,6 +7,7 @@ using ApplicationServices;
using DataLayer;
using Dinah.Core.Collections.Generic;
using Dinah.Core.DataBinding;
using Dinah.Core.Windows.Forms;
namespace LibationWinForm
{
@@ -32,7 +34,10 @@ namespace LibationWinForm
public ProductsGrid()
{
InitializeComponent();
Disposed += (_, __) => { if (context != null) context.Dispose(); };
Disposed += (_, __) => context?.Dispose();
manageLiveImageUpdateSubscriptions();
}
private bool hasBeenDisplayed = false;
@@ -232,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;
}
}
}

View File

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

View File

@@ -1,6 +1,10 @@
-- begin VERSIONING ---------------------------------------------------------------------------------------------------------------------
https://github.com/rmcrackan/Libation/releases
v3.1-beta.5 : Improved importing
v3.1-beta.4 : Added beta-specific logging
v3.1-beta.3 : fixed known performance issue: Full-screen grid is slow to respond loading when books aren't liberated
v3.1-beta.2 : fixed known performance issue: Tag add/edit
v3.1-beta.1 : RELEASE TO BETA
v3.0.3 : Switch to SQLite. No longer relies on LocalDB, which must be installed separately
v3.0.2 : Final using LocalDB
@@ -38,19 +42,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
@@ -74,31 +65,4 @@ aggregate root is transactional boundary
// // test with and without : using TransactionScope scope = new TransactionScope();
//System.Transactions.Transaction.Current.TransactionCompleted += (sender, e) => { };
// also : https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction.enlistvolatile
pattern when using 1 db context per form
public Ctor()
{
InitializeComponent();
// dispose context here only. DO NOT dispose in OnParentChanged(). parent form will call dispose after this one has been switched.
// disposing context prematurely can result in:
// The ObjectContext instance has been disposed and can no longer be used for operations that require a connection.
this.Disposed += (_, __) => context?.Dispose();
}
-- end EF CORE ---------------------------------------------------------------------------------------------------------------------
-- begin ASYNC/AWAIT ---------------------------------------------------------------------------------------------------------------------
Using Async and Await to update the UI Thread <20> Stephen Haunts { Coding in the Trenches }
https://stephenhaunts.com/2014/10/14/using-async-and-await-to-update-the-ui-thread/
Using Async and Await to update the UI Thread Part 2 <20> Stephen Haunts { Coding in the Trenches }
https://stephenhaunts.com/2014/10/16/using-async-and-await-to-update-the-ui-thread-part-2/
Simple Async Await Example for Asynchronous Programming <20> Stephen Haunts { Coding in the Trenches }
https://stephenhaunts.com/2014/10/10/simple-async-await-example-for-asynchronous-programming/
Async and Await -- Stephen Cleary's famous intro
https://blog.stephencleary.com/2012/02/async-and-await.html
Async-Await - Best Practices in Asynchronous Programming -- Stephen Cleary
https://msdn.microsoft.com/en-us/magazine/jj991977.aspx
-- end ASYNC/AWAIT ---------------------------------------------------------------------------------------------------------------------

View File

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

View File

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

View File

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