Compare commits

...

30 Commits

Author SHA1 Message Date
Robert McRackan
34f56d2fd7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-08 14:07:51 -05:00
Robert McRackan
c45ffaf4a6 Incr ver 2023-03-08 14:07:47 -05:00
rmcrackan
ae43ab103e Merge pull request #524 from Mbucari/master
Improve library scan speed and Track and display book availability
2023-03-08 14:06:45 -05:00
Mbucari
559977ce0b Add 'Unavailable' book and pdf counts. 2023-03-08 11:26:07 -07:00
Mbucari
ccd4d3e26d Check for null Plan array 2023-03-08 11:21:47 -07:00
MBucari
e76f99ff28 Fix rmcrackan/Libation#523 2023-03-07 22:34:36 -07:00
MBucari
d3607583ab Tweak episode scan 2023-03-07 20:32:50 -07:00
MBucari
3ebd4ce243 Show AbsentFromLastScan book status in grid 2023-03-07 20:02:22 -07:00
Mbucari
f6dcc0db1d Add AbsentFromLastScan 2023-03-07 18:58:18 -07:00
MBucari
bd49db83e4 Improve library scan performance 2023-03-07 15:30:22 -07:00
Mbucari
4140722a6d Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-06 16:56:57 -07:00
Mbucari
da36f9414d Improve library scan performance 2023-03-06 16:49:52 -07:00
Mbucari
1510f71ca6 Merge branch 'rmcrackan:master' into master 2023-03-03 16:33:36 -07:00
Mbucari
cdb27ef712 Add last downloaded info to exports 2023-03-03 15:06:06 -07:00
Robert McRackan
790319ed98 incr ver 2023-03-03 15:58:05 -05:00
rmcrackan
1b0fb2b316 Merge pull request #522 from Mbucari/master
Resolved Several Issues (It's not as bad as  2,453 lines suggests)
2023-03-03 15:56:59 -05:00
Mbucari
02371f2221 Deleting folders with custom icons no longer triggers system file warning 2023-03-03 10:56:31 -07:00
Mbucari
2b672f86be Fatten up the chevrons 2023-03-02 19:57:43 -07:00
Mbucari
36176bff33 Update ImageSharp 2023-03-02 19:41:59 -07:00
Mbucari
174b0c26b8 Update fileicon to latest version 2023-03-02 19:40:38 -07:00
Mbucari
26c60e8e79 Convert queue expand/collapse button text to images (rmcrackan/Libation#339) 2023-03-02 19:23:03 -07:00
Mbucari
d94759d868 Add Last Download column to grid (rmcrackan/Libation#498) 2023-03-02 18:52:45 -07:00
Mbucari
bd7e45ca3c Add last download into to database 2023-03-02 15:09:10 -07:00
Mbucari
52a863c62a Add audiobook Trash Bin 2023-03-02 13:12:32 -07:00
Mbucari
fe55b90ee3 Fix rmcrackan/Libation#511 2023-03-01 22:14:57 -07:00
Mbucari
df224cc7f3 Move TrackedQueue to LibationUiBase 2023-03-01 09:33:17 -07:00
Mbucari
2a59329350 Merge branch 'rmcrackan:master' into master 2023-02-28 16:41:14 -07:00
Mbucari
abdf0e7261 Parallelize post-liberation tasks 2023-02-28 16:40:53 -07:00
Mbucari
b9c2a1cce3 Add folder icon support to MacOS 2023-02-28 15:57:27 -07:00
rmcrackan
aa86fca08f Update InstallOnMac.md 2023-02-28 15:46:51 -05:00
76 changed files with 3422 additions and 884 deletions

View File

@@ -9,7 +9,9 @@ This walkthrough should get you up and running with Libation on your Mac.
## Install Libation
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
- Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder.
- Open a terminal (Go > Utilities > Terminal)
- Copy/paste/run the following command (you'll be prompted to enter your password)

View File

@@ -65,6 +65,9 @@ if [ $? -ne 0 ]
exit
fi
echo "Make fileicon executable..."
chmod +x $BUNDLE_MACOS/fileicon
echo "Moving icon..."
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>9.4.2.1</Version>
<Version>9.4.4.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="5.0.0" />

View File

@@ -75,13 +75,15 @@ namespace AppScaffolding
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
.Max(a => a.Version);
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
{
// must occur before access to Configuration instance
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
Configuration.SetLibationVersion(BuildVersion);
//***********************************************//
// //
// do not use Configuration before this line //

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AudibleApi;
using AudibleUtilities;
@@ -242,18 +243,16 @@ namespace ApplicationServices
#endregion
#region remove/restore books
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
private static int removeBooks(List<string> idsToRemove)
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
{
try
{
if (idsToRemove is null || !idsToRemove.Any())
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
@@ -275,7 +274,7 @@ namespace ApplicationServices
}
}
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
{
@@ -303,6 +302,31 @@ namespace ApplicationServices
throw;
}
}
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
context.LibraryBooks.RemoveRange(libraryBooks);
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
#endregion
// call this whenever books are added or removed from library
@@ -346,8 +370,10 @@ namespace ApplicationServices
if (rating is not null)
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
});
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus, Version libationVersion)
=> book.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
@@ -428,40 +454,74 @@ namespace ApplicationServices
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
private string toBookStatusString()
{
if (!HasBookResults) return "No books. Begin by importing your library";
if (!HasPendingBooks && booksError + booksUnavailable == 0) return $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
var sb = new StringBuilder($"BACKUPS: No progress: {booksNoProgress} In process: {booksDownloadedOnly} Fully backed up: {booksFullyBackedUp}");
if (booksError > 0)
sb.Append($" Errors: {booksError}");
if (booksUnavailable > 0)
sb.Append($" Unavailable: {booksUnavailable}");
return sb.ToString();
}
private string toPdfStatusString()
{
if (pdfsNotDownloaded + pdfsUnavailable == 0) return $"All {pdfsDownloaded} PDFs downloaded";
var sb = new StringBuilder($"PDFs: NOT d/l'ed: {pdfsNotDownloaded} Downloaded: {pdfsDownloaded}");
if (pdfsUnavailable > 0)
sb.Append($" Unavailable: {pdfsUnavailable}");
return sb.ToString();
}
}
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => !r.absent && r.status == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
var boolResults = libraryBooks
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
var pdfResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
}
}
}

View File

@@ -106,6 +106,12 @@ namespace ApplicationServices
[Name("Language")]
public string Language { get; set; }
[Name("LastDownloaded")]
public DateTime? LastDownloaded { get; set; }
[Name("LastDownloadedVersion")]
public string LastDownloadedVersion { get; set; }
}
public static class LibToDtos
{
@@ -140,7 +146,10 @@ namespace ApplicationServices
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
}).ToList();
}
public static class LibraryExporter
@@ -212,7 +221,9 @@ namespace ApplicationServices
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language)
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
};
var col = 0;
foreach (var c in columns)
@@ -238,9 +249,9 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.Account);
var dateAddedCell = row.CreateCell(col++);
dateAddedCell.CellStyle = dateStyle;
dateAddedCell.SetCellValue(dto.DateAdded);
var dateCell = row.CreateCell(col++);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.DateAdded);
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
row.CreateCell(col++).SetCellValue(dto.Locale);
@@ -281,6 +292,15 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue)
{
dateCell = row.CreateCell(col);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.LastDownloaded.Value);
}
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
rowIndex++;
}

View File

@@ -2,8 +2,9 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Diagnostics;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
@@ -19,6 +20,9 @@ namespace AudibleUtilities
{
public Api Api { get; private set; }
private const int MaxConcurrency = 10;
private const int BatchSize = 50;
private ApiExtended(Api api) => Api = api;
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
@@ -85,35 +89,51 @@ namespace AudibleUtilities
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
{
var items = new List<Item>();
Serilog.Log.Logger.Debug("Beginning library scan.");
List<Task<List<Item>>> getChildEpisodesTasks = new();
int count = 0;
List<Item> items = new();
List<Item> seriesItems = new();
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
var sw = Stopwatch.StartNew();
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
//Scan the library for all added books, and add any episode-type items to seriesItems to be scanned for episodes/parents
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions, BatchSize, MaxConcurrency))
{
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
}
seriesItems.Add(item);
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
count++;
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on series episode scans to complete.", count);
Serilog.Log.Logger.Debug("Beginning episode scan.");
//await and add all episodes from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
count = 0;
Serilog.Log.Logger.Debug("Completed library scan.");
//'get' Tasks are activated when they are written to the channel. To avoid more concurrency than is desired, the
//channel is bounded with a capacity of 1. Channel write operations are blocked until the current item is read
var episodeChannel = Channel.CreateBounded<Task<List<Item>>>(new BoundedChannelOptions(1) { SingleReader = true });
//Start scanning for all episodes. Episode batch 'get' Tasks are written to the channel.
var scanAllSeriesTask = scanAllSeries(seriesItems, episodeChannel.Writer);
//Read all episodes from the channel and add them to the import items.
//This method blocks until episodeChannel.Writer is closed by scanAllSeries()
await foreach (var ep in getAllEpisodesAsync(episodeChannel.Reader))
{
items.AddRange(ep);
count += ep.Count;
}
//Be sure to await the scanAllSeries Task so that any exceptions are thrown
await scanAllSeriesTask;
sw.Stop();
Serilog.Log.Logger.Debug("Episode scan complete. Found {count} episodes and series.", count);
Serilog.Log.Logger.Debug($"Completed library scan in {sw.Elapsed.TotalMilliseconds:F0} ms.");
#if DEBUG
//// this will not work for multi accounts
@@ -146,165 +166,178 @@ namespace AudibleUtilities
#region episodes and podcasts
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
/// <summary>
/// Read get tasks from the <paramref name="channel"/> and await results. This method maintains
/// a list of up to <see cref="MaxConcurrency"/> get tasks. When any of the get tasks completes,
/// the Items are yielded, that task is removed from the list, and a new get task is read from
/// the channel.
/// </summary>
private async IAsyncEnumerable<List<Item>> getAllEpisodesAsync(ChannelReader<Task<List<Item>>> channel)
{
await concurrencySemaphore.WaitAsync();
List<Task<List<Item>>> concurentGets = new();
for (int i = 0; i < MaxConcurrency && await channel.WaitToReadAsync(); i++)
concurentGets.Add(await channel.ReadAsync());
while (concurentGets.Count > 0)
{
var completed = await Task.WhenAny(concurentGets);
concurentGets.Remove(completed);
if (await channel.WaitToReadAsync())
concurentGets.Add(await channel.ReadAsync());
yield return completed.Result;
}
}
/// <summary>
/// Gets all child episodes and episode parents belonging to <paramref name="seriesItems"/> in batches and
/// writes the get tasks to <paramref name="channel"/>.
/// </summary>
private async Task scanAllSeries(IEnumerable<Item> seriesItems, ChannelWriter<Task<List<Item>>> channel)
{
try
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
List<Task> episodeScanTasks = new();
List<Item> children;
if (parent.IsEpisodes)
foreach (var item in seriesItems)
{
//The 'parent' is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
if (item.IsEpisodes)
await channel.WriteAsync(getEpisodeParentAsync(item));
else if (item.IsSeriesParent)
episodeScanTasks.Add(getParentEpisodesAsync(item, channel));
}
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
//episodeScanTasks complete only after all episode batch 'gets' have been written to the channel
await Task.WhenAll(episodeScanTasks);
}
finally { channel.Complete(); }
}
children = new() { parent };
private async Task<List<Item>> getEpisodeParentAsync(Item episode)
{
//Item is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
var parentAsins = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", episode);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
List<Item> children = new() { episode };
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//so we can figure out what to do about those special cases, and don't
//import the episode.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters =
{
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
return new List<Item>();
var parentAsins = episode.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//so we can figure out what to do about those special cases, and don't
//import the episode.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters = {
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
}
};
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {episode.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(episode, Formatting.None, Settings)}");
return new();
}
var realParent = seriesParents.Single(p => p.IsSeriesParent);
realParent.PurchaseDate = parent.PurchaseDate;
var parent = seriesParents.Single(p => p.IsSeriesParent);
parent.PurchaseDate = episode.PurchaseDate;
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
parent = realParent;
}
else
setSeries(parent, children);
children.Add(parent);
Serilog.Log.Logger.Debug("Completed parent scan for {episode}", episode);
return children;
}
/// <summary>
/// Gets all episodes belonging to <paramref name="parent"/> in batches of <see cref="BatchSize"/> and writes the batch get tasks to <paramref name="channel"/>
/// This method only completes after all episode batch 'gets' have been written to the channel
/// </summary>
private async Task getParentEpisodesAsync(Item parent, ChannelWriter<Task<List<Item>>> channel)
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
var episodeIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin);
for (int batchNum = 0; episodeIds.Any(); batchNum++)
{
var batch = episodeIds.Take(BatchSize);
await channel.WriteAsync(getEpisodeBatchAsync(batchNum, parent, batch));
episodeIds = episodeIds.Skip(BatchSize);
}
}
private async Task<List<Item>> getEpisodeBatchAsync(int batchNum, Item parent, IEnumerable<string> childrenIds)
{
try
{
List<Item> episodeBatch = await Api.GetCatalogProductsAsync(childrenIds, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
setSeries(parent, episodeBatch);
if (batchNum == 0)
episodeBatch.Add(parent);
Serilog.Log.Logger.Debug($"Batch {batchNum}: {episodeBatch.Count} results\t({{parent}})", parent);
return episodeBatch;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
children = await getEpisodeChildrenAsync(parent);
if (!children.Any())
return new();
}
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = batchNum,
ChildIdBatch = childrenIds
});
throw;
}
}
//A series parent will always have exactly 1 Series
parent.Series = new Series[]
private static void setSeries(Item parent, IEnumerable<Item> children)
{
//A series parent will always have exactly 1 Series
parent.Series = new[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
}
children.Add(parent);
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children;
}
finally
{
concurrencySemaphore.Release();
}
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
{
var childrenIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin)
.ToList();
// fetch children in batches
const int batchSize = 20;
var results = new List<Item>();
for (var i = 1; ; i++)
{
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
if (!idBatch.Any())
break;
List<Item> childrenBatch;
try
{
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
#if DEBUG
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
#endif
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = i,
ChildIdBatch = idBatch
});
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
results.AddRange(childrenBatch);
}
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
ChildCount = childrenIds.Count
});
if (childrenIds.Count != results.Count)
{
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
throw ex;
}
return results;
}
#endregion
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
namespace DataLayer.Configurations
{
@@ -19,40 +20,45 @@ namespace DataLayer.Configurations
entity.Ignore(nameof(Book.Authors));
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
entity
.OwnsMany(b => b.Supplements, b_s =>
{
b_s.WithOwner(s => s.Book)
.HasForeignKey(s => s.BookId);
b_s.HasKey(s => s.SupplementId);
});
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
entity
.OwnsMany(b => b.Supplements, b_s =>
{
b_s.WithOwner(s => s.Book)
.HasForeignKey(s => s.BookId);
b_s.HasKey(s => s.SupplementId);
});
// even though it's owned, we need to map its backing field
entity
.Metadata
.FindNavigation(nameof(Book.Supplements))
.SetPropertyAccessMode(PropertyAccessMode.Field);
// owns it 1:1, store in separate table
entity
.OwnsOne(b => b.UserDefinedItem, b_udi =>
{
b_udi.WithOwner(udi => udi.Book)
.HasForeignKey(udi => udi.BookId);
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
b_udi.ToTable(nameof(Book.UserDefinedItem));
// owns it 1:1, store in separate table
entity
.OwnsOne(b => b.UserDefinedItem, b_udi =>
{
b_udi.WithOwner(udi => udi.Book)
.HasForeignKey(udi => udi.BookId);
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
b_udi.ToTable(nameof(Book.UserDefinedItem));
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);
});
b_udi.Property(udi => udi.LastDownloaded);
b_udi
.Property(udi => udi.LastDownloadedVersion)
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
entity
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);
});
entity
.Metadata
.FindNavigation(nameof(Book.ContributorsLink))
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
@@ -68,6 +74,6 @@ namespace DataLayer.Configurations
.HasOne(b => b.Category)
.WithMany()
.HasForeignKey(b => b.CategoryId);
}
}
}
}

View File

@@ -12,6 +12,7 @@ namespace DataLayer
public string Account { get; private set; }
public bool IsDeleted { get; set; }
public bool AbsentFromLastScan { get; set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)

View File

@@ -24,8 +24,27 @@ namespace DataLayer
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
public DateTime? LastDownloaded { get; private set; }
public Version LastDownloadedVersion { get; private set; }
private UserDefinedItem() { }
public void SetLastDownloaded(Version version)
{
if (LastDownloadedVersion != version)
{
LastDownloadedVersion = version;
OnItemChanged(nameof(LastDownloadedVersion));
}
if (version is null)
LastDownloaded = null;
else
{
LastDownloaded = DateTime.Now;
OnItemChanged(nameof(LastDownloaded));
}
}
private UserDefinedItem() { }
internal UserDefinedItem(Book book)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));

View File

@@ -0,0 +1,410 @@
// <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("20230302220539_AddLastDownloadedInfo")]
partial class AddLastDownloadedInfo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
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<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>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
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.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
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<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.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("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
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("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
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.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
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("ContributorsLink");
b.Navigation("SeriesLink");
});
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,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddLastDownloadedInfo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastDownloaded",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastDownloadedVersion",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastDownloaded",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "LastDownloadedVersion",
table: "UserDefinedItem");
}
}
}

View File

@@ -0,0 +1,413 @@
// <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("20230308013410_AddAbsentFromLastScan")]
partial class AddAbsentFromLastScan
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
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<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>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
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.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
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<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("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
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("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
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.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
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("ContributorsLink");
b.Navigation("SeriesLink");
});
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 Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddAbsentFromLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AbsentFromLastScan",
table: "LibraryBooks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AbsentFromLastScan",
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", "7.0.2");
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("DataLayer.Book", b =>
{
@@ -157,6 +157,9 @@ namespace DataLayer.Migrations
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
@@ -272,6 +275,12 @@ namespace DataLayer.Migrations
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");

View File

@@ -107,8 +107,9 @@ namespace DataLayer
=> bookList
.Where(
lb =>
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
!lb.AbsentFromLastScan &&
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
);
}
}

View File

@@ -8,5 +8,7 @@ namespace DtoImporterService
public Item DtoItem { get; set; }
public string AccountId { get; set; }
public string LocaleName { get; set; }
public override string ToString()
=> DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}";
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
namespace DtoImporterService
@@ -40,9 +41,8 @@ namespace DtoImporterService
//
// CURRENT SOLUTION: don't re-insert
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
var newItems = importItems
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
.ExceptBy(DbContext.LibraryBooks.Select(lb => lb.Book.AudibleProductId), imp => imp.DtoItem.ProductId)
.ToList();
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
@@ -55,7 +55,11 @@ namespace DtoImporterService
var libraryBook = new LibraryBook(
bookImporter.Cache[newItem.DtoItem.ProductId],
newItem.DtoItem.DateAdded,
newItem.AccountId);
newItem.AccountId)
{
AbsentFromLastScan = isPlusTitleUnavailable(newItem)
};
try
{
DbContext.LibraryBooks.Add(libraryBook);
@@ -66,8 +70,30 @@ namespace DtoImporterService
}
}
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null))
nullBook.AbsentFromLastScan = true;
//Join importItems on LibraryBooks before iterating over LibraryBooks to avoid
//quadratic complexity caused by searching all of importItems for each LibraryBook.
//Join uses hashing, so complexity should approach O(N) instead of O(N^2).
var items_lbs
= importItems
.Join(DbContext.LibraryBooks, o => (o.AccountId, o.DtoItem.ProductId), i => (i.Account, i.Book?.AudibleProductId), (o, i) => (o, i));
foreach ((ImportItem item, LibraryBook lb) in items_lbs)
lb.AbsentFromLastScan = isPlusTitleUnavailable(item);
var qtyNew = hash.Count;
return qtyNew;
}
//This SEEMS to work to detect plus titles which are no longer available.
//I have my doubts it won't yield false negatives, but I have more
//confidence that it won't yield many/any false positives.
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.IsAyce is true
&& item.DtoItem.Plans?.Any(p => p.PlanName.ContainsInsensitive("Minerva") || p.PlanName.ContainsInsensitive("Free")) is not true;
}
}

View File

@@ -41,7 +41,7 @@ namespace FileLiberator
OnBegin(libraryBook);
try
try
{
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
@@ -61,31 +61,30 @@ namespace FileLiberator
}
// decrypt failed
if (!success)
if (!success || getFirstAudioFile(entries) == default)
{
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
FileUtility.SaferDelete(tmpFile.Path);
await Task.WhenAll(
entries
.Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
return abDownloader?.IsCanceled == true ?
new StatusHandler { "Cancelled" } :
new StatusHandler { "Decrypt failed" };
return
abDownloader?.IsCanceled is true
? new StatusHandler { "Cancelled" }
: new StatusHandler { "Decrypt failed" };
}
// moves new files from temp dir to final dest.
// This could take a few seconds if moving hundreds of files.
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
var finalStorageDir = getDestinationDirectory(libraryBook);
// decrypt failed
if (finalStorageDir is null)
return new StatusHandler { "Cannot find final audio file after decryption" };
Task[] finalTasks = new[]
{
Task.Run(() => downloadCoverArt(libraryBook)),
Task.Run(() => moveFilesToBooksDir(libraryBook, entries)),
Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)),
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
};
if (Configuration.Instance.DownloadCoverArt)
downloadCoverArt(libraryBook);
// contains logic to check for config setting and OS
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
await Task.WhenAll(finalTasks);
return new StatusHandler();
}
@@ -131,8 +130,8 @@ namespace FileLiberator
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
@@ -335,18 +334,12 @@ namespace FileLiberator
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
{
// create final directory. move each file into it
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
Directory.CreateDirectory(destinationDir);
var destinationDir = getDestinationDirectory(libraryBook);
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
if (getFirstAudio() == default)
return null;
for (var i = 0; i < entries.Count; i++)
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
@@ -357,22 +350,33 @@ namespace FileLiberator
entries[i] = entry with { Path = realDest };
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
AudibleFileStorage.Audio.Refresh();
return destinationDir;
}
private static void downloadCoverArt(LibraryBook libraryBook)
private static string getDestinationDirectory(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
if (!Directory.Exists(destinationDir))
Directory.CreateDirectory(destinationDir);
return destinationDir;
}
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(LibraryBook libraryBook)
{
if (!Configuration.Instance.DownloadCoverArt) return;
var coverPath = "[null]";
try
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
var destinationDir = getDestinationDirectory(libraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

View File

@@ -8,6 +8,8 @@
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="{StaticResource SystemAltHighColor}" />
<SolidColorBrush x:Key="ProcessQueueBookBorderBrush" Color="Gray" />
<SolidColorBrush x:Key="DisabledGrayBrush" Color="#60D3D3D3" />
</Styles.Resources>
<Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="LightGray" />

View File

@@ -0,0 +1,30 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LibationAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
Items="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,46 @@
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationAvalonia.Controls
{
public partial class CheckedListBox : UserControl
{
public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.CheckboxItems = Items;
base.OnPropertyChanged(change);
}
private class CheckedListBoxViewModel : ViewModelBase
{
private AvaloniaList<CheckBoxViewModel> _checkboxItems;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get => _checkboxItems; set => this.RaiseAndSetIfChanged(ref _checkboxItems, value); }
}
}
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}
}

View File

@@ -1,8 +1,5 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using System;
using System.ComponentModel;
using System.IO;
@@ -16,23 +13,8 @@ namespace LibationAvalonia.Dialogs
public string PictureFileName { get; set; }
public string BookSaveDirectory { get; set; }
private byte[] _coverBytes;
public byte[] CoverBytes
{
get => _coverBytes;
set
{
_coverBytes = value;
var ms = new MemoryStream(_coverBytes);
ms.Position = 0;
_bitmapHolder.CoverImage = new Bitmap(ms);
}
}
private readonly BitmapHolder _bitmapHolder = new BitmapHolder();
public ImageDisplayDialog()
{
InitializeComponent();
@@ -45,6 +27,21 @@ namespace LibationAvalonia.Dialogs
AvaloniaXamlLoader.Load(this);
}
public void SetCoverBytes(byte[] cover)
{
try
{
var ms = new MemoryStream(cover);
_bitmapHolder.CoverImage = new Bitmap(ms);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {file}", PictureFileName);
using var ms = App.OpenAsset("img-coverart-prod-unavailable_500x500.jpg");
_bitmapHolder.CoverImage = new Bitmap(ms);
}
}
public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var options = new FilePickerSaveOptions
@@ -70,7 +67,7 @@ namespace LibationAvalonia.Dialogs
try
{
File.WriteAllBytes(uri.LocalPath, CoverBytes);
_bitmapHolder.CoverImage.Save(uri.LocalPath);
}
catch (Exception ex)
{

View File

@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750"
MinWidth="900" MinHeight="700"
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
@@ -376,7 +376,7 @@
Grid.Row="3"
Margin="5"
VerticalAlignment="Top"
IsVisible="{Binding IsWindows}"
IsVisible="{Binding !IsLinux}"
IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}">
<TextBlock

View File

@@ -108,7 +108,8 @@ namespace LibationAvalonia.Dialogs
LoadSettings(config);
}
public bool IsWindows => AppScaffolding.LibationScaffolding.ReleaseIdentifier is AppScaffolding.ReleaseIdentifier.WindowsAvalonia;
public bool IsLinux => Configuration.IsLinux;
public bool IsWindows => Configuration.IsWindows;
public ImportantSettings ImportantSettings { get; private set; }
public ImportSettings ImportSettings { get; private set; }
public DownloadDecryptSettings DownloadDecryptSettings { get; private set; }

View File

@@ -0,0 +1,66 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="630" d:DesignHeight="480"
x:Class="LibationAvalonia.Dialogs.TrashBinDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
MinWidth="630" MinHeight="480"
Title="Trash Bin"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
<Grid
RowDefinitions="Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="Check books you want to permanently delete from or restore to Libation" />
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,*,Auto">
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Restore"
Click="Restore_Click" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Click="EmptyTrash_Click" >
<TextBlock
TextAlignment="Center"
Text="Permanently Delete&#xa;from Libation" />
</Button>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,145 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using LibationAvalonia.Controls;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class TrashBinDialog : Window
{
TrashBinViewModel _viewModel;
public TrashBinDialog()
{
InitializeComponent();
this.RestoreSizeAndLocation(Configuration.Instance);
DataContext = _viewModel = new();
this.Closing += (_,_) => this.SaveSizeAndLocation(Configuration.Instance);
}
public async void EmptyTrash_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.PermanentlyDeleteCheckedAsync();
public async void Restore_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.RestoreCheckedAsync();
}
public class TrashBinViewModel : ViewModelBase, IDisposable
{
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
private bool _controlsEnabled = true;
public bool ControlsEnabled { get => _controlsEnabled; set=> this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
private bool? everythingChecked = false;
public bool? EverythingChecked
{
get => everythingChecked;
set
{
everythingChecked = value ?? false;
if (everythingChecked is true)
CheckAll();
else if (everythingChecked is false)
UncheckAll();
}
}
private int _totalBooksCount = 0;
private int _checkedBooksCount = -1;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
everythingChecked
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
: _checkedBooksCount == _totalBooksCount ? true
: null;
this.RaisePropertyChanged(nameof(EverythingChecked));
}
}
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
public TrashBinViewModel()
{
DeletedBooks = new()
{
ResetBehavior = ResetBehavior.Remove
};
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
Reload();
}
public void CheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = true;
}
public void UncheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = false;
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
private void Reload()
{
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
_totalBooksCount = DeletedBooks.Count;
CheckedBooksCount = 0;
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
}
public void Dispose() => tracker?.Dispose();
}
}

View File

@@ -31,6 +31,8 @@
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<None Remove=".gitignore" />
<None Remove="Assets\Arrows_left.png" />
<None Remove="Assets\Arrows_right.png" />
<None Remove="Assets\Asterisk.png" />
<None Remove="Assets\cancel.png" />
<None Remove="Assets\completed.png" />

View File

@@ -1,5 +1,6 @@
using ApplicationServices;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using DataLayer;
using Dinah.Core;
using FileLiberator;
@@ -35,18 +36,31 @@ namespace LibationAvalonia.ViewModels
#region Model properties exposed to the view
private Avalonia.Media.Imaging.Bitmap _cover;
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } }
public string PurchaseDate { get; protected set; }
public string Series { get; protected set; }
public string Title { get; protected set; }
public string Length { get; protected set; }
public string Authors { get; protected set; }
public string Narrators { get; protected set; }
public string Category { get; protected set; }
public string Misc { get; protected set; }
public string Description { get; protected set; }
public Rating ProductRating { get; protected set; }
private string _purchasedate;
private string _series;
private string _title;
private string _length;
private string _authors;
private string _narrators;
private string _category;
private string _misc;
private LastDownloadStatus _lastDownload;
private string _description;
private Rating _productrating;
protected Rating _myRating;
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); }
public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); }
public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); }
public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); }
public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); }
public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); }
public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); }
public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); }
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => this.RaiseAndSetIfChanged(ref _lastDownload, value); }
public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); }
public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); }
public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); }
public Rating MyRating
{
get => _myRating;
@@ -65,7 +79,6 @@ namespace LibationAvalonia.ViewModels
public abstract bool? Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public abstract BookTags BookTags { get; }
public abstract bool IsSeries { get; }
public abstract bool IsEpisode { get; }
public abstract bool IsBook { get; }
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
@@ -111,6 +124,7 @@ namespace LibationAvalonia.ViewModels
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
@@ -126,8 +140,7 @@ namespace LibationAvalonia.ViewModels
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
using var ms = new System.IO.MemoryStream(picture);
_cover = new Avalonia.Media.Imaging.Bitmap(ms);
_cover = loadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
@@ -143,12 +156,28 @@ namespace LibationAvalonia.ViewModels
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
using var ms = new System.IO.MemoryStream(e.Picture);
Cover = new Avalonia.Media.Imaging.Bitmap(ms);
Cover = loadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
private Bitmap loadImage(byte[] picture)
{
try
{
using var ms = new System.IO.MemoryStream(picture);
return new Bitmap(ms);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return DefaultImage;
}
}
private static Bitmap _defaultImage;
private static Bitmap DefaultImage => _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg"));
#endregion
#region Static library display functions

View File

@@ -8,9 +8,10 @@ namespace LibationAvalonia.ViewModels
{
public class LiberateButtonStatus : ViewModelBase, IComparable
{
public LiberateButtonStatus(bool isSeries)
public LiberateButtonStatus(bool isSeries, bool isAbsent)
{
IsSeries = isSeries;
IsAbsent = isAbsent;
}
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
@@ -26,7 +27,10 @@ namespace LibationAvalonia.ViewModels
this.RaisePropertyChanged(nameof(ToolTip));
}
}
private bool IsSeries { get; }
private bool IsAbsent { get; }
public bool IsSeries { get; }
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public Bitmap Image => GetLiberateIcon();
public string ToolTip => GetTooltip();
@@ -40,6 +44,8 @@ namespace LibationAvalonia.ViewModels
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
else if (IsUnavailable && !second.IsUnavailable) return 1;
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);
@@ -72,11 +78,15 @@ namespace LibationAvalonia.ViewModels
return GetFromResources($"liberate_{image_lib}{image_pdf}");
}
private string GetTooltip()
{
if (IsSeries)
return Expanded ? "Click to Collpase" : "Click to Expand";
if (IsUnavailable)
return "This book cannot be downloaded\nbecause it wasn't found during\nthe most recent library scan";
if (BookStatus == LiberatedStatus.Error)
return "Book downloaded ERROR";

View File

@@ -44,13 +44,12 @@ namespace LibationAvalonia.ViewModels
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus(IsSeries) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
}
}
public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) };
public override bool IsSeries => false;
public override bool IsEpisode => Parent is not null;
public override bool IsBook => Parent is null;
@@ -58,8 +57,22 @@ namespace LibationAvalonia.ViewModels
public LibraryBookEntry(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
setLibraryBook(libraryBook);
LoadCover();
}
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
throw new Exception("Invalid grid entry update. IDs must match");
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
setLibraryBook(libraryBook);
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
@@ -73,10 +86,13 @@ namespace LibationAvalonia.ViewModels
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
this.RaisePropertyChanged(nameof(MyRating));
this.RaisePropertyChanged(nameof(Liberate));
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
@@ -112,6 +128,10 @@ namespace LibationAvalonia.ViewModels
_pdfStatus = udi.PdfStatus;
this.RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new(udi);
this.RaisePropertyChanged(nameof(LastDownload));
break;
}
}
@@ -134,6 +154,7 @@ namespace LibationAvalonia.ViewModels
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },

View File

@@ -1,5 +1,4 @@
using ApplicationServices;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
@@ -135,15 +134,9 @@ namespace LibationAvalonia.ViewModels
set
{
this.RaiseAndSetIfChanged(ref _queueOpen, value);
QueueHideButtonText = _queueOpen? "❱❱❱" : "❰❰❰";
this.RaisePropertyChanged(nameof(QueueHideButtonText));
}
}
/// <summary> The Process Queue's Expand/Collapse button display text </summary>
public string QueueHideButtonText { get; private set; }
/// <summary> The number of books visible in the Product Display </summary>
public int VisibleCount
@@ -172,18 +165,6 @@ namespace LibationAvalonia.ViewModels
{
this.RaiseAndSetIfChanged(ref _libraryStats, value);
var backupsCountText
= !LibraryStats.HasBookResults ? "No books. Begin by importing your library"
: !LibraryStats.HasPendingBooks ? $"All {"book".PluralizeWithCount(LibraryStats.booksFullyBackedUp)} backed up"
: $"BACKUPS: No progress: {LibraryStats.booksNoProgress} In process: {LibraryStats.booksDownloadedOnly} Fully backed up: {LibraryStats.booksFullyBackedUp} {(LibraryStats.booksError > 0 ? $" Errors : {LibraryStats.booksError}" : "")}";
var pdfCountText
= !LibraryStats.HasPdfResults ? ""
: LibraryStats.pdfsNotDownloaded == 0 ? $" | All {LibraryStats.pdfsDownloaded} PDFs downloaded"
: $" | PDFs: NOT d/l'ed: {LibraryStats.pdfsNotDownloaded} Downloaded: {LibraryStats.pdfsDownloaded}";
StatusCountText = backupsCountText + pdfCountText;
BookBackupsToolStripText
= LibraryStats.HasPendingBooks
? $"Begin _Book and PDF Backups: {LibraryStats.PendingBooks} remaining"
@@ -194,21 +175,17 @@ namespace LibationAvalonia.ViewModels
? $"Begin _PDF Only Backups: {LibraryStats.pdfsNotDownloaded} remaining"
: "All PDFs have been downloaded";
this.RaisePropertyChanged(nameof(StatusCountText));
this.RaisePropertyChanged(nameof(BookBackupsToolStripText));
this.RaisePropertyChanged(nameof(PdfBackupsToolStripText));
}
}
/// <summary> Bottom-left library statistics display text </summary>
public string StatusCountText { get; private set; } = "[Calculating backed up book quantities] | [Calculating backed up PDFs]";
/// <summary> The "Begin Book and PDF Backup" menu item header text </summary>
public string BookBackupsToolStripText { get; private set; } = "Begin _Book and PDF Backups: 0";
/// <summary> The "Begin PDF Only Backup" menu item header text </summary>
public string PdfBackupsToolStripText { get; private set; } = "Begin _PDF Only Backups: 0";
/// <summary> The number of books visible in the Products Display that have not yet been liberated </summary>
public int VisibleNotLiberated
{

View File

@@ -1,4 +1,5 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
@@ -16,9 +17,8 @@ namespace LibationAvalonia.ViewModels
public class ProcessQueueViewModel : ViewModelBase, ILogForm
{
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public TrackedQueue<ProcessBookViewModel> Items { get; } = new();
private TrackedQueue<ProcessBookViewModel> Queue => Items;
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
public TrackedQueue<ProcessBookViewModel> Queue { get; }
public ProcessBookViewModel SelectedItem { get; set; }
public Task QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false;
@@ -28,6 +28,7 @@ namespace LibationAvalonia.ViewModels
public ProcessQueueViewModel()
{
Logger = LogMe.RegisterForm(this);
Queue = new(Items);
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
@@ -88,19 +89,19 @@ namespace LibationAvalonia.ViewModels
public decimal SpeedLimitIncrement { get; private set; }
private void Queue_CompletedCountChanged(object sender, int e)
private async void Queue_CompletedCountChanged(object sender, int e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;
CompletedCount = completeCount;
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
}
private void Queue_QueuededCountChanged(object sender, int cueCount)
private async void Queue_QueuededCountChanged(object sender, int cueCount)
{
QueuedCount = cueCount;
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
}
public void WriteLine(string text)

View File

@@ -20,11 +20,11 @@ namespace LibationAvalonia.ViewModels
public event EventHandler<int> RemovableCountChanged;
/// <summary>Backing list of all grid entries</summary>
private readonly List<GridEntry> SOURCE = new();
private readonly AvaloniaList<GridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private List<GridEntry> FilteredInGridEntries;
public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; }
public DataGridCollectionView GridEntries { get; private set; }
private bool _removeColumnVisivle;
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
@@ -42,59 +42,60 @@ namespace LibationAvalonia.ViewModels
public ProductsDisplayViewModel()
{
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
GridEntries = new(SOURCE);
GridEntries.Filter = CollectionFilter;
VisibleCountChanged?.Invoke(this, 0);
}
GridEntries.CollectionChanged += (s, e)
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
private static readonly System.Reflection.MethodInfo SetFlagsMethod;
/// <summary>
/// Tells the <see cref="DataGridCollectionView"/> whether it should process changes to the underlying collection
/// </summary>
/// <remarks> DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4</remarks>
private void SetShouldProcessCollectionChanged(bool flagSet)
=> SetFlagsMethod.Invoke(GridEntries, new object[] { 4, flagSet });
static ProductsDisplayViewModel()
{
/*
* When a book is removed from the library, SearchEngineUpdated is fired before LibrarySizeChanged, so
* the book is removed from the filtered set and the grid is refreshed before RemoveBooks() is ever
* called.
*
* To remove an item from DataGridCollectionView, it must be be in the current filtered view. If it's
* not and you try to remove the book from the source list, the source will fire NotifyCollectionChanged
* on an invalid item and the DataGridCollectionView will throw an exception. There are two ways to
* remove an item that is filtered out of the DataGridCollectionView:
*
* (1) Re-add the item to the filtered-in list and refresh the grid so DataGridCollectionView knows
* that the item is present. This causes the whole grid to flicker to refresh twice in rapid
* succession, which is undesirable.
*
* (2) Remove it from the underlying collection and suppress NotifyCollectionChanged. This is the
* method used. Steps to complete a removal using this method:
*
* (a) Set DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged to false.
* (b) Remove the item from the source list. The source will fire NotifyCollectionChanged, but the
* DataGridCollectionView will ignore it.
* (c) Reset the flag to true.
*/
SetFlagsMethod =
typeof(DataGridCollectionView)
.GetMethod("SetFlag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
}
#region Display Functions
/// <summary>
/// Call when there's been a change to the library
/// </summary>
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
internal void BindToGrid(List<LibraryBook> dbBooks)
{
try
GridEntries = new(SOURCE)
{
var existingSeriesEntries = SOURCE.SeriesEntries().ToList();
Filter = CollectionFilter
};
FilteredInGridEntries?.Clear();
SOURCE.Clear();
SOURCE.AddRange(CreateGridEntries(dbBooks));
GridEntries.CollectionChanged += (_, _)
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
//If replacing the list, preserve user's existing collapse/expand
//state. When resetting a list, default state is cosed.
foreach (var series in existingSeriesEntries)
{
var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId);
if (sEntry is SeriesEntry se)
se.Liberate.Expanded = series.Liberate.Expanded;
}
//Run query on new list
FilteredInGridEntries = QueryResults(SOURCE, FilterString);
await refreshGrid();
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel));
}
}
private async Task refreshGrid()
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
private static List<GridEntry> CreateGridEntries(IEnumerable<LibraryBook> dbBooks)
{
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
@@ -103,26 +104,159 @@ namespace LibationAvalonia.ViewModels
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent()))
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
foreach (var parent in seriesBooks)
{
var seriesEpisodes = episodes.FindChildren(parent);
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
}
var bookList = geList.OrderByDescending(e => e.DateAdded).ToList();
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = QueryResults(geList, FilterString);
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
}
//ListIndex is used by RowComparer to make column sort stable
int index = 0;
foreach (GridEntry di in bookList)
di.ListIndex = index++;
/// <summary>
/// Call when there's been a change to the library
/// </summary>
internal async Task UpdateGridAsync(List<LibraryBook> dbBooks)
{
#region Add new or update existing grid entries
return bookList;
//Add absent entries to grid, or update existing entry
var allEntries = SOURCE.BookEntries();
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes();
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
if (libraryBook.Book.IsProduct())
UpsertBook(libraryBook, existingEntry);
else if (parentedEpisodes.Any(lb => lb == libraryBook))
//Only try to add or update is this LibraryBook is a know child of a parent
UpsertEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
});
#endregion
#region Remove entries no longer in the library
//Rapid successive book removals will cause changes to SOURCE after the update has
//begun but before it has completed, so perform all updates on a copy of the list.
var sourceSnapshot = SOURCE.ToList();
// remove deleted from grid.
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
var removedBooks =
sourceSnapshot
.BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
var removedSeries = sourceSnapshot.EmptySeries();
await Dispatcher.UIThread.InvokeAsync(() => RemoveBooks(removedBooks, removedSeries));
#endregion
await Filter(FilterString);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
private void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
{
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
{
if (GridEntries.PassesFilter(removed))
GridEntries.Remove(removed);
else
{
SetShouldProcessCollectionChanged(false);
SOURCE.Remove(removed);
SetShouldProcessCollectionChanged(true);
}
}
}
private void UpsertBook(LibraryBook book, LibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
if (seriesEntry is null)
{
//Series doesn't exist yet, so create and add it
var seriesBook = dbBooks.FindSeriesParent(episodeBook);
if (seriesBook is null)
{
//This is only possible if the user's db has some malformed
//entries from earlier Libation releases that could not be
//automatically fixed. Log, but don't throw.
Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames());
return;
}
seriesEntry = new SeriesEntry(seriesBook, new[] { episodeBook });
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
seriesEntry.Liberate.Expanded = true;
SOURCE.Insert(0, seriesEntry);
}
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new(episodeBook) { Parent = seriesEntry };
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Add episode to the grid beneath the parent
int seriesIndex = SOURCE.IndexOf(seriesEntry);
SOURCE.Insert(seriesIndex + 1, episodeEntry);
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
}
private async Task refreshGrid()
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
@@ -138,9 +272,6 @@ namespace LibationAvalonia.ViewModels
public async Task Filter(string searchString)
{
if (searchString == FilterString)
return;
FilterString = searchString;
if (SOURCE.Count == 0)
@@ -206,10 +337,10 @@ namespace LibationAvalonia.ViewModels
if (selectedBooks.Count == 0)
return;
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var result = await MessageBox.ShowConfirmationDialog(
null,
libraryBooks,
booksToRemove,
// do not use `$` string interpolation. See impl.
"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
@@ -220,8 +351,6 @@ namespace LibationAvalonia.ViewModels
foreach (var book in selectedBooks)
book.PropertyChanged -= GridEntry_PropertyChanged;
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
@@ -240,7 +369,7 @@ namespace LibationAvalonia.ViewModels
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
//so there's no need to remove books from the grid display here.
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
await booksToRemove.RemoveBooksAsync();
RemovableCountChanged?.Invoke(this, 0);
}

View File

@@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;

View File

@@ -46,7 +46,6 @@ namespace LibationAvalonia.ViewModels
public override LiberateButtonStatus Liberate { get; }
public override BookTags BookTags { get; } = new();
public override bool IsSeries => true;
public override bool IsEpisode => false;
public override bool IsBook => false;
@@ -54,17 +53,38 @@ namespace LibationAvalonia.ViewModels
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
Liberate = new LiberateButtonStatus(IsSeries);
Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false);
SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
Children = children
.Select(c => new LibraryBookEntry(c) { Parent = this })
.OrderBy(c => c.SeriesIndex)
.ToList();
setLibraryBook(parent);
LoadCover();
}
public void RemoveChild(LibraryBookEntry lbe)
{
Children.Remove(lbe);
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
throw new Exception("Invalid grid entry update. IDs must match");
setLibraryBook(libraryBook);
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
@@ -75,12 +95,15 @@ namespace LibationAvalonia.ViewModels
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(LibraryBook);
LastDownload = new();
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
this.RaisePropertyChanged(nameof(MyRating));
}
@@ -101,6 +124,7 @@ namespace LibationAvalonia.ViewModels
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },

View File

@@ -1,4 +1,5 @@
using AudibleUtilities;
using LibationAvalonia.Dialogs;
using System;
using System.Linq;
@@ -15,6 +16,12 @@ namespace LibationAvalonia.Views
_viewModel.RemoveButtonsVisible = false;
}
public async void openTrashBinToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var trash = new TrashBinDialog();
await trash.ShowDialog(this);
}
public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
// if 0 accounts, this will not be visible

View File

@@ -144,11 +144,8 @@ namespace LibationAvalonia.Views
"Remove books from Libation?",
MessageBoxDefaultButton.Button2);
if (confirmationResult != DialogResult.Yes)
return;
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
await LibraryCommands.RemoveBooksAsync(visibleIds);
if (confirmationResult is DialogResult.Yes)
await visibleLibraryBooks.RemoveBooksAsync();
}
public async void ProductsDisplay_VisibleCountChanged(object sender, int qty)
{
@@ -157,9 +154,9 @@ namespace LibationAvalonia.Views
await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem);
}
void setLiberatedVisibleMenuItem()
=> _viewModel.VisibleNotLiberated
= _viewModel.ProductsDisplay
.GetVisibleBookEntries()
.Count(lb => lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.NotLiberated);
{
var libraryStats = LibraryCommands.GetCounts(_viewModel.ProductsDisplay.GetVisibleBookEntries());
_viewModel.VisibleNotLiberated = libraryStats.PendingBooks;
}
}
}

View File

@@ -131,6 +131,7 @@
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
<Separator />
<MenuItem Click="openTrashBinToolStripMenuItem_Click" Header="Trash Bin" />
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
<Separator />
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
@@ -172,7 +173,12 @@
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
<Button Click="filterBtn_Click" Height="30" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click" Content="{Binding QueueHideButtonText}"/>
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click">
<Panel>
<Image Stretch="None" IsVisible="{Binding !QueueOpen}" Source="/Assets/Arrows_left.png" />
<Image Stretch="None" IsVisible="{Binding QueueOpen}" Source="/Assets/Arrows_right.png" />
</Panel>
</Button>
</StackPanel>
</Grid>
@@ -203,7 +209,7 @@
<TextBlock FontSize="14" Grid.Column="0" Text="Upgrading:" VerticalAlignment="Center" IsVisible="{Binding DownloadProgress, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ProgressBar Grid.Column="1" Margin="5,0,10,0" VerticalAlignment="Stretch" Width="100" Value="{Binding DownloadProgress}" IsVisible="{Binding DownloadProgress, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<TextBlock FontSize="14" Grid.Column="2" Text="{Binding VisibleCountText}" VerticalAlignment="Center" />
<TextBlock FontSize="14" Grid.Column="3" Text="{Binding StatusCountText}" VerticalAlignment="Center" />
<TextBlock FontSize="14" Grid.Column="3" Text="{Binding LibraryStats.StatusString}" VerticalAlignment="Center" />
</Grid>
</Grid>
</Border>

View File

@@ -51,7 +51,7 @@ namespace LibationAvalonia.Views
{
this.LibraryLoaded += MainWindow_LibraryLoaded;
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooksAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
}
Closing += MainWindow_Closing;
@@ -67,7 +67,7 @@ namespace LibationAvalonia.Views
if (QuickFilters.UseDefault)
await performFilter(QuickFilters.Filters.FirstOrDefault());
await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks);
_viewModel.ProductsDisplay.BindToGrid(dbBooks);
}
private void InitializeComponent()

View File

@@ -15,7 +15,7 @@ namespace LibationAvalonia.Views
{
public partial class ProcessQueueControl : UserControl
{
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Items;
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Queue;
private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel;
public ProcessQueueControl()
@@ -76,14 +76,14 @@ namespace LibationAvalonia.Views
},
};
vm.Items.Enqueue(testList);
vm.Items.MoveNext();
vm.Items.MoveNext();
vm.Items.MoveNext();
vm.Items.MoveNext();
vm.Items.MoveNext();
vm.Items.MoveNext();
vm.Items.MoveNext();
vm.Queue.Enqueue(testList);
vm.Queue.MoveNext();
vm.Queue.MoveNext();
vm.Queue.MoveNext();
vm.Queue.MoveNext();
vm.Queue.MoveNext();
vm.Queue.MoveNext();
vm.Queue.MoveNext();
return;
}
#endregion

View File

@@ -61,9 +61,13 @@
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="75" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Opacity="{Binding Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" ToolTip.Tip="{Binding Liberate.ToolTip}">
<Image Source="{Binding Liberate.Image}" Stretch="None" />
</Button>
<Panel ToolTip.Tip="{Binding Liberate.ToolTip}">
<Button Opacity="{Binding Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" IsVisible="{Binding !Liberate.IsUnavailable}">
<Image Source="{Binding Liberate.Image}" Stretch="None" />
</Button>
<Image Source="{Binding Liberate.Image}" Stretch="None" IsVisible="{Binding Liberate.IsUnavailable}"/>
<Panel Background="{StaticResource DisabledGrayBrush}" IsVisible="{Binding Liberate.IsUnavailable}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
@@ -183,6 +187,16 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="102" Header="Last&#xA;Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" ToolTip.Tip="{Binding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
<TextBlock Text="{Binding LastDownload}" TextWrapping="WrapWithOverflow" FontSize="10" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
<DataGridTemplateColumn.CellTemplate>

View File

@@ -31,20 +31,25 @@ namespace LibationAvalonia.Views
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
List<LibraryBook> sampleEntries = new()
List<LibraryBook> sampleEntries;
try
{
//context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),
context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"),
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")
};
sampleEntries = new()
{
//context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),try{
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"),
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")
};
}
catch { sampleEntries = new(); }
var pdvm = new ProductsDisplayViewModel();
_ = pdvm.DisplayBooksAsync(sampleEntries);
pdvm.BindToGrid(sampleEntries);
DataContext = pdvm;
return;
@@ -84,7 +89,7 @@ namespace LibationAvalonia.Views
{
var entry = args.GridEntry;
if (entry.IsSeries)
if (entry.Liberate.IsSeries)
return;
var setDownloadMenuItem = new MenuItem()
@@ -102,7 +107,7 @@ namespace LibationAvalonia.Views
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId));
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
locateFileMenuItem.Click += async (_, __) =>
@@ -135,7 +140,7 @@ namespace LibationAvalonia.Views
var convertToMp3MenuItem = new MenuItem
{
Header = "_Convert to Mp3",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook);
@@ -306,6 +311,12 @@ namespace LibationAvalonia.Views
imageDisplayDialog.Close();
}
public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is Control panel && panel.DataContext is LibraryBookEntry lbe && lbe.LastDownload.IsValid)
lbe.LastDownload.OpenReleaseUrl();
}
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry)
@@ -321,7 +332,7 @@ namespace LibationAvalonia.Views
void PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == picDef.PictureId)
imageDisplayDialog.CoverBytes = e.Picture;
imageDisplayDialog.SetCoverBytes(e.Picture);
PictureStorage.PictureCached -= PictureCached;
}
@@ -336,7 +347,7 @@ namespace LibationAvalonia.Views
imageDisplayDialog.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook);
imageDisplayDialog.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg"));
imageDisplayDialog.Title = windowTitle;
imageDisplayDialog.CoverBytes = initialImageBts;
imageDisplayDialog.SetCoverBytes(initialImageBts);
if (!isDefault)
PictureStorage.PictureCached -= PictureCached;

View File

@@ -20,6 +20,8 @@ namespace LibationFileManager
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
public static Version LibationVersion { get; private set; }
public static void SetLibationVersion(Version version) => LibationVersion = version;
public static OS OS { get; }
= IsLinux ? OS.Linux

View File

@@ -73,7 +73,7 @@ namespace LibationFileManager
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
[Description("Set cover art as the folder's icon. (Windows only)")]
[Description("Set cover art as the folder's icon. (Windows and macOS only)")]
public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]

View File

@@ -9,11 +9,13 @@ namespace LibationFileManager
{
public static class WindowsDirectory
{
public static void SetCoverAsFolderIcon(string pictureId, string directory)
{
try
{
if (!Configuration.Instance.UseCoverAsFolderIcon || !Configuration.IsWindows)
//Currently only works for Windows and macOS
if (!Configuration.Instance.UseCoverAsFolderIcon || Configuration.IsLinux)
return;
// get path of cover art in Images dir. Download first if not exists

View File

@@ -0,0 +1,52 @@
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SixLabors.ImageSharp.Formats;
namespace LibationUiBase
{
public class IcoEncoder : IImageEncoder
{
public bool SkipMetadata { get; init; } = true;
public void Encode<TPixel>(Image<TPixel> image, Stream stream) where TPixel : unmanaged, IPixel<TPixel>
{
// https://stackoverflow.com/a/21389253
using var ms = new MemoryStream();
//Knowing the image size ahead of time removes the
//requirement of the output stream to support seeking.
image.SaveAsPng(ms);
//Disposing of the BinaryWriter disposes the soutput stream. Let the caller clean up.
var bw = new BinaryWriter(stream);
// Header
bw.Write((short)0); // 0-1 : reserved
bw.Write((short)1); // 2-3 : 1=ico, 2=cur
bw.Write((short)1); // 4-5 : number of images
// Image directory
var w = image.Width;
if (w >= 256) w = 0;
bw.Write((byte)w); // 0 : width of image
var h = image.Height;
if (h >= 256) h = 0;
bw.Write((byte)h); // 1 : height of image
bw.Write((byte)0); // 2 : number of colors in palette
bw.Write((byte)0); // 3 : reserved
bw.Write((short)0); // 4 : number of color planes
bw.Write((short)0); // 6 : bits per pixel
bw.Write((int)ms.Position); // 8 : image size
bw.Write((int)stream.Position + 4); // 12: offset of image data
ms.Position = 0;
ms.CopyTo(stream); // Image data
}
public Task EncodeAsync<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel<TPixel>
=> throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,41 @@
using DataLayer;
using System;
namespace LibationUiBase
{
public class LastDownloadStatus : IComparable
{
public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue;
public Version LastDownloadedVersion { get; }
public DateTime? LastDownloaded { get; }
public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : "";
public LastDownloadStatus() { }
public LastDownloadStatus(UserDefinedItem udi)
{
LastDownloadedVersion = udi.LastDownloadedVersion;
LastDownloaded = udi.LastDownloaded;
}
public void OpenReleaseUrl()
{
if (IsValid)
Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion.ToString(3)}");
}
public override string ToString()
=> IsValid ? $"{dateString()}\n\nLibation v{LastDownloadedVersion.ToString(3)}" : "";
//Call ToShortDateString to use current culture's date format.
private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}";
public int CompareTo(object obj)
{
if (obj is not LastDownloadStatus second) return -1;
else if (IsValid && !second.IsValid) return -1;
else if (!IsValid && second.IsValid) return 1;
else if (!IsValid && !second.IsValid) return 0;
else return LastDownloaded.Value.CompareTo(second.LastDownloaded.Value);
}
}
}

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace LibationAvalonia.ViewModels
namespace LibationUiBase
{
public enum QueuePosition
{
@@ -33,7 +32,7 @@ namespace LibationAvalonia.ViewModels
* and is stored in ObservableCollection.Items. When the primary list changes, the
* secondary list is cleared and reset to match the primary.
*/
public class TrackedQueue<T> : ObservableCollection<T> where T : class
public class TrackedQueue<T> where T : class
{
public event EventHandler<int> CompletedCountChanged;
public event EventHandler<int> QueuededCountChanged;
@@ -47,6 +46,60 @@ namespace LibationAvalonia.ViewModels
private readonly List<T> _completed = new();
private readonly object lockObject = new();
private readonly ICollection<T> _underlyingList;
public TrackedQueue(ICollection<T> underlyingList = null)
{
_underlyingList = underlyingList;
}
public T this[int index]
{
get
{
lock (lockObject)
{
if (index < _completed.Count)
return _completed[index];
index -= _completed.Count;
if (index == 0 && Current != null) return Current;
if (Current != null) index--;
if (index < _queued.Count) return _queued.ElementAt(index);
throw new IndexOutOfRangeException();
}
}
}
public int Count
{
get
{
lock (lockObject)
{
return _queued.Count + _completed.Count + (Current == null ? 0 : 1);
}
}
}
public int IndexOf(T item)
{
lock (lockObject)
{
if (_completed.Contains(item))
return _completed.IndexOf(item);
if (Current == item) return _completed.Count;
if (_queued.Contains(item))
return _queued.IndexOf(item) + (Current is null ? 0 : 1);
return -1;
}
}
public bool RemoveQueued(T item)
{
bool itemsRemoved;
@@ -188,29 +241,6 @@ namespace LibationAvalonia.ViewModels
}
}
public bool TryPeek(out T item)
{
lock (lockObject)
{
if (_queued.Count == 0)
{
item = null;
return false;
}
item = _queued[0];
return true;
}
}
public T Peek()
{
lock (lockObject)
{
if (_queued.Count == 0) throw new InvalidOperationException("Queue empty");
return _queued.Count > 0 ? _queued[0] : default;
}
}
public void Enqueue(IEnumerable<T> item)
{
int queueCount;
@@ -220,15 +250,15 @@ namespace LibationAvalonia.ViewModels
queueCount = _queued.Count;
}
foreach (var i in item)
base.Add(i);
_underlyingList?.Add(i);
QueuededCountChanged?.Invoke(this, queueCount);
}
private void RebuildSecondary()
{
base.ClearItems();
_underlyingList?.Clear();
foreach (var item in GetAllItems())
base.Add(item);
_underlyingList?.Add(item);
}
public IEnumerable<T> GetAllItems()

View File

@@ -0,0 +1,129 @@
namespace LibationWinForms.Dialogs
{
partial class TrashBinDialog
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
deletedCbl = new System.Windows.Forms.CheckedListBox();
label1 = new System.Windows.Forms.Label();
restoreBtn = new System.Windows.Forms.Button();
permanentlyDeleteBtn = new System.Windows.Forms.Button();
everythingCb = new System.Windows.Forms.CheckBox();
deletedCheckedLbl = new System.Windows.Forms.Label();
SuspendLayout();
//
// deletedCbl
//
deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
deletedCbl.FormattingEnabled = true;
deletedCbl.Location = new System.Drawing.Point(12, 27);
deletedCbl.Name = "deletedCbl";
deletedCbl.Size = new System.Drawing.Size(776, 364);
deletedCbl.TabIndex = 3;
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
//
// label1
//
label1.AutoSize = true;
label1.Location = new System.Drawing.Point(12, 9);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(388, 15);
label1.TabIndex = 4;
label1.Text = "Check books you want to permanently delete from or restore to Libation";
//
// restoreBtn
//
restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
restoreBtn.Location = new System.Drawing.Point(572, 398);
restoreBtn.Name = "restoreBtn";
restoreBtn.Size = new System.Drawing.Size(75, 40);
restoreBtn.TabIndex = 5;
restoreBtn.Text = "Restore";
restoreBtn.UseVisualStyleBackColor = true;
restoreBtn.Click += restoreBtn_Click;
//
// permanentlyDeleteBtn
//
permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398);
permanentlyDeleteBtn.Name = "permanentlyDeleteBtn";
permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40);
permanentlyDeleteBtn.TabIndex = 5;
permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation";
permanentlyDeleteBtn.UseVisualStyleBackColor = true;
permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click;
//
// everythingCb
//
everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
everythingCb.AutoSize = true;
everythingCb.Location = new System.Drawing.Point(12, 410);
everythingCb.Name = "everythingCb";
everythingCb.Size = new System.Drawing.Size(82, 19);
everythingCb.TabIndex = 6;
everythingCb.Text = "Everything";
everythingCb.ThreeState = true;
everythingCb.UseVisualStyleBackColor = true;
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
//
// deletedCheckedLbl
//
deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
deletedCheckedLbl.AutoSize = true;
deletedCheckedLbl.Location = new System.Drawing.Point(126, 411);
deletedCheckedLbl.Name = "deletedCheckedLbl";
deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
deletedCheckedLbl.TabIndex = 7;
deletedCheckedLbl.Text = "Checked: {0} of {1}";
//
// TrashBinDialog
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(800, 450);
Controls.Add(deletedCheckedLbl);
Controls.Add(everythingCb);
Controls.Add(permanentlyDeleteBtn);
Controls.Add(restoreBtn);
Controls.Add(label1);
Controls.Add(deletedCbl);
Name = "TrashBinDialog";
Text = "Trash Bin";
ResumeLayout(false);
PerformLayout();
}
#endregion
private System.Windows.Forms.CheckedListBox deletedCbl;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Button restoreBtn;
private System.Windows.Forms.Button permanentlyDeleteBtn;
private System.Windows.Forms.CheckBox everythingCb;
private System.Windows.Forms.Label deletedCheckedLbl;
}
}

View File

@@ -0,0 +1,116 @@
using ApplicationServices;
using System;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using DataLayer;
using LibationFileManager;
using System.Collections;
namespace LibationWinForms.Dialogs
{
public partial class TrashBinDialog : Form
{
private readonly string deletedCheckedTemplate;
public TrashBinDialog()
{
InitializeComponent();
this.SetLibationIcon();
this.RestoreSizeAndLocation(Configuration.Instance);
this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
deletedCheckedTemplate = deletedCheckedLbl.Text;
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
deletedCbl.Items.Add(lb);
setLabel();
}
private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e)
{
// CheckedItems.Count is not updated until after the event fires
setLabel(e.NewValue);
}
private async void permanentlyDeleteBtn_Click(object sender, EventArgs e)
{
setControlsEnabled(false);
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
removeFromCheckList(removed);
await Task.Run(removed.PermanentlyDeleteBooks);
setControlsEnabled(true);
}
private async void restoreBtn_Click(object sender, EventArgs e)
{
setControlsEnabled(false);
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
removeFromCheckList(removed);
await Task.Run(removed.RestoreBooks);
setControlsEnabled(true);
}
private void removeFromCheckList(IEnumerable objects)
{
foreach (var o in objects)
deletedCbl.Items.Remove(o);
deletedCbl.Refresh();
setLabel();
}
private void setControlsEnabled(bool enabled)
=> restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled;
private void everythingCb_CheckStateChanged(object sender, EventArgs e)
{
if (everythingCb.CheckState is CheckState.Indeterminate)
{
everythingCb.CheckState = CheckState.Unchecked;
return;
}
deletedCbl.ItemCheck -= deletedCbl_ItemCheck;
for (var i = 0; i < deletedCbl.Items.Count; i++)
deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked);
setLabel();
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
}
private void setLabel(CheckState? checkedState = null)
{
var pre = deletedCbl.CheckedItems.Count;
int count = checkedState switch
{
CheckState.Checked => pre + 1,
CheckState.Unchecked => pre - 1,
_ => pre,
};
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
everythingCb.CheckState
= count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked
: count == 0 ? CheckState.Unchecked
: CheckState.Indeterminate;
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count);
}
}
}

View File

@@ -0,0 +1,60 @@
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -13,7 +13,6 @@ namespace LibationWinForms
// init formattable
beginBookBackupsToolStripMenuItem.Format(0);
beginPdfBackupsToolStripMenuItem.Format(0);
pdfsCountsLbl.Text = "| [Calculating backed up PDFs]";
Load += setBackupCounts;
LibraryCommands.LibrarySizeChanged += setBackupCounts;
@@ -21,9 +20,8 @@ namespace LibationWinForms
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
updateCountsBw.RunWorkerCompleted += exportMenuEnable;
updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers;
updateCountsBw.RunWorkerCompleted += updateBottomStats;
updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem;
updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbers;
updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem;
}
@@ -52,27 +50,13 @@ namespace LibationWinForms
Invoke(() => exportLibraryToolStripMenuItem.Enabled = libraryStats.HasBookResults);
}
// this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text
private const string backupsCountsLbl_Format = "BACKUPS: No progress: {0} In process: {1} Fully backed up: {2}";
private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
private void updateBottomStats(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{
var libraryStats = e.Result as LibraryCommands.LibraryStats;
var formatString
= !libraryStats.HasBookResults ? "No books. Begin by importing your library"
: libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
: libraryStats.HasPendingBooks ? backupsCountsLbl_Format
: $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up";
var statusStripText = string.Format(formatString,
libraryStats.booksNoProgress,
libraryStats.booksDownloadedOnly,
libraryStats.booksFullyBackedUp,
libraryStats.booksError);
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = statusStripText);
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = libraryStats.StatusString);
}
// update 'begin book backups' menu item
// update 'begin book and pdf backups' menu item
private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{
var libraryStats = e.Result as LibraryCommands.LibraryStats;
@@ -88,18 +72,6 @@ namespace LibationWinForms
});
}
private void updateBottomPdfNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{
var libraryStats = e.Result as LibraryCommands.LibraryStats;
// don't need to assign the output of Format(). It just makes this logic cleaner
var statusStripText
= !libraryStats.HasPdfResults ? ""
: libraryStats.pdfsNotDownloaded > 0 ? pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded)
: $"| All {libraryStats.pdfsDownloaded} PDFs downloaded";
statusStrip1.UIThreadAsync(() => pdfsCountsLbl.Text = statusStripText);
}
// update 'begin pdf only backups' menu item
private void udpate_BeginPdfOnlyBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{

View File

@@ -63,6 +63,7 @@
this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
this.openTrashBinToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -76,7 +77,6 @@
this.visibleCountLbl = new LibationWinForms.FormattableToolStripStatusLabel();
this.springLbl = new System.Windows.Forms.ToolStripStatusLabel();
this.backupsCountsLbl = new System.Windows.Forms.ToolStripStatusLabel();
this.pdfsCountsLbl = new LibationWinForms.FormattableToolStripStatusLabel();
this.addQuickFilterBtn = new System.Windows.Forms.Button();
this.splitContainer1 = new System.Windows.Forms.SplitContainer();
this.panel1 = new System.Windows.Forms.Panel();
@@ -383,8 +383,9 @@
this.accountsToolStripMenuItem,
this.basicSettingsToolStripMenuItem,
this.toolStripSeparator4,
this.openTrashBinToolStripMenuItem,
this.launchHangoverToolStripMenuItem,
this.toolStripSeparator2,
this.toolStripSeparator2,
this.aboutToolStripMenuItem});
this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem";
this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20);
@@ -424,8 +425,7 @@
this.upgradePb,
this.visibleCountLbl,
this.springLbl,
this.backupsCountsLbl,
this.pdfsCountsLbl});
this.backupsCountsLbl});
this.statusStrip1.Location = new System.Drawing.Point(0, 618);
this.statusStrip1.Name = "statusStrip1";
this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
@@ -464,13 +464,6 @@
this.backupsCountsLbl.Size = new System.Drawing.Size(218, 17);
this.backupsCountsLbl.Text = "[Calculating backed up book quantities]";
//
// pdfsCountsLbl
//
this.pdfsCountsLbl.FormatText = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}";
this.pdfsCountsLbl.Name = "pdfsCountsLbl";
this.pdfsCountsLbl.Size = new System.Drawing.Size(218, 17);
this.pdfsCountsLbl.Text = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}";
//
// addQuickFilterBtn
//
this.addQuickFilterBtn.Location = new System.Drawing.Point(50, 3);
@@ -592,6 +585,13 @@
this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks";
this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click);
//
// openTrashBinToolStripMenuItem
//
this.openTrashBinToolStripMenuItem.Name = "openTrashBinToolStripMenuItem";
this.openTrashBinToolStripMenuItem.Size = new System.Drawing.Size(247, 22);
this.openTrashBinToolStripMenuItem.Text = "Trash Bin";
this.openTrashBinToolStripMenuItem.Click += new System.EventHandler(this.openTrashBinToolStripMenuItem_Click);
//
// launchHangoverToolStripMenuItem
//
this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem";
@@ -640,7 +640,6 @@
private System.Windows.Forms.ToolStripMenuItem liberateToolStripMenuItem;
private System.Windows.Forms.ToolStripStatusLabel backupsCountsLbl;
private LibationWinForms.FormattableToolStripMenuItem beginBookBackupsToolStripMenuItem;
private LibationWinForms.FormattableToolStripStatusLabel pdfsCountsLbl;
private LibationWinForms.FormattableToolStripMenuItem beginPdfBackupsToolStripMenuItem;
private System.Windows.Forms.TextBox filterSearchTb;
private System.Windows.Forms.Button filterBtn;
@@ -676,6 +675,7 @@
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
private System.Windows.Forms.ToolStripMenuItem openTrashBinToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem;
private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu;
private System.Windows.Forms.SplitContainer splitContainer1;

View File

@@ -16,6 +16,9 @@ namespace LibationWinForms
private async void removeBooksBtn_Click(object sender, EventArgs e)
=> await productsDisplay.RemoveCheckedBooksAsync();
private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e)
=> new TrashBinDialog().ShowDialog(this);
private void doneRemovingBtn_Click(object sender, EventArgs e)
{
removeBooksBtn.Visible = false;

View File

@@ -27,25 +27,22 @@ namespace LibationWinForms
=> await Task.Run(setLiberatedVisibleMenuItem);
void setLiberatedVisibleMenuItem()
{
var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible());
this.UIThreadSync(() =>
{
if (notLiberated > 0)
if (libraryStats.HasPendingBooks)
{
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(notLiberated);
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = true;
liberateVisibleToolStripMenuItem_LiberateMenu.Format(notLiberated);
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = true;
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(libraryStats.PendingBooks);
liberateVisibleToolStripMenuItem_LiberateMenu.Format(libraryStats.PendingBooks);
}
else
{
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Text = "All visible books are liberated";
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = false;
liberateVisibleToolStripMenuItem_LiberateMenu.Text = "All visible books are liberated";
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = false;
}
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = libraryStats.HasPendingBooks;
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = libraryStats.HasPendingBooks;
});
}
@@ -168,11 +165,8 @@ namespace LibationWinForms
"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
if (confirmationResult != DialogResult.Yes)
return;
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
await LibraryCommands.RemoveBooksAsync(visibleIds);
if (confirmationResult is DialogResult.Yes)
await visibleLibraryBooks.RemoveBooksAsync();
}
private async void productsDisplay_VisibleCountChanged(object sender, int qty)
@@ -183,9 +177,6 @@ namespace LibationWinForms
// top menu strip
visibleBooksToolStripMenuItem.Format(qty);
visibleBooksToolStripMenuItem.Enabled = qty > 0;
//Not used for anything?
var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
await Task.Run(setLiberatedVisibleMenuItem);
}

View File

@@ -12,6 +12,6 @@ namespace LibationWinForms.GridView
// per standard INotifyPropertyChanged pattern:
// https://docs.microsoft.com/en-us/dotnet/desktop/wpf/data/how-to-implement-property-change-notification
public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
=> this.UIThreadAsync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
=> this.UIThreadSync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
}
}

View File

@@ -59,6 +59,7 @@ namespace LibationWinForms.GridView
public string Narrators { get; protected set; }
public string Category { get; protected set; }
public string Misc { get; protected set; }
public virtual LastDownloadStatus LastDownload { get; protected set; } = new();
public string Description { get; protected set; }
public string ProductRating { get; protected set; }
protected Rating _myRating;
@@ -120,6 +121,7 @@ namespace LibationWinForms.GridView
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
@@ -136,7 +138,7 @@ namespace LibationWinForms.GridView
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = ImageReader.ToImage(picture);
_cover = loadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
@@ -152,11 +154,25 @@ namespace LibationWinForms.GridView
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = ImageReader.ToImage(e.Picture);
Cover = loadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
private Image loadImage(byte[] picture)
{
try
{
return ImageReader.ToImage(picture);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return Properties.Resources.default_cover_80x80;
}
}
#endregion
#region Static library display functions

View File

@@ -9,10 +9,6 @@ namespace LibationWinForms.GridView
{
public string PictureFileName { get; set; }
public string BookSaveDirectory { get; set; }
public byte[] CoverPicture { get => _coverBytes; set => pictureBox1.Image = Dinah.Core.WindowsDesktop.Drawing.ImageReader.ToImage(_coverBytes = value); }
private byte[] _coverBytes;
public ImageDisplay()
{
@@ -21,6 +17,19 @@ namespace LibationWinForms.GridView
lastHeight = Height;
}
public void SetCoverArt(byte[] cover)
{
try
{
pictureBox1.Image = Dinah.Core.WindowsDesktop.Drawing.ImageReader.ToImage(cover);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {file}", PictureFileName);
pictureBox1.Image = Properties.Resources.default_cover_500x500;
}
}
#region Make the form's aspect ratio always match the picture's aspect ratio.
private bool detectedResizeDirection = false;
@@ -106,7 +115,7 @@ namespace LibationWinForms.GridView
try
{
File.WriteAllBytes(saveFileDialog.FileName, CoverPicture);
pictureBox1.Image.Save(saveFileDialog.FileName);
}
catch (Exception ex)
{

View File

@@ -0,0 +1,39 @@
using LibationUiBase;
using System;
using System.Drawing;
using System.Windows.Forms;
namespace LibationWinForms.GridView
{
public class LastDownloadedGridViewColumn : DataGridViewColumn
{
public LastDownloadedGridViewColumn() : base(new LastDownloadedGridViewCell()) { }
public override DataGridViewCell CellTemplate
{
get => base.CellTemplate;
set
{
if (value is not LastDownloadedGridViewCell)
throw new InvalidCastException($"Must be a {nameof(LastDownloadedGridViewCell)}");
base.CellTemplate = value;
}
}
}
internal class LastDownloadedGridViewCell : DataGridViewTextBoxCell
{
private LastDownloadStatus LastDownload => (LastDownloadStatus)Value;
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
ToolTipText = ((LastDownloadStatus)value).ToolTipText;
base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts);
}
protected override void OnDoubleClick(DataGridViewCellEventArgs e)
{
LastDownload.OpenReleaseUrl();
base.OnDoubleClick(e);
}
}
}

View File

@@ -8,7 +8,15 @@ namespace LibationWinForms.GridView
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
public bool Expanded { get; set; }
public bool IsSeries { get; init; }
public bool IsSeries { get; }
private bool IsAbsent { get; }
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public LiberateButtonStatus(bool isSeries, bool isAbsent)
{
IsSeries = isSeries;
IsAbsent = isAbsent;
}
/// <summary>
/// Defines the Liberate column's sorting behavior
@@ -20,6 +28,8 @@ namespace LibationWinForms.GridView
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
else if (IsUnavailable && !second.IsUnavailable) return 1;
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);

View File

@@ -17,11 +17,14 @@ namespace LibationWinForms.GridView
internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell
{
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray));
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
if (value is LiberateButtonStatus status)
{
if (status.BookStatus is LiberatedStatus.Error)
if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable)
//Don't paint the button graphic
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null)
@@ -41,7 +44,14 @@ namespace LibationWinForms.GridView
DrawButtonImage(graphics, buttonImage, cellBounds);
ToolTipText = mouseoverText;
if (status.IsUnavailable)
{
//Create the "disabled" look by painting a transparent gray box over the buttom image.
graphics.FillRectangle(DISABLED_GRAY, cellBounds);
ToolTipText = "This book cannot be downloaded\r\nbecause it wasn't found during\r\nthe most recent library scan";
}
else
ToolTipText = mouseoverText;
}
}
}

View File

@@ -1,6 +1,7 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using LibationUiBase;
using System;
using System.Collections.Generic;
using System.ComponentModel;
@@ -24,6 +25,8 @@ namespace LibationWinForms.GridView
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override LastDownloadStatus LastDownload { get; protected set; }
public override RemoveStatus Remove
{
get
@@ -49,7 +52,7 @@ namespace LibationWinForms.GridView
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
}
}
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
@@ -87,6 +90,7 @@ namespace LibationWinForms.GridView
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
@@ -126,6 +130,10 @@ namespace LibationWinForms.GridView
_pdfStatus = udi.PdfStatus;
NotifyPropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new(udi);
NotifyPropertyChanged(nameof(LastDownload));
break;
}
}
@@ -153,6 +161,7 @@ namespace LibationWinForms.GridView
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(DisplayTags), () => DisplayTags },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },

View File

@@ -39,7 +39,7 @@ namespace LibationWinForms.GridView
void PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == picDef.PictureId)
imageDisplay.CoverPicture = e.Picture;
imageDisplay.SetCoverArt(e.Picture);
PictureStorage.PictureCached -= PictureCached;
}
@@ -51,7 +51,7 @@ namespace LibationWinForms.GridView
if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible)
{
imageDisplay = new GridView.ImageDisplay();
imageDisplay = new ImageDisplay();
imageDisplay.RestoreSizeAndLocation(Configuration.Instance);
imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance);
}
@@ -59,7 +59,7 @@ namespace LibationWinForms.GridView
imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook);
imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg"));
imageDisplay.Text = windowTitle;
imageDisplay.CoverPicture = initialImageBts;
imageDisplay.SetCoverArt(initialImageBts);
if (!isDefault)
PictureStorage.PictureCached -= PictureCached;
@@ -107,9 +107,9 @@ namespace LibationWinForms.GridView
if (selectedBooks.Count == 0)
return;
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var result = MessageBoxLib.ShowConfirmationDialog(
libraryBooks,
booksToRemove,
// do not use `$` string interpolation. See impl.
"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
@@ -118,8 +118,7 @@ namespace LibationWinForms.GridView
return;
productsGrid.RemoveBooks(selectedBooks);
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
await booksToRemove.RemoveBooksAsync();
}
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
@@ -201,7 +200,8 @@ namespace LibationWinForms.GridView
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
{
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error
&& !liveGridEntry.Liberate.IsUnavailable)
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}

View File

@@ -45,6 +45,7 @@
this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.lastDownloadedGVColumn = new LastDownloadedGridViewColumn();
this.tagAndDetailsGVColumn = new LibationWinForms.GridView.EditTagsDataGridViewImageButtonColumn();
this.showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(this.components);
this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
@@ -75,7 +76,8 @@
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.lastDownloadedGVColumn,
this.tagAndDetailsGVColumn});
this.gridEntryDataGridView.ContextMenuStrip = this.showHideColumnsContextMenuStrip;
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
@@ -216,6 +218,15 @@
this.miscGVColumn.ReadOnly = true;
this.miscGVColumn.Width = 135;
//
// lastDownloadedGVColumn
//
this.lastDownloadedGVColumn.DataPropertyName = "LastDownload";
this.lastDownloadedGVColumn.HeaderText = "Last Download";
this.lastDownloadedGVColumn.Name = "lastDownloadedGVColumn";
this.lastDownloadedGVColumn.ReadOnly = true;
this.lastDownloadedGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
this.lastDownloadedGVColumn.Width = 108;
//
// tagAndDetailsGVColumn
//
this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags";
@@ -268,6 +279,7 @@
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn;
private MyRatingGridViewColumn myRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
private LastDownloadedGridViewColumn lastDownloadedGVColumn;
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
}
}

View File

@@ -154,7 +154,7 @@ namespace LibationWinForms.GridView
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" };
removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId));
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
locateFileMenuItem.Click += (_, __) =>
@@ -180,7 +180,7 @@ namespace LibationWinForms.GridView
var convertToMp3MenuItem = new ToolStripMenuItem
{
Text = "&Convert to Mp3",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
Enabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as LibraryBookEntry);

View File

@@ -62,7 +62,7 @@ namespace LibationWinForms.GridView
private SeriesEntry(LibraryBook parent)
{
Liberate = new LiberateButtonStatus { IsSeries = true };
Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false);
SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
@@ -122,6 +122,7 @@ namespace LibationWinForms.GridView
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(DisplayTags), () => string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },

View File

@@ -1,250 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationWinForms.ProcessQueue
{
public enum QueuePosition
{
Fisrt,
OneUp,
OneDown,
Last
}
/*
* This data structure is like lifting a metal chain one link at a time.
* Each time you grab and lift a new link (MoveNext call):
*
* 1) you're holding a new link in your hand (Current)
* 2) the remaining chain to be lifted shortens by 1 link (Queued)
* 3) the pile of chain at your feet grows by 1 link (Completed)
*
* The index is the link position from the first link you lifted to the
* last one in the chain.
*/
public class TrackedQueue<T> where T : class
{
public event EventHandler<int> CompletedCountChanged;
public event EventHandler<int> QueuededCountChanged;
public T Current { get; private set; }
public IReadOnlyList<T> Queued => _queued;
public IReadOnlyList<T> Completed => _completed;
private readonly List<T> _queued = new();
private readonly List<T> _completed = new();
private readonly object lockObject = new();
public T this[int index]
{
get
{
lock (lockObject)
{
if (index < _completed.Count)
return _completed[index];
index -= _completed.Count;
if (index == 0 && Current != null) return Current;
if (Current != null) index--;
if (index < _queued.Count) return _queued.ElementAt(index);
throw new IndexOutOfRangeException();
}
}
}
public int Count
{
get
{
lock (lockObject)
{
return _queued.Count + _completed.Count + (Current == null ? 0 : 1);
}
}
}
public int IndexOf(T item)
{
lock (lockObject)
{
if (_completed.Contains(item))
return _completed.IndexOf(item);
if (Current == item) return _completed.Count;
if (_queued.Contains(item))
return _queued.IndexOf(item) + (Current is null ? 0 : 1);
return -1;
}
}
public bool RemoveQueued(T item)
{
bool itemsRemoved;
int queuedCount;
lock (lockObject)
{
itemsRemoved = _queued.Remove(item);
queuedCount = _queued.Count;
}
if (itemsRemoved)
QueuededCountChanged?.Invoke(this, queuedCount);
return itemsRemoved;
}
public void ClearCurrent()
{
lock(lockObject)
Current = null;
}
public bool RemoveCompleted(T item)
{
bool itemsRemoved;
int completedCount;
lock (lockObject)
{
itemsRemoved = _completed.Remove(item);
completedCount = _completed.Count;
}
if (itemsRemoved)
CompletedCountChanged?.Invoke(this, completedCount);
return itemsRemoved;
}
public void ClearQueue()
{
lock (lockObject)
_queued.Clear();
QueuededCountChanged?.Invoke(this, 0);
}
public void ClearCompleted()
{
lock (lockObject)
_completed.Clear();
CompletedCountChanged?.Invoke(this, 0);
}
public bool Any(Func<T, bool> predicate)
{
lock (lockObject)
{
return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate);
}
}
public void MoveQueuePosition(T item, QueuePosition requestedPosition)
{
lock (lockObject)
{
if (_queued.Count == 0 || !_queued.Contains(item)) return;
if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item)
return;
if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item)
return;
int queueIndex = _queued.IndexOf(item);
if (requestedPosition == QueuePosition.OneUp)
{
_queued.RemoveAt(queueIndex);
_queued.Insert(queueIndex - 1, item);
}
else if (requestedPosition == QueuePosition.OneDown)
{
_queued.RemoveAt(queueIndex);
_queued.Insert(queueIndex + 1, item);
}
else if (requestedPosition == QueuePosition.Fisrt)
{
_queued.RemoveAt(queueIndex);
_queued.Insert(0, item);
}
else
{
_queued.RemoveAt(queueIndex);
_queued.Insert(_queued.Count, item);
}
}
}
public bool MoveNext()
{
int completedCount = 0, queuedCount = 0;
bool completedChanged = false;
try
{
lock (lockObject)
{
if (Current != null)
{
_completed.Add(Current);
completedCount = _completed.Count;
completedChanged = true;
}
if (_queued.Count == 0)
{
Current = null;
return false;
}
Current = _queued[0];
_queued.RemoveAt(0);
queuedCount = _queued.Count;
return true;
}
}
finally
{
if (completedChanged)
CompletedCountChanged?.Invoke(this, completedCount);
QueuededCountChanged?.Invoke(this, queuedCount);
}
}
public bool TryPeek(out T item)
{
lock (lockObject)
{
if (_queued.Count == 0)
{
item = null;
return false;
}
item = _queued[0];
return true;
}
}
public T Peek()
{
lock (lockObject)
{
if (_queued.Count == 0) throw new InvalidOperationException("Queue empty");
return _queued.Count > 0 ? _queued[0] : default;
}
}
public void Enqueue(IEnumerable<T> item)
{
int queueCount;
lock (lockObject)
{
_queued.AddRange(item);
queueCount = _queued.Count;
}
QueuededCountChanged?.Invoke(this, queueCount);
}
}
}

View File

@@ -29,6 +29,9 @@
</ItemGroup>
<ItemGroup>
<None Update="fileicon">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Info.plist">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@@ -1,4 +1,5 @@
using LibationFileManager;
using Dinah.Core;
using LibationFileManager;
using System.Diagnostics;
namespace MacOSConfigApp
@@ -9,8 +10,14 @@ namespace MacOSConfigApp
public MacOSInterop() { }
public MacOSInterop(params object[] values) { }
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
public void SetFolderIcon(string image, string directory)
{
Process.Start("fileicon", $"set {directory.SurroundWithQuotes()} {image.SurroundWithQuotes()}").WaitForExit();
}
public void DeleteFolderIcon(string directory)
{
Process.Start("fileicon", $"rm {directory.SurroundWithQuotes()}").WaitForExit();
}
//I haven't figured out how to find the app bundle's directory from within
//the running process, so don't upgrade unless it's "installed" in /Applications
@@ -21,7 +28,7 @@ namespace MacOSConfigApp
Serilog.Log.Information($"Extracting upgrade bundle to {AppPath}");
//tar wil overwrite existing without elevated privileges
Process.Start("tar", $"-xf \"{upgradeBundle}\" -C \"/Applications\"").WaitForExit();
Process.Start("tar", $"-xf {upgradeBundle.SurroundWithQuotes()} -C \"/Applications\"").WaitForExit();
//For now, it seems like this step is unnecessary. We can overwrite and
//run Libation without needing to re-add the exception. This is insurance.

View File

@@ -0,0 +1,732 @@
#!/usr/bin/env bash
###
# Home page: https://github.com/mklement0/fileicon
# Author: Michael Klement <mklement0@gmail.com> (http://same2u.net)
# Invoke with:
# --version for version information
# --help for usage information
###
# --- STANDARD SCRIPT-GLOBAL CONSTANTS
kTHIS_NAME=${BASH_SOURCE##*/}
kTHIS_HOMEPAGE='https://github.com/mklement0/fileicon'
kTHIS_VERSION='v0.3.3' # NOTE: This assignment is automatically updated by `make version VER=<newVer>` - DO keep the 'v' prefix.
unset CDPATH # To prevent unpredictable `cd` behavior.
# --- Begin: STANDARD HELPER FUNCTIONS
die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; }
dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; }
# SYNOPSIS
# openUrl <url>
# DESCRIPTION
# Opens the specified URL in the system's default browser.
openUrl() {
local url=$1 platform=$(uname) cmd=()
case $platform in
'Darwin') # OSX
cmd=( open "$url" )
;;
'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin
cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space.
;;
'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary
cmd=( start '' "$url" )
;;
*) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ...
cmd=( xdg-open "$url" )
;;
esac
"${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; }
}
# Prints the embedded Markdown-formatted man-page source to stdout.
printManPageSource() {
/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE"
}
# Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online.
openManPage() {
local pager embeddedText
if ! man 1 "$kTHIS_NAME" 2>/dev/null; then
# 2nd attempt: if present, display the embedded Markdown-formatted man-page source
embeddedText=$(printManPageSource)
if [[ -n $embeddedText ]]; then
pager='more'
command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more`
printf '%s\n' "$embeddedText" | "$pager"
else # 3rd attempt: open the the man page on the utility's website
openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md"
fi
fi
}
# Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference.
printUsage() {
local embeddedText
# Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source.
embeddedText=$(/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE")
if [[ -n $embeddedText ]]; then
# Print extracted synopsis chapter - remove backticks for uncluttered display.
printf '%s\n\n' "$embeddedText" | tr -d '`'
else # No SYNOPIS chapter found; fall back to displaying the man page.
echo "WARNING: usage information not found; opening man page instead." >&2
openManPage
fi
}
# --- End: STANDARD HELPER FUNCTIONS
# --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS.
case $1 in
--version)
# Output version number and exit, if requested.
ver="v0.3.3"; echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0
;;
-h|--help)
# Print usage information and exit.
printUsage; exit
;;
--man)
# Display the manual page and exit.
openManPage; exit
;;
--man-source) # private option, used by `make update-doc`
# Print raw, embedded Markdown-formatted man-page source and exit
printManPageSource; exit
;;
--home)
# Open the home page and exit.
openUrl "$kTHIS_HOMEPAGE"; exit
;;
esac
# --- Begin: SPECIFIC HELPER FUNCTIONS
# NOTE: The functions below operate on byte strings such as the one above:
# A single single string of pairs of hex digits, without separators or line breaks.
# Thus, a given byte position is easily calculated: to get byte $byteIndex, use
# ${byteString:byteIndex*2:2}
# Outputs the specified EXTENDED ATTRIBUTE VALUE as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000A2C".
# IMPORTANT: Hex. digits > 9 use UPPPERCASE characters.
# getAttribByteString <file> <attrib_name>
getAttribByteString() {
/usr/bin/xattr -px "$2" "$1" | tr -d ' \n'
return ${PIPESTATUS[0]}
}
# Outputs the specified file's RESOURCE FORK as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000a2c".
# IMPORTANT: Hex. digits > 9 use *lowercase* characters.
# Note: This function relies on `xxd -p <file>/..namedfork/rsrc | tr -d '\n'` rather than the conceptually equivalent call,
# `getAttribByteString <file> com.apple.ResourceFork`, for PERFORMANCE reasons:
# getAttribByteString() (defined above) relies on `xattr`, which is a *Python* script [!! seemingly no longer, as of macOS 10.16]
# and therefore quite slow due to Python's startup cost.
# getResourceByteString <file>
getResourceByteString() {
xxd -p "$1"/..namedfork/rsrc | tr -d '\n'
}
# Patches a single byte in the byte string provided via stdin.
# patchByteInByteString ndx byteSpec
# ndx is the 0-based byte index
# - If <byteSpec> has NO prefix: <byteSpec> becomes the new byte
# - If <byteSpec> has prefix '|': "adds" the value: the result of a bitwise OR with the existing byte becomes the new byte
# - If <byteSpec> has prefix '~': "removes" the value: the result of a applying a bitwise AND with the bitwise complement of <byteSpec> to the existing byte becomes the new byte
patchByteInByteString() {
local ndx=$1 byteSpec=$2 byteVal byteStr charPos op='' charsBefore='' charsAfter='' currByte
byteStr=$(</dev/stdin)
charPos=$(( 2 * ndx ))
# Validat the byte spec.
case ${byteSpec:0:1} in
'|')
op='|'
byteVal=${byteSpec:1}
;;
'~')
op='& ~'
byteVal=${byteSpec:1}
;;
*)
byteVal=$byteSpec
;;
esac
[[ $byteVal == [0-9A-Fa-f][0-9A-Fa-f] ]] || return 1
# Validat the byte index.
(( charPos > 0 && charPos < ${#byteStr} )) || return 1
# Determine the target byte, and strings before and after the byte to patch.
(( charPos >= 2 )) && charsBefore=${byteStr:0:charPos}
charsAfter=${byteStr:charPos + 2}
# Determine the new byte value
if [[ -n $op ]]; then
currByte=${byteStr:charPos:2}
printf -v patchedByte '%02X' "$(( 0x${currByte} $op 0x${byteVal} ))"
else
patchedByte=$byteSpec
fi
printf '%s%s%s' "$charsBefore" "$patchedByte" "$charsAfter"
}
# hasAttrib <fileOrFolder> <attrib_name>
hasAttrib() {
/usr/bin/xattr "$1" | /usr/bin/grep -Fqx "$2"
}
# hasIconData <file>
# Test if the file has a resource fork with icon data in it, or,
# in the case of a .VolumeIcon.icns file, has icon data *as the file contents*
hasIconData() {
local file=$1
if [[ $(basename "$file") == $kFILENAME_VOLUMECUSTOMICON ]]; then
# special file for any folder that is a *volume mountpoint*: has the icon data as the file's *content*
file "$file" | /usr/bin/grep -Fq ' Mac OS X icon'
else
# file itself or special helper file $'Icon\r' for a regular folder: has the icon data *in its resource fork*.
getResourceByteString "$file" | /usr/bin/grep -Fq "$kMAGICBYTES_ICNS_RESOURCE"
fi
}
# isVolumeMountPoint <folder>
isVolumeMountPoint() {
local folder=$1
# Must resolve to the physical underlying path, as that is what `mount` shows
folder=$(cd -P -- "$1"; pwd)
mount | grep -qF "on $folder (" # !! Is there a more robust way to test for mountpoints?
}
# getFileWithIconData <fileOrFolder>
# Returns the path of the file that contains the actual icon data, based on whether the target is
# * a file ... the file path itself
# * a folder:
# * regular folder: file $'Icon\r' inside that folder, with the icon data in its resource fork
# * volume mountpoint: file '.VolumeIcon.icns' inside that folder, with the icon data inside the file.
getFileWithIconData() {
local fileOrFolder=$1
if [[ -f $fileOrFolder ]]; then # file
printf '%s' "$fileOrFolder"
elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint
printf '%s' "$fileOrFolder/$kFILENAME_VOLUMECUSTOMICON"
else # regular folder
printf '%s' "$fileOrFolder/$kFILENAME_FOLDERCUSTOMICON"
fi
}
# getTargetType <fileOrFolder>
# Returns a descriptor for the specified target path:
# * a file ... 'file'
# * a folder:
# * regular folder: 'folder'
# * volume mountpoint: 'volume'
getTargetType() {
local fileOrFolder=$1
if [[ -f $fileOrFolder ]]; then # file
printf 'file'
elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint
printf 'volume'
else # regular folder
printf 'folder'
fi
}
# setCustomIcon <fileOrFolder> <imgFile>
# Tips for debugging:
# * To exercise this function, from the repo dir.:
# touch /tmp/tf; ./bin/fileicon set /tmp/tf ./test/.fixtures/img.png
# !!??? VOLUME SUPPORT, as of macOS 13.1:
# !! * While targeting volume root folders is now supported *in principle*,
# !! assigning the 'com.apple.FinderInfo' extended attribute to the mountpoint folder
# !! typically (always??) fails, so the icon doesn't take effect.
# !! SetFile -a C <file> also fails - with or without `sudo` - with "ERROR: Unexpected Error. (-5000) on file: <file>"
# !! (SetFile -a c <file> clears the custom-icon flag; note that SetFile isn't installed by default and is DEPRECATED: "Tools supporting Carbon development, including /usr/bin/SetFile, were deprecated with Xcode 6.")
# !! ?? It feels like at *some point* on 13.1 our NFS mount from our NAS seemed to support it, but inexplicably no longer.
# !! * SOME volumes, even if *network* volumes, support custom icons for their files and folders,
# !! such as a SMB mount.
# !! ?? Our NFS mount from our NAS seemed to support that *for a while* on 13.1, but inexplicably no longer.
setCustomIcon() {
local fileOrFolder=$1 imgFile=$2 fileWithIconData
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 3
[[ -f $imgFile ]] || return 3
# !! Sadly, Apple decided to remove the `-i` / `--addicon` option from the `sips` utility.
# !! Therefore, use of *Cocoa* is required, which we do *via AppleScript* and its ObjC bridge,
# !! which has the added advantage of creating a *set* of icons from the source image, scaling as necessary
# !! to create a 512 x 512 top resolution icon (whereas sips -i created a single, 128 x 128 icon).
# !! Thanks:
# !! * https://apple.stackexchange.com/a/161984/28668 (Python original)
# !! * @scriptingosx (https://github.com/mklement0/fileicon/issues/32#issuecomment-1074124748) (AppleScript-ObjC version)
# !! Note: We moved from Python to AppleScript when the system Python was removed in macOS 12.3
# !! Note: The setIcon method seemingly always indicates True, even with invalid image files, so
# !! we attempt no error handling in the AppleScript code, and instead verify success explicitly later.
osascript <<EOF >/dev/null || die
use framework "Cocoa"
set sourcePath to "$imgFile"
set destPath to "$fileOrFolder"
set imageData to (current application's NSImage's alloc()'s initWithContentsOfFile:sourcePath)
(current application's NSWorkspace's sharedWorkspace()'s setIcon:imageData forFile:destPath options:2)
EOF
# Fully verify that everything worked as intended.
# Unfortunately, several things can go wrong.
testForCustomIcon "$targetFileOrFolder" 2>/dev/null && return 0
ec=$?
if (( ec == 1 )); then
cat >&2 <<EOF
Failed to assign a custom icon.
Typically, this means that the specified image file is not supported or corrupt: $imgFile
Supported image formats: jpeg | tiff | png | gif | jp2 | pict | bmp | qtif| psd | sgi | tga
EOF
elif ((ec == 2 )); then
cat >&2 <<EOF
Failed to set the custom-icon flag in the 'com.apple.FinderInfo' extended attribute of: $targetFileOrFolder
Typically, this means that you're targeting a volume itself or a file or folder on a volume that doesn't support custom icons.
Rerun with "rm" to clean up.
EOF
elif ((ec == 3 )); then
cat >&2 <<EOF
The custom-icon flag in the 'com.apple.FinderInfo' extended attribute of: $imgFile
was successfully set, but, unexpectedly, the associated icon data was not.
Rerun with "rm" to clean up.
EOF
fi
return $ec
}
# getCustomIcon <fileOrFolder> <icnsOutFile>
getCustomIcon() {
local fileOrFolder=$1 icnsOutFile=$2 byteStr fileWithIconData byteOffset byteCount
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3
# Determine what file to extract the resource fork from.
if [[ -d $fileOrFolder ]]; then
fileWithIconData=$(getFileWithIconData "$fileOrFolder")
[[ -f $fileWithIconData ]] || { echo "Custom-icon file does not exist: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; }
if [[ $(basename "$fileWithIconData") == $kFILENAME_VOLUMECUSTOMICON ]]; then
# !! Volume mount points are an exception: their helper file contains the icon data as the file's *content*
# !! rather than in a *resource fork*; therefore, simply *copying* the file's content is enough.
# !! However, we use `cat` rather than `cp`, so as not to also copy the extended attributes.
cat "$fileWithIconData" > "$icnsOutFile" || die
return 0
fi
# Otherwise: proceed below to extract the data from the resource fork.
else
fileWithIconData=$fileOrFolder
fi
# Determine (based on format description at https://en.wikipedia.org/wiki/Apple_Icon_Image_format):
# - the byte offset at which the icns resource begins, via the magic literal identifying an icns resource
# - the length of the resource, which is encoded in the 4 bytes right after the magic literal.
read -r byteOffset byteCount < <(getResourceByteString "$fileWithIconData" | /usr/bin/awk -F "$kMAGICBYTES_ICNS_RESOURCE" '{ printf "%s %d", (length($1) + 2) / 2, "0x" substr($2, 0, 8) }')
(( byteOffset > 0 && byteCount > 0 )) || { echo "Custom-icon file contains no icons resource: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; }
# Extract the actual bytes using tail and head and save them to the output file.
tail -c "+${byteOffset}" "$fileWithIconData/..namedfork/rsrc" | head -c $byteCount > "$icnsOutFile" || return
return 0
}
# removeCustomIcon <fileOrFolder>
removeCustomIcon() {
local fileOrFolder=$1 byteStr
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 1
# Step 1: Turn off the custom-icon flag in the com.apple.FinderInfo extended attribute.
# Note: Using SetFile -a c <file> is tempting, but SetFile doesn't come with macOS by default (part of XCode CLI package)
if hasAttrib "$fileOrFolder" com.apple.FinderInfo; then
byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo | patchByteInByteString $kFI_BYTEOFFSET_CUSTOMICON '~'$kFI_VAL_CUSTOMICON) || return
if [[ $byteStr == "$kFI_BYTES_BLANK" ]]; then # All bytes cleared? Remove the entire attribute.
/usr/bin/xattr -d com.apple.FinderInfo "$fileOrFolder"
else # Update the attribute.
/usr/bin/xattr -wx com.apple.FinderInfo "$byteStr" "$fileOrFolder" || return
fi
fi
# Step 2: Remove the resource fork (if target is a file) / hidden file with custom icon (if target is a folder)
if [[ -d $fileOrFolder ]]; then # folder or volume -> remove the special file inside it.
rm -f "$(getFileWithIconData "$fileOrFolder")"
else # file -> remove the resource fork
if hasIconData "$fileOrFolder"; then
/usr/bin/xattr -d com.apple.ResourceFork "$fileOrFolder"
fi
fi
return 0
}
# testForCustomIcon <fileOrFolder>
testForCustomIcon() {
local fileOrFolder=$1 byteStr byteVal fileWithIconData hasCustomIconFlag hasIconData
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3
# Step 1: Check if the com.apple.FinderInfo extended attribute has the custom-icon
# flag set. This applies to *all* target types.
byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo 2>/dev/null) # || return 1
byteVal=${byteStr:2*kFI_BYTEOFFSET_CUSTOMICON:2}
hasCustomIconFlag=$(( byteVal & kFI_VAL_CUSTOMICON ))
fileWithIconData=$(getFileWithIconData "$fileOrFolder")
# Step 2: Check if there's actual icon data present,
# via the resource fork of the file or the folder's helper file or the file content of a
# volume mountpoint's helper file (./.VolumeIcon.icns)
hasIconData "$fileWithIconData" 2>/dev/null && hasIconData=1 || hasIconData=0
# Provide a specific exit code reflecting the state.
# !! This is used by setCustomIcon()
if (( hasCustomIconFlag && hasIconData )); then
return 0 # has custom icon
elif (( ! hasCustomIconFlag && ! hasIconData )); then
return 1 # typical case of file/folder NOT having a custom icon
elif (( ! hasCustomIconFlag )); then
echo "WARNING: Custom-icon data is present, but the 'com.apple.FinderInfo' extended attribute isn't set for $(getTargetType "$fileOrFolder") '$fileOrFolder'" >&2
return 2 # broken state: has icons, but no custom flag
else # (( ! hasIconData ))
echo "WARNING: While the 'com.apple.FinderInfo' extended attribute is set for $(getTargetType "$fileOrFolder") '$fileOrFolder', associated icon data is missing." >&2
return 3 # broken state: has custom flag, but no icons
fi
}
# --- End: SPECIFIC HELPER FUNCTIONS
# --- Begin: SPECIFIC SCRIPT-GLOBAL CONSTANTS
kFILENAME_FOLDERCUSTOMICON=$'Icon\r' # the helper file for regular folders, with the actual icon image data in its *resource fork*
kFILENAME_VOLUMECUSTOMICON='.VolumeIcon.icns' # the helper file for volume mountpoints, with the actual icon image data in the file's *content*.
# The blank hex dump form (single string of pairs of hex digits) of the 32-byte data structure stored in extended attribute
# com.apple.FinderInfo
kFI_BYTES_BLANK='0000000000000000000000000000000000000000000000000000000000000000'
# [UPDATE]
# * THIS CONSTANT ISN'T USED ANYMORE.
# * Also, on macOS 13 (Ventura): seemingly, the Icon\r file's com.apple.FinderInfo extended attribute is now
# SIMPLER: where the folder itself has 0x4 in its 9th byte, Icon\r now has 0x40
# [ORIGINAL COMMENT]
# The hex dump form of the full 32 bytes that Finder assigns to the hidden $'Icon\r'
# file whose com.apple.ResourceFork extended attribute contains the icon image data for the enclosing folder.
# The first 8 bytes spell out the magic literal 'iconMACS'; they are followed by the invisibility flag, '40' in the 9th byte, and '10' (?? specifying what?)
# in the 10th byte.
# NOTE: Since file $'Icon\r' serves no other purpose than to store the icon, it is
# safe to simply assign all 32 bytes blindly, without having to worry about
# preserving existing values.
# kFI_BYTES_CUSTOMICONFILEFORFOLDER='69636F6E4D414353401000000000000000000000000000000000000000000000'
# The hex dump form of the magic literal inside a resource fork that marks the
# start of an icns (icons) resource.
# NOTE: This will be used with `xxd -p .. | tr -d '\n'`, which uses *lowercase*
# hex digits, so we must use lowercase here.
kMAGICBYTES_ICNS_RESOURCE='69636e73'
# The byte values (as hex strings) of the flags at the relevant byte position
# of the com.apple.FinderInfo extended attribute.
kFI_VAL_CUSTOMICON='04'
# The custom-icon-flag byte offset in the com.apple.FinderInfo extended attribute.
kFI_BYTEOFFSET_CUSTOMICON=8
# --- End: SPECIFIC SCRIPT-GLOBAL CONSTANTS
# Option defaults.
force=0 quiet=0
# --- Begin: OPTIONS PARSING
allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0
while (( $# )); do
if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option
prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0
for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do
acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq=
if (( isLong )); then # long option: parse into name and, if present, argument
optName=${1:2}
[[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; }
else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument.
optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1
fi
(( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; }
# ---- BEGIN: CUSTOMIZE HERE
case $optName in
f|force)
force=1
;;
q|quiet)
quiet=1
;;
*)
dieSyntax "Unknown option: ${prefix}${optName}."
;;
esac
# ---- END: CUSTOMIZE HERE
(( needOptArg )) && { (( ! haveOptArgAttached && ! haveOptArgAsNextArg )) && dieSyntax "Option ${prefix}${optName} is missing its argument." || (( haveOptArgAsNextArg )) && shift; }
(( acceptOptArg || needOptArg )) && break
done
else # an operand
if [[ $1 == '--' ]]; then
shift; operands+=( "$@" ); break
elif (( allowOptsAfterOperands )); then
operands+=( "$1" ) # continue
else
operands=( "$@" )
break
fi
fi
shift
done
(( "${#operands[@]}" > 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg
# --- End: OPTIONS PARSING: "$@" now contains all operands (non-option arguments).
# Validate the command
cmd=$(printf %s "$1" | tr '[:upper:]' '[:lower:]') # translate to all-lowercase - we don't want the command name to be case-sensitive
[[ $cmd == 'remove' ]] && cmd='rm' # support alias 'remove' for 'rm'
case $cmd in
set|get|rm|remove|test)
shift
;;
*)
dieSyntax "Unrecognized or missing command: '$cmd'."
;;
esac
# Validate file operands
(( $# > 0 )) || dieSyntax "Missing operand(s)."
# Target file or folder.
targetFileOrFolder=$1 imgFile= outFile=
[[ -f $targetFileOrFolder || -d $targetFileOrFolder ]] || die "Target not found or neither file nor folder: '$targetFileOrFolder'"
# Make sure the target file/folder is readable, and, unless only getting or testing for an icon are requested, writeable too.
[[ -r $targetFileOrFolder ]] || die "Cannot access '$targetFileOrFolder': you do not have read permissions."
[[ $cmd == 'test' || $cmd == 'get' || -w $targetFileOrFolder ]] || die "Cannot modify '$targetFileOrFolder': you do not have write permissions."
# Other operands, if any, and their number.
valid=0
case $cmd in
'set')
(( $# <= 2 )) && {
valid=1
# If no image file was specified, the target file is assumed to be an image file itself whose image should be self-assigned as an icon.
(( $# == 2 )) && imgFile=$2 || imgFile=$1
# !! Apparently, a regular file is required - a process subsitution such
# !! as `<(base64 -D <encoded-file.txt)` is NOT supported by NSImage.initWithContentsOfFile()
[[ -f $imgFile && -r $imgFile ]] || die "Image file not found or not a (readable) regular file: $imgFile"
}
;;
'rm'|'test')
(( $# == 1 )) && valid=1
;;
'get')
(( $# == 1 || $# == 2 )) && {
valid=1
outFile=$2
if [[ $outFile == '-' ]]; then
outFile=/dev/stdout
else
# By default, we extract to a file with the same filename root + '.icns'
# in the current folder.
[[ -z $outFile ]] && outFile=${targetFileOrFolder##*/}
# Unless already specified, we append '.icns' to the output filename.
mustReset=$(shopt -q nocasematch; echo $?); shopt -s nocasematch
[[ $outFile =~ \.icns$ ]] || outFile+='.icns'
(( mustReset )) && shopt -u nocasematch
[[ -e $outFile && $force -eq 0 ]] && die "Output file '$outFile' already exists. To force its replacement, use -f."
fi
}
;;
esac
(( valid )) || dieSyntax "Unexpected number of operands."
case $cmd in
'set')
setCustomIcon "$targetFileOrFolder" "$imgFile" || die
(( quiet )) || echo "Custom icon assigned to $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder' based on '$imgFile'."
;;
'rm')
removeCustomIcon "$targetFileOrFolder" || die
(( quiet )) || echo "Custom icon (if any) removed from $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'."
;;
'get')
getCustomIcon "$targetFileOrFolder" "$outFile" || die
(( quiet )) || { [[ $outFile != '/dev/stdout' ]] && echo "Custom icon extracted to '$outFile'."; }
exit 0
;;
'test')
if (( quiet )); then
testForCustomIcon "$targetFileOrFolder" 2>/dev/null
else
testForCustomIcon "$targetFileOrFolder" # This may issue warnings.
fi
ec=$?
if (( ! quiet )); then
echo "$( (( ec == 0 )) && printf 'HAS' || printf 'Has NO' ) custom icon: $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'"
fi
exit $ec
;;
*)
die "DESIGN ERROR: unanticipated command: $cmd"
;;
esac
exit 0
####
# MAN PAGE MARKDOWN SOURCE
# - Place a Markdown-formatted version of the man page for this script
# inside the here-document below.
# The document must be formatted to look good in all 3 viewing scenarios:
# - as a man page, after conversion to ROFF with marked-man
# - as plain text (raw Markdown source)
# - as HTML (rendered Markdown)
# Markdown formatting tips:
# - GENERAL
# To support plain-text rendering in the terminal, limit all lines to 80 chars.,
# and, for similar rendering as HTML, *end every line with 2 trailing spaces*.
# - HEADINGS
# - For better plain-text rendering, leave an empty line after a heading
# marked-man will remove it from the ROFF version.
# - The first heading must be a level-1 heading containing the utility
# name and very brief description; append the manual-section number
# directly to the CLI name; e.g.:
# # foo(1) - does bar
# - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body
# must render reasonably as plain text, because it is printed to stdout
# when `-h`, `--help` is specified:
# Use 4-space indentation without markup for both the syntax line and the
# block of brief option descriptions; represent option-arguments and operands
# in angle brackets; e.g., '<foo>'
# - All other headings should be level-2 headings in ALL-CAPS.
# - TEXT
# - Use NO indentation for regular chapter text; if you do, it will
# be indented further than list items.
# - Use 4-space indentation, as usual, for code blocks.
# - Markup character-styling markup translates to ROFF rendering as follows:
# `...` and **...** render as bolded (red) text
# _..._ and *...* render as word-individually underlined text
# - LISTS
# - Indent list items by 2 spaces for better plain-text viewing, but note
# that the ROFF generated by marked-man still renders them unindented.
# - End every list item (bullet point) itself with 2 trailing spaces too so
# that it renders on its own line.
# - Avoid associating more than 1 paragraph with a list item, if possible,
# because it requires the following trick, which hampers plain-text readability:
# Use '&nbsp;<space><space>' in lieu of an empty line.
####
: <<'EOF_MAN_PAGE'
# fileicon(1) - manage file and folder custom icons
## SYNOPSIS
Manage custom icons for files and folders on macOS.
SET a custom icon for a file or folder:
fileicon set <fileOrFolder> [<imageFile>]
REMOVE a custom icon from a file or folder:
fileicon rm <fileOrFolder>
GET a file or folder's custom icon:
fileicon get [-f] <fileOrFolder> [<iconOutputFile>]
-f ... force replacement of existing output file
TEST if a file or folder has a custom icon:
fileicon test <fileOrFolder>
All forms: option -q silences status output.
Standard options: `--help`, `--man`, `--version`, `--home`
## DESCRIPTION
`<fileOrFolder>` is the file or folder whose custom icon should be managed.
Note that symlinks are followed to their (ultimate target); that is, you
can only assign custom icons to regular files and folders, not to symlinks
to them.
`<imageFile>` can be an image file of any format supported by the system.
It is converted to an icon and assigned to `<fileOrFolder>`.
If you omit `<imageFile>`, `<fileOrFolder>` must itself be an image file whose
image should become its own icon.
`<iconOutputFile>` specifies the file to extract the custom icon to:
Defaults to the filename of `<fileOrFolder>` with extension `.icns` appended.
If a value is specified, extension `.icns` is appended, unless already present.
Either way, extraction fails if the target file already exists; use `-f` to
override.
Specify `-` to extract to stdout.
Command `test` signals with its exit code whether a custom icon is set (0)
or not (1); any other exit code signals an unexpected error.
**Options**:
* `-f`, `--force`
When getting (extracting) a custom icon, forces replacement of the
output file, if it already exists.
* `-q`, `--quiet`
Suppresses output of the status information that is by default output to
stdout.
Note that errors and warnings are still printed to stderr.
## NOTES
Custom icons are stored in extended attributes of the HFS+ filesystem.
Thus, if you copy files or folders to a different filesystem that doesn't
support such attributes, custom icons are lost; for instance, custom icons
cannot be stored in a Git repository.
To determine if a give file or folder has extended attributes, use
`ls -l@ <fileOrFolder>`.
When setting an image as a custom icon, a set of icons with several resolutions
is created, with the highest resolution at 512 x 512 pixels.
All icons created are square, so images with a non-square aspect ratio will
appear distorted; for best results, use square imges.
## STANDARD OPTIONS
All standard options provide information only.
* `-h, --help`
Prints the contents of the synopsis chapter to stdout for quick reference.
* `--man`
Displays this manual page, which is a helpful alternative to using `man`,
if the manual page isn't installed.
* `--version`
Prints version information.
* `--home`
Opens this utility's home page in the system's default web browser.
## LICENSE
For license information and more, visit the home page by running
`fileicon --home`
EOF_MAN_PAGE

View File

@@ -1,45 +1,17 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using LibationUiBase;
using SixLabors.ImageSharp;
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace WindowsConfigApp
{
{
internal static partial class FolderIcon
{
// https://stackoverflow.com/a/21389253
static readonly IcoEncoder IcoEncoder = new();
public static byte[] ToIcon(this Image img)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
// Header
bw.Write((short)0); // 0-1 : reserved
bw.Write((short)1); // 2-3 : 1=ico, 2=cur
bw.Write((short)1); // 4-5 : number of images
// Image directory
var w = img.Width;
if (w >= 256) w = 0;
bw.Write((byte)w); // 0 : width of image
var h = img.Height;
if (h >= 256) h = 0;
bw.Write((byte)h); // 1 : height of image
bw.Write((byte)0); // 2 : number of colors in palette
bw.Write((byte)0); // 3 : reserved
bw.Write((short)0); // 4 : number of color planes
bw.Write((short)0); // 6 : bits per pixel
var sizeHere = ms.Position;
bw.Write((int)0); // 8 : image size
var start = (int)ms.Position + 4;
bw.Write(start); // 12: offset of image data
// Image data
img.Save(ms, new PngEncoder());
var imageSize = (int)ms.Position - start;
ms.Seek(sizeHere, SeekOrigin.Begin);
bw.Write(imageSize);
ms.Seek(0, SeekOrigin.Begin);
// And load it
using var ms = new MemoryStream();
img.Save(ms, IcoEncoder);
return ms.ToArray();
}
@@ -57,50 +29,33 @@ namespace WindowsConfigApp
File.Delete(text);
}
}
refresh();
}
// https://github.com/dimuththarindu/FIC-Folder-Icon-Changer/blob/master/project/FIC/Classes/IconCustomizer.cs
public static void SetIcon(this DirectoryInfo directoryInfo, string icoPath, string folderType)
=> SetIcon(directoryInfo.FullName, icoPath, folderType);
public static void SetIcon(this DirectoryInfo directoryInfo, byte[] icon, string folderType)
=> SetIcon(directoryInfo.FullName, icon, folderType);
public static void SetIcon(string dir, string icoPath, string folderType)
public static void SetIcon(string dir, byte[] icon, string folderType)
{
var desktop_ini = Path.Combine(dir, "desktop.ini");
var Icon_ico = Path.Combine(dir, "Icon.ico");
var hidden = Path.Combine(dir, ".hidden");
//deleting existing files
DeleteIcon(dir);
//copying Icon file //overwriting
File.Copy(icoPath, Icon_ico, true);
File.WriteAllBytes(Icon_ico, icon);
//writing configuration file
string[] desktopLines = { "[.ShellClassInfo]", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" };
string[] desktopLines = { "[.ShellClassInfo]", "ConfirmFileOp=0", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" };
File.WriteAllLines(desktop_ini, desktopLines);
//configure file 2
string[] hiddenLines = { "desktop.ini", "Icon.ico" };
File.WriteAllLines(hidden, hiddenLines);
File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.ReadOnly);
File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.ReadOnly);
//making system files
File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
File.SetAttributes(hidden, File.GetAttributes(hidden) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
// this strangely completes the process. also hides these 3 hidden system files, even if "show hidden items" is checked
File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.ReadOnly);
refresh();
//https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini
File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.System);
}
private static void refresh() => SHChangeNotify(0x08000000, 0x0000, 0, 0); //SHCNE_ASSOCCHANGED SHCNF_IDLIST
[DllImport("shell32.dll", SetLastError = true)]
private static extern void SHChangeNotify(int wEventId, int uFlags, nint dwItem1, nint dwItem2);
}
}

View File

@@ -14,22 +14,9 @@ namespace WindowsConfigApp
public void SetFolderIcon(string image, string directory)
{
string iconPath = null;
try
{
var icon = Image.Load(File.ReadAllBytes(image)).ToIcon();
iconPath = Path.Combine(directory, $"{Guid.NewGuid()}.ico");
File.WriteAllBytes(iconPath, icon);
new DirectoryInfo(directory)?.SetIcon(iconPath, "Music");
}
finally
{
if (File.Exists(iconPath))
File.Delete(iconPath);
}
}
var icon = Image.Load(image).ToIcon();
new DirectoryInfo(directory)?.SetIcon(icon, "Music");
}
public void DeleteFolderIcon(string directory)
=> new DirectoryInfo(directory)?.DeleteIcon();