Compare commits

...

33 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
rmcrackan
2c882e883d #1378 : allow for invalid beginning-of-time 'purchase_date'. This is the case for an actual user 2025-09-30 16:28:48 -04:00
Mbucari
74c76a7414 Enhance bug report template formatting and instructions
Updated the bug report template to improve clarity and formatting, including default log file locations for various platforms.
2025-09-04 12:46:22 -06:00
Mbucari
17a0c21453 Document xHE-AAC conformance errors for Audible
Added notes on xHE-AAC conformance errors related to Audible files.
2025-09-04 12:24:46 -06:00
rmcrackan
fc9c9dfe48 Merge pull request #1360 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-5
Bump actions/setup-dotnet from 4 to 5
2025-09-03 21:43:10 -04:00
dependabot[bot]
d5f0e39981 Bump actions/setup-dotnet from 4 to 5
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 5.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 00:05:35 +00:00
rmcrackan
0f6493f4af Merge pull request #1353 from rmcrackan/rmcrackan-patch-2
Update NamingTemplates.md
2025-08-27 09:30:09 -04:00
rmcrackan
454b490a06 Update NamingTemplates.md 2025-08-27 09:28:45 -04:00
rmcrackan
ffea2648aa Merge pull request #1352 from rmcrackan/rmcrackan-patch-1
Update NamingTemplates.md
2025-08-27 09:22:20 -04:00
rmcrackan
1ac967500c Update NamingTemplates.md
Better documentation for inverted tags
2025-08-27 08:57:20 -04:00
Michael Bucari-Tovo
39e9f675d2 Fix possible race error on startup with autoscan 2025-08-26 10:00:21 -06:00
rmcrackan
ed5afe5d0f update dependencies 2025-08-20 12:07:53 -04:00
rmcrackan
ab075d0bef Merge pull request #1343 from MajorTanya/patch-1
Place examples in their own line
2025-08-20 11:49:46 -04:00
MajorTanya
7fb1adb41b Place examples in their own line
Markdown collapses single line breaks, so this change makes it so the examples will have their own lines.
2025-08-20 17:26:48 +02:00
34 changed files with 853 additions and 133 deletions

View File

@@ -10,10 +10,10 @@ PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking a
___
**Describe the bug**
## Describe the bug
A clear and concise description of what the bug is.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@@ -21,14 +21,23 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
## Screenshots
If applicable, add screenshots to help explain your problem.
**Platform**
## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
## Log Files
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
**Default Log File Locations**
|Platform|Folder|
|-|-|
|Windows|`%userprofile%\Libation`|
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.

View File

@@ -43,7 +43,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
@@ -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

@@ -44,7 +44,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
@@ -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

@@ -58,6 +58,11 @@ This is a proprietary codec created by the [Fraunhofer Institute for Integrated
xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11)
Note that the xHE-AAC files authored by Audible have some USAC conformance errors including:
- Number of samples per frame not matching the UsacConfig coreCoderFrameLength value.
- Disagreement between stts and UsacFrame usacIndependencyFlag value.
- Stts indicating a frame is an immediate play-out frame, but USAC AudioPreRoll is absent.
## Dolby Atmos
Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4.

View File

@@ -87,15 +87,32 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|Inverted Tag|Description|Type|
|-|-|-|
|\<!if series-\>...\<-if series\>|Only include if *not* part of a book series or podcast|Conditional|
|\<!if podcast-\>...\<-if podcast\>|Only include if *not* part of a podcast|Conditional|
|\<!if bookseries-\>...\<-if bookseries\>|Only include if *not* part of a book series|Conditional|
|\<!if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is *not* a podcast series parent|Conditional|
|\<!has PROPERTY-\>...\<-has\>|Only include if the PROPERTY *does not* have a value (i.e. is null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
This example will add a number if the `<series#\>` tag has a value:
`<has series#><series#><-has>`
This example will put non-series books in a "Standalones" folder:
`<!if series->Standalones/<-if series>`
And this example will customize the title based on whether the book has a subtitle:
`<audible title><has audible subtitle->-<audible subtitle><-has>`
# Tag Formatters

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.5.3.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

@@ -28,10 +28,11 @@ namespace AudibleUtilities
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items)));
if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
//// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date'
//if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
// exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
return exceptions;
return exceptions;
}
}
public class BookValidator : IValidator
@@ -91,7 +92,7 @@ namespace AudibleUtilities
if (distinct.Any(s => s.SeriesId is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
//// unfortunately, a user has a series with no name
//// unfortunately, an actual user has a series with no name
//if (distinct.Any(s => s.SeriesName is null))
// exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.4.1" />
<PackageReference Include="AudibleApi" Version="9.4.5.1" />
<PackageReference Include="Google.Protobuf" Version="3.32.0" />
</ItemGroup>

View File

@@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>

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

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Polly" Version="8.6.2" />
</ItemGroup>

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

View File

@@ -42,7 +42,7 @@
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="9.0.3.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
</ItemGroup>
</Project>