Compare commits

..

9 Commits

Author SHA1 Message Date
Robert
a43f25db23 incr ver 2026-01-22 17:50:31 -05:00
rmcrackan
e1f4168599 Merge pull request #1567 from Mbucari/master
Minor big fixes and performance improvements
2026-01-22 17:48:31 -05:00
Michael Bucari-Tovo
29501bddf3 Clean up queries
Use .NET 10 extensions
Refactor some extensions for clarity
2026-01-22 13:36:25 -07:00
Michael Bucari-Tovo
e9016ace03 Make IncludedUntil and LastDownload nulls always sort to bottom 2026-01-21 13:41:47 -07:00
Michael Bucari-Tovo
d498c094bf Update AAXClean
Some minor performance improvements, especially when opening a file/stream.
2026-01-21 13:40:59 -07:00
Michael Bucari-Tovo
ce92e79cd8 Fix SaferEnumerateFiles creating too manu open file handles (#1565)
Using the EnumerationOptions overload of EnumerateFiles() serves the same function as the previous manual implementation.
2026-01-21 10:34:59 -07:00
Michael Bucari-Tovo
f54a789ae8 Improve querying UnLiberated books
Add GetUnliberated_Flat_NoTracking() which queries only unliberated books/episodes and does not load the entire library.

Fix UnLiberated() query to only return products or episodes (not parents) (#1564)
2026-01-19 16:57:32 -07:00
Michael Bucari-Tovo
3ef0bce909 No reason to restrict CDM type to Android or Level to L3 2026-01-19 14:16:47 -07:00
Robert
413da72bf0 logging typo 2026-01-19 10:31:34 -05:00
11 changed files with 129 additions and 123 deletions

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.1.3.1" />
<PackageReference Include="AAXClean.Codecs" Version="2.1.3.2" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -25,6 +25,12 @@ namespace ApplicationServices
return context.GetLibrary_Flat_NoTracking(includeParents);
}
public static List<LibraryBook> GetUnliberated_Flat_NoTracking()
{
using var context = GetContext();
return context.GetUnLiberated_Flat_NoTracking();
}
public static List<LibraryBook> GetDeletedLibraryBooks()
{
using var context = GetContext();

View File

@@ -35,10 +35,6 @@ internal class Device
if (FileVersion != 2)
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
if (Type != DeviceTypes.Android)
throw new InvalidDataException($"Unknown CDM Type: '{Type}'");
if (SecurityLevel != 3)
throw new InvalidDataException($"Unknown CDM Security Level: '{SecurityLevel}'");
var privateKeyLength = (fileData[7] << 8) | fileData[8];

View File

@@ -1,116 +1,131 @@
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
#nullable enable
namespace DataLayer
namespace DataLayer;
// only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook
public static class LibraryBookQueries
{
// only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook
public static class LibraryBookQueries
private static System.Linq.Expressions.Expression<System.Func<LibraryBook, bool>> IsUnLiberatedExpression { get; }
= lb =>
!lb.AbsentFromLastScan &&
(lb.Book.ContentType == ContentType.Product || lb.Book.ContentType == ContentType.Episode) &&
(lb.Book.UserDefinedItem.PdfStatus == LiberatedStatus.NotLiberated || lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.NotLiberated || lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.PartialDownload);
extension(LibationContext context)
{
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
//public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
// => context
// .Library
// .GetLibrary()
// .ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.AsEnumerable()
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.Where(lb => lb.Book.ContentType != ContentType.Parent || includeParents)
.AsEnumerable()
.ToList();
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId, bool caseSensative = true)
{
var libraryQuery
= context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary();
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
{
var libraryQuery
= context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary();
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
}
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
=> library
.Where(lb => !lb.IsDeleted)
.getLibrary();
public List<LibraryBook> GetUnLiberated_Flat_NoTracking()
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.Where(IsUnLiberatedExpression)
.AsEnumerable()
.ToList();
public static List<LibraryBook> GetDeletedLibraryBooks(this LibationContext context)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
//Return all parents so the trash bin grid can show podcasts beneath their parents
.Where(lb => lb.IsDeleted || lb.Book.ContentType == ContentType.Parent)
.getLibrary()
.ToList();
public List<LibraryBook> GetDeletedLibraryBooks()
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
//Return all parents so the trash bin grid can show podcasts beneath their parents
.Where(lb => lb.IsDeleted || lb.Book.ContentType == ContentType.Parent)
.getLibrary()
.ToList();
}
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
private static IQueryable<LibraryBook> getLibrary(this IQueryable<LibraryBook> library)
=> library
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
extension(IQueryable<LibraryBook> library)
{
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public IQueryable<LibraryBook> GetLibrary()
=> library.Where(lb => !lb.IsDeleted).getLibrary();
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
private IQueryable<LibraryBook> getLibrary()
=> library
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
}
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks
.Where(lb => lb.Book.IsEpisodeChild())
.ExceptBy(
libraryBooks
.ParentedEpisodes()
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
extension(IEnumerable<LibraryBook> libraryBooks)
{
public IEnumerable<LibraryBook> UnLiberated()
=> libraryBooks.Where(lb => lb.NeedsPdfDownload || lb.NeedsBookDownload);
#nullable enable
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> libraryBooks, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
public IEnumerable<LibraryBook> ParentedEpisodes()
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return libraryBooks.FirstOrDefault(
lb =>
lb.Book.IsEpisodeParent() &&
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (System.Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
#nullable disable
public IEnumerable<LibraryBook> FindOrphanedEpisodes()
=> libraryBooks
.Where(lb => lb.Book.IsEpisodeChild())
.ExceptBy(
libraryBooks
.ParentedEpisodes()
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
public static List<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList
.Where(
lb =>
lb.Book.IsEpisodeChild() &&
lb.Book.SeriesLink?
.Any(
s =>
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
) == true
).ToList();
public IEnumerable<LibraryBook> FindChildren(LibraryBook parent)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeChild() && lb.HasSeriesId(parent.Book.AudibleProductId));
public static bool NeedsPdfDownload(this LibraryBook libraryBook)
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
public static bool NeedsBookDownload(this LibraryBook libraryBook)
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload;
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
=> bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload());
}
public LibraryBook? FindSeriesParent(LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return libraryBooks.FirstOrDefault(
lb =>
lb.Book.IsEpisodeParent() &&
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (System.Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
}
extension(LibraryBook libraryBook)
{
public bool HasSeriesId(string audibleSeriesId) => libraryBook.Book.SeriesLink?.Any(s => s.Series.AudibleSeriesId.EqualsInsensitive(audibleSeriesId)) is true;
public bool Downloadable => !libraryBook.AbsentFromLastScan && libraryBook.Book.ContentType is ContentType.Product or ContentType.Episode;
public bool NeedsPdfDownload => libraryBook.Downloadable && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
public bool NeedsBookDownload => libraryBook.Downloadable && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload;
}
}

View File

@@ -244,27 +244,14 @@ namespace FileManager
/// <returns>List of files</returns>
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var foundFiles = Enumerable.Empty<LongPath>();
try
{
if (searchOption == SearchOption.AllDirectories)
{
IEnumerable<LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
// Add files in subdirectories recursively to the list
foreach (string dir in subDirs)
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
}
// Add files from the current directory
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
}
catch (UnauthorizedAccessException) { }
catch (PathTooLongException) { }
// Symbolic links will result in DirectoryNotFoundException. Ohter logical directories might also. Just skip them. Don't want to risk (or have to handle) infinite recursion
catch (DirectoryNotFoundException) { }
return foundFiles;
var enumOptions = new EnumerationOptions
{
RecurseSubdirectories = searchOption == SearchOption.AllDirectories,
IgnoreInaccessible = true,
ReturnSpecialDirectories = false,
MatchType = MatchType.Simple
};
return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath) p);
}
/// <summary>

View File

@@ -17,7 +17,7 @@ namespace LibationAvalonia.ViewModels
/// <summary> This gets called by the "Begin Book and PDF Backups" menu item. </summary>
public async Task BackupAllBooks()
{
var books = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking());
var books = await Task.Run(DbContexts.GetUnliberated_Flat_NoTracking);
BackupAllBooks(books);
}

View File

@@ -221,6 +221,8 @@ namespace LibationUiBase.GridView
nameof(Category) => string.IsNullOrWhiteSpace(Category),
nameof(Misc) => string.IsNullOrWhiteSpace(Misc),
nameof(BookTags) => string.IsNullOrWhiteSpace(BookTags),
nameof(IncludedUntil) => string.IsNullOrWhiteSpace(IncludedUntil),
nameof(LastDownload) => LastDownload?.IsValid is not true,
_ => false
};

View File

@@ -151,7 +151,7 @@ public class ProcessBookViewModel : ReactiveObject
}
catch (ContentLicenseDeniedException ldex)
{
Serilog.Log.Logger.Error(ldex, "Content license was denied for {#Book}", LibraryBook.LogFriendly());
Serilog.Log.Logger.Error(ldex, "Content license was denied for {Book}", LibraryBook.LogFriendly());
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
{
LogInfo($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");

View File

@@ -96,7 +96,7 @@ public class ProcessQueueViewModel : ReactiveObject
if (!IsBooksDirectoryValid(config))
return false;
var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload()).ToArray();
var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload).ToArray();
if (needsPdf.Length > 0)
{
Serilog.Log.Logger.Information("Begin download {count} pdfs", needsPdf.Length);
@@ -137,14 +137,14 @@ public class ProcessQueueViewModel : ReactiveObject
if (item.AbsentFromLastScan)
return false;
else if (item.NeedsBookDownload())
else if (item.NeedsBookDownload)
{
RemoveCompleted(item);
Serilog.Log.Logger.Information("Begin single library book backup of {libraryBook}", item);
AddDownloadDecrypt([item], config);
return true;
}
else if (item.NeedsPdfDownload())
else if (item.NeedsPdfDownload)
{
RemoveCompleted(item);
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);

View File

@@ -15,7 +15,7 @@ namespace LibationWinForms
//GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread
private async void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
{
var library = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking());
var library = await Task.Run(DbContexts.GetUnliberated_Flat_NoTracking);
BackupAllBooks(library);
}