Compare commits

...

15 Commits

Author SHA1 Message Date
Robert
946d4779a9 incr ver 2026-01-28 07:47:10 -05:00
Robert
e620d0be24 remove unneeded coalesce 2026-01-28 07:44:32 -05:00
rmcrackan
f720127a68 Merge pull request #1579 from rmcrackan/rmcrackan/bug-1578
Bug #1578 Add null checks
2026-01-27 13:22:20 -05:00
Robert
c6c3b39e9c Bug #1578 Adding null checks to the line that's logged with NRE. This bit of code has been untouched for years and is run hundreds of times per day. I'm documenting this suspiciously 2026-01-27 09:06:46 -05:00
rmcrackan
e70cc5342c Merge pull request #1572 from shuvashish76/patch-1
Add AppImage installation instructions
2026-01-23 09:45:12 -05:00
shuvashish76
bf0d380dd2 Add AppImage installation instructions
Added installation instructions for AppImage using AppMan and AM.
2026-01-23 14:41:46 +05:30
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
13 changed files with 143 additions and 124 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.5.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);
}

View File

@@ -57,7 +57,7 @@ namespace LibationWinForms
private void productsDisplay_InitialLoaded(object sender, EventArgs e)
{
if (QuickFilters.UseDefault)
performFilter(QuickFilters.Filters.FirstOrDefault().Filter);
performFilter(QuickFilters.Filters.FirstOrDefault()?.Filter);
}
}
}

View File

@@ -31,6 +31,18 @@ sudo dnf5 install ./libation.rpm
---
### AppImage
- Install via [AppMan](https://github.com/ivan-hc/AppMan) (rootless)
```bash
appman -i libation
```
- Install via [AM](https://github.com/ivan-hc/AM)
```bash
am -i libation
```
Thanks to Package Forge dev [Samuel](https://github.com/Samueru-sama) for [AppImage](https://github.com/pkgforge-dev/Libation-AppImage) maintenence.
### Arch Linux
```bash
@@ -76,6 +88,7 @@ Pacstall is the AUR Ubuntu wishes it had. It takes the concept of the AUR and pu
```bash
pacstall -I libation-deb
```
Thanks to [Tobias Heinlein](https://github.com/niontrix) for Pacstall package maintenance.
---