Compare commits

...

21 Commits

Author SHA1 Message Date
Robert
f4e7cf3418 Bugfix #1403 : Trash bin actions result in app crashes 2025-11-01 13:32:19 -04:00
MBucari
8492a7ea3a Properly disposed of LibationContext (#1403) 2025-10-31 13:45:07 -06:00
rmcrackan
5589a6cbd5 Merge pull request #1401 from rmcrackan/dependabot/github_actions/actions/upload-artifact-5
Bump actions/upload-artifact from 4 to 5
2025-10-27 11:22:19 -04:00
dependabot[bot]
bfbad988c0 Bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 15:14:03 +00:00
rmcrackan
8db8615713 Merge pull request #1400 from rmcrackan/dependabot/github_actions/actions/download-artifact-6
Bump actions/download-artifact from 5 to 6
2025-10-27 11:07:00 -04:00
dependabot[bot]
e8fa3f14b3 Bump actions/download-artifact from 5 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 14:59:43 +00:00
Robert
71552f2417 incr ver 2025-10-23 09:45:55 -04:00
rmcrackan
5b96f96a80 Merge pull request #1390 from Mbucari/master
Bug Fixes and attempts to solve random crashes
2025-10-23 09:43:23 -04:00
Michael Bucari-Tovo
afffeb953c Enforce sequential access to DbContext. 2025-10-22 09:22:05 -06:00
Mbucari
f4d8685058 Merge branch 'rmcrackan:master' into master 2025-10-22 09:16:49 -06:00
Robert
b4cfd18976 incr ver 2025-10-20 21:12:43 -04:00
rmcrackan
e12548dacd Merge pull request #1388 from delebash/master
Added IncludedUntil Date
2025-10-20 21:10:46 -04:00
delebash
fd95ac7a9c changes per Mbucari 2025-10-20 16:31:06 -04:00
delebash
f7cd2b106b fix migration files 2025-10-20 14:12:04 -04:00
delebash
07e51f2191 IncludedUntil migration 2025-10-20 13:09:41 -04:00
delebash
fcd79c5561 InludedUntil fixes by Mbucari 2025-10-20 12:55:48 -04:00
delebash
ba98820989 move code to LibraryBook 2025-10-18 02:05:56 -04:00
delebash
b07e61e6a8 fix UntilDate 2025-10-17 13:49:37 -04:00
delebash
8c3fd19c70 Merge remote-tracking branch 'origin/master' 2025-10-17 13:36:21 -04:00
delebash
fa8f761771 Added IncludedUntil Date 2025-10-17 13:36:01 -04:00
Michael Bucari-Tovo
39e9f675d2 Fix possible race error on startup with autoscan 2025-08-26 10:00:21 -06:00
25 changed files with 802 additions and 114 deletions

View File

@@ -124,7 +124,7 @@ jobs:
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}

View File

@@ -110,7 +110,7 @@ jobs:
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
- name: Publish artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip

View File

@@ -40,7 +40,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: artifacts
pattern: "*(Classic-)Libation.*"

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.5.4.1</Version>
<Version>12.5.7.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -9,7 +9,7 @@ namespace ApplicationServices
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString);
=> InstanceQueue<LibationContext>.WaitToCreateInstance(() => LibationContext.Create(SqliteStorage.ConnectionString));
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)

View File

@@ -73,7 +73,8 @@ namespace DataLayer
ContentType contentType,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
string localeName)
string localeName
)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));

View File

@@ -13,7 +13,8 @@ namespace DataLayer
public bool IsDeleted { get; set; }
public bool AbsentFromLastScan { get; set; }
public DateTime? IncludedUntil { get; private set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{
@@ -26,7 +27,7 @@ namespace DataLayer
}
public void SetAccount(string account) => Account = account;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}
public void SetIncludedUntil(DateTime? includedUntil) => IncludedUntil = includedUntil;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

View File

@@ -0,0 +1,103 @@
using System;
using System.Diagnostics;
using System.Threading;
#nullable enable
namespace DataLayer;
/// <summary> Notifies clients that the object is being disposed. </summary>
public interface INotifyDisposed : IDisposable
{
/// <summary> Event raised when the object is disposed. </summary>
event EventHandler? ObjectDisposed;
}
/// <summary> Creates a single instance of <typeparamref name="TDisposable"/> at a time, blocking subsequent creations until the previous creations are disposed. </summary>
public static class InstanceQueue<TDisposable> where TDisposable : INotifyDisposed
{
/// <summary> Synchronization object for access to <see cref="LastInLine"/>"/> </summary>
private static Lock Locker { get; } = new();
/// <summary> Ticket holder for the last instance creator in line. </summary>
private static Ticket? LastInLine { get; set; }
/// <summary> Waits for all previously created instances of <typeparamref name="TDisposable"/> to be disposed, then creates and returns a new instance of <typeparamref name="TDisposable"/> using the provided <paramref name="creator"/> factory. </summary>
public static TDisposable WaitToCreateInstance(Func<TDisposable> creator)
{
Ticket ticket;
lock (Locker)
{
ticket = LastInLine = new Ticket(creator, LastInLine);
}
return ticket.Fulfill();
}
/// <summary> A queue ticket for an instance creator. </summary>
/// <param name="creator">Factory to create a new instance of <typeparamref name="TDisposable"/></param>
/// <param name="inFront">The ticket immediately in preceding this new ticket. This new ticket must wait for <paramref name="inFront"/> to signal its instance has been disposed before creating a new instance of <typeparamref name="TDisposable"/></param>
private class Ticket(Func<TDisposable> creator, Ticket? inFront) : IDisposable
{
/// <summary> Factory to create a new instance of <typeparamref name="TDisposable"/> </summary>
private Func<TDisposable> Creator { get; } = creator;
/// <summary> Ticket immediately in front of this one. This instance must wait for <see cref="InFront"/> to signal its instance has been disposed before creating a new instance of <typeparamref name="TDisposable"/></summary>
private Ticket? InFront { get; } = inFront;
/// <summary> Wait handle to signal when this ticket's created instance is disposed </summary>
private EventWaitHandle WaitHandle { get; } = new(false, EventResetMode.ManualReset);
/// <summary> This ticket's created instance of <typeparamref name="TDisposable"/> </summary>
private TDisposable? CreatedInstance { get; set; }
/// <summary> Disposes of this ticket and every ticket queued in front of it. </summary>
public void Dispose()
{
WaitHandle.Dispose();
InFront?.Dispose();
}
/// <summary>
/// Waits for the <see cref="InFront"/> ticket's instance to be disposed, then creates and returns a new instance of <typeparamref name="TDisposable"/> using the <see cref="Creator"/> factory.
/// </summary>
public TDisposable Fulfill()
{
#if DEBUG
var sw = Stopwatch.StartNew();
#endif
//Wait for the previous ticket's instance to be disposed, then dispose of the previous ticket.
InFront?.WaitHandle.WaitOne(Timeout.Infinite);
InFront?.Dispose();
#if DEBUG
sw.Stop();
Debug.WriteLine($"Waited {sw.ElapsedMilliseconds}ms to create instance of {typeof(TDisposable).Name}");
#endif
CreatedInstance = Creator();
CreatedInstance.ObjectDisposed += CreatedInstance_ObjectDisposed;
return CreatedInstance;
}
private void CreatedInstance_ObjectDisposed(object? sender, EventArgs e)
{
Debug.WriteLine($"{typeof(TDisposable).Name} Disposed");
if (CreatedInstance is not null)
{
CreatedInstance.ObjectDisposed -= CreatedInstance_ObjectDisposed;
CreatedInstance = default;
}
lock (Locker)
{
if (this == LastInLine)
{
//There are no ticket holders waiting after this one.
//This ticket is fulfilled and will never be waited on.
LastInLine = null;
Dispose();
}
else
{
//Signal the that this ticket has been fulfilled so that
//the next ticket in line may proceed.
WaitHandle.Set();
}
}
}
}
}

View File

@@ -1,9 +1,11 @@
using DataLayer.Configurations;
using Microsoft.EntityFrameworkCore;
using System;
using System.Threading.Tasks;
namespace DataLayer
{
public class LibationContext : DbContext
public class LibationContext : DbContext, INotifyDisposed
{
// IMPORTANT: USING DbSet<>
// ========================
@@ -25,6 +27,18 @@ namespace DataLayer
public DbSet<Category> Categories { get; private set; }
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
public event EventHandler ObjectDisposed;
public override void Dispose()
{
base.Dispose();
ObjectDisposed?.Invoke(this, EventArgs.Empty);
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
ObjectDisposed?.Invoke(this, EventArgs.Empty);
}
public static LibationContext Create(string connectionString)
{
var factory = new LibationContextFactory();

View File

@@ -0,0 +1,477 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20251020175053_AddIncludedUntil")]
partial class AddIncludedUntil
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpatial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.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("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<bool>("IsFinished")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddIncludedUntil : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "IncludedUntil",
table: "LibraryBooks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IncludedUntil",
table: "LibraryBooks");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -194,6 +194,9 @@ namespace DataLayer.Migrations
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");

View File

@@ -111,7 +111,8 @@ namespace DtoImporterService
contentType,
authors,
narrators,
importItem.LocaleName)
importItem.LocaleName
)
).Entity;
Cache.Add(book.AudibleProductId, book);
}

View File

@@ -8,121 +8,136 @@ using Dinah.Core.Collections.Generic;
namespace DtoImporterService
{
public class LibraryBookImporter : ItemsImporterBase
{
protected override IValidator Validator => new LibraryValidator();
public class LibraryBookImporter : ItemsImporterBase
{
protected override IValidator Validator => new LibraryValidator();
private BookImporter bookImporter { get; }
private BookImporter bookImporter { get; }
public LibraryBookImporter(LibationContext context) : base(context)
{
bookImporter = new BookImporter(DbContext);
}
public LibraryBookImporter(LibationContext context) : base(context)
{
bookImporter = new BookImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
int qtyNew = 0;
int qtyNew = 0;
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
var libraryBook = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
existing = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
try
{
DbContext.LibraryBooks.Add(libraryBook);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
}
}
}
try
{
DbContext.LibraryBooks.Add(existing);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account });
}
}
existing.SetIncludedUntil(GetExpirationDate(item));
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
return qtyNew;
}
return qtyNew;
}
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
}
}
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
/// <summary>
/// Determines when your audible plus or free book will expire from your library
/// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used.
/// In some cases current date is later than end date so exclude.
/// </summary>
/// <returns>The DateTime that this title will become unavailable, otherwise null</returns>
private static DateTime? GetExpirationDate(ImportItem item)
=> item.DtoItem.Plans
?.Where(p => p.IsAyce)
.Select(p => p.EndDate)
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
?.DateTime;
}
}

View File

@@ -4,6 +4,7 @@ using Dinah.Core.ErrorHandling;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.ProcessQueue;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

View File

@@ -122,7 +122,8 @@ namespace LibationAvalonia.Dialogs
private void Reload()
{
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
using var context = DbContexts.GetContext();
var deletedBooks = context.GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));

View File

@@ -12,6 +12,7 @@ namespace LibationAvalonia.ViewModels
{
public partial class MainVM : ViewModelBase
{
public Task? BindToGridTask { get; set; }
public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel();
public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel();
@@ -43,6 +44,13 @@ namespace LibationAvalonia.ViewModels
{
try
{
//Prevent race condition which can occur if an auto-scan
//completes before the initial grid binding completes.
if (BindToGridTask is null)
return;
else if (BindToGridTask.IsCompleted is false)
await BindToGridTask;
await Task.WhenAll(
SetBackupCountsAsync(fullLibrary),
Task.Run(() => ProductsDisplay.UpdateGridAsync(fullLibrary)));

View File

@@ -477,6 +477,7 @@ namespace LibationAvalonia.ViewModels
public DataGridLength PurchaseDateWidth { get => getColumnWidth("PurchaseDate", 75); set => setColumnWidth("PurchaseDate", value); }
public DataGridLength MyRatingWidth { get => getColumnWidth("MyRating", 95); set => setColumnWidth("MyRating", value); }
public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); }
public DataGridLength IncludedUntilWidth { get => getColumnWidth("IncludedUntil", 140); set => setColumnWidth("IncludedUntil", value); }
public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); }
public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); }
public DataGridLength IsSpatialWidth { get => getColumnWidth("IsSpatial", 100); set => setColumnWidth("IsSpatial", value); }

View File

@@ -165,9 +165,10 @@ namespace LibationAvalonia.Views
if (QuickFilters.UseDefault)
await vm.PerformFilter(QuickFilters.Filters.FirstOrDefault());
await Task.WhenAll(
ViewModel.BindToGridTask = Task.WhenAll(
vm.SetBackupCountsAsync(initialLibrary),
Task.Run(() => vm.ProductsDisplay.BindToGridAsync(initialLibrary)));
await ViewModel.BindToGridTask;
}
public void ProductsDisplay_LiberateClicked(object _, LibraryBook[] libraryBook) => ViewModel.LiberateClicked(libraryBook);

View File

@@ -213,6 +213,16 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Included&#xA;Until" MinWidth="10" Width="{CompiledBinding IncludedUntilWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="IncludedUntil" ClipboardContentBinding="{Binding IncludedUntil}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding IncludedUntil}" TextWrapping="WrapWithOverflow" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{CompiledBinding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">

View File

@@ -209,7 +209,8 @@ namespace LibationFileManager
private static readonly EquatableDictionary<string, bool> DefaultColumns = new([
new ("SeriesOrder", false),
new ("LastDownload", false),
new ("IsSpatial", false)
new ("IsSpatial", false),
new ("IncludedUntil", false),
]);
public bool GetColumnVisibility(string columnName)
=> GridColumnsVisibilities.TryGetValue(columnName, out var isVisible) ? isVisible

View File

@@ -49,6 +49,7 @@ namespace LibationUiBase.GridView
private string _bookTags;
private Rating _myRating;
private bool _isSpatial;
private string _includedUntil;
public abstract bool? Remove { get; set; }
public EntryStatus Liberate { get => _liberate; private set => RaiseAndSetIfChanged(ref _liberate, value); }
public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); }
@@ -66,6 +67,7 @@ namespace LibationUiBase.GridView
public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); }
public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); }
public bool IsSpatial { get => _isSpatial; protected set => RaiseAndSetIfChanged(ref _isSpatial, value); }
public string IncludedUntil { get => _includedUntil; protected set => RaiseAndSetIfChanged(ref _includedUntil, value); }
public Rating MyRating
{
@@ -120,14 +122,18 @@ namespace LibationUiBase.GridView
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
BookTags = GetBookTags();
IsSpatial = Book.IsSpatial;
IncludedUntil = GetIncludedUntilString();
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
protected abstract string GetBookTags();
protected virtual DateTime GetPurchaseDate() => LibraryBook.DateAdded;
protected virtual DateTime? GetIncludedUntil() => LibraryBook.IncludedUntil;
protected virtual int GetLengthInMinutes() => Book.LengthInMinutes;
protected string GetPurchaseDateString() => GetPurchaseDate().ToString("d");
protected string GetIncludedUntilString() => GetIncludedUntil()?.ToString("d") ?? string.Empty;
protected string GetBookLengthString()
{
int bookLenMins = GetLengthInMinutes();
@@ -208,6 +214,7 @@ namespace LibationUiBase.GridView
nameof(Liberate) => Liberate,
nameof(DateAdded) => DateAdded,
nameof(IsSpatial) => IsSpatial,
nameof(IncludedUntil) => GetIncludedUntil() ?? default,
_ => null
};

View File

@@ -102,5 +102,6 @@ namespace LibationUiBase.GridView
protected override string GetBookTags() => null;
protected override int GetLengthInMinutes() => Children.Count == 0 ? 0 : Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
protected override DateTime GetPurchaseDate() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.DateAdded);
protected override DateTime? GetIncludedUntil() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.IncludedUntil);
}
}

View File

@@ -23,7 +23,8 @@ namespace LibationWinForms.Dialogs
deletedCheckedTemplate = deletedCheckedLbl.Text;
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
using var context = DbContexts.GetContext();
var deletedBooks = context.GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
deletedCbl.Items.Add(lb);

View File

@@ -54,6 +54,7 @@ namespace LibationWinForms.GridView
lastDownloadedGVColumn = new LastDownloadedGridViewColumn();
isSpatialGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
tagAndDetailsGVColumn = new EditTagsDataGridViewImageButtonColumn();
includedUntilGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).BeginInit();
((System.ComponentModel.ISupportInitialize)syncBindingSource).BeginInit();
SuspendLayout();
@@ -66,7 +67,7 @@ namespace LibationWinForms.GridView
gridEntryDataGridView.AllowUserToResizeRows = false;
gridEntryDataGridView.AutoGenerateColumns = false;
gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, isSpatialGVColumn, tagAndDetailsGVColumn });
gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, isSpatialGVColumn, tagAndDetailsGVColumn, includedUntilGVColumn });
gridEntryDataGridView.ContextMenuStrip = showHideColumnsContextMenuStrip;
gridEntryDataGridView.DataSource = syncBindingSource;
dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
@@ -276,6 +277,16 @@ namespace LibationWinForms.GridView
tagAndDetailsGVColumn.ScaleFactor = 0F;
tagAndDetailsGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
//
// includedUntilGVColumn
//
includedUntilGVColumn.DataPropertyName = "IncludedUntil";
includedUntilGVColumn.HeaderText = "Included Until";
includedUntilGVColumn.MinimumWidth = 10;
includedUntilGVColumn.Name = "includedUntilGVColumn";
includedUntilGVColumn.ReadOnly = true;
includedUntilGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
includedUntilGVColumn.Width = 108;
//
// ProductsGrid
//
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
@@ -314,5 +325,6 @@ namespace LibationWinForms.GridView
private LastDownloadedGridViewColumn lastDownloadedGVColumn;
private System.Windows.Forms.DataGridViewCheckBoxColumn isSpatialGVColumn;
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn includedUntilGVColumn;
}
}