Compare commits

..

29 Commits

Author SHA1 Message Date
rmcrackan
09dc5e9846 Merge pull request #273 from Mbucari/master
Add option to save episodes to series parent
2022-06-13 12:00:05 -04:00
Mbucari
cf35a87d85 Update AppScaffolding.csproj 2022-06-12 19:48:51 -06:00
Michael Bucari-Tovo
9f25f619a8 Formatting 2022-06-12 19:37:42 -06:00
Michael Bucari-Tovo
7e989c730c Add option to save podcasts to series folder 2022-06-12 19:36:18 -06:00
Robert McRackan
0926e86956 Version 8 2022-06-12 21:20:02 -04:00
rmcrackan
75967730fd Merge pull request #271 from Mbucari/master
Move Remove Books function into main grid, added more db migrations and fixups for episodes
2022-06-12 21:14:51 -04:00
Michael Bucari-Tovo
a3be3e354f Code readability changes 2022-06-12 17:30:11 -06:00
Michael Bucari-Tovo
58c52196f1 Remove Books button now on Main button row 2022-06-12 17:03:29 -06:00
Michael Bucari-Tovo
b7b49a60cf Migration Exception handling 2022-06-12 16:35:48 -06:00
Michael Bucari-Tovo
fa195483d6 Set Import/Esport initial directory 2022-06-12 16:29:33 -06:00
Michael Bucari-Tovo
2341f6ea3b Better display and hiding of process queue 2022-06-12 16:29:06 -06:00
Michael Bucari-Tovo
ffe0f0730d Don't fire click for error books 2022-06-12 15:27:10 -06:00
Michael Bucari-Tovo
23b512910e Update 2022-06-12 15:23:55 -06:00
Michael Bucari-Tovo
b1c624b104 Revised stoplight icons 2022-06-12 15:17:07 -06:00
Michael Bucari-Tovo
fe35be6682 New libation icons 2022-06-12 13:39:35 -06:00
Michael Bucari-Tovo
2d3eb29bd5 Move event invoke out of lock 2022-06-11 19:10:08 -06:00
Michael Bucari-Tovo
26f0ff62df Additional safety check 2022-06-11 15:10:18 -06:00
Michael Bucari-Tovo
5e145846bd Only check non-liberated books when doing scan remove books. 2022-06-11 12:42:00 -06:00
Michael Bucari-Tovo
1ae5f99bf0 Add migration to try and fix db for incorrect or missing espiode series entries. 2022-06-11 12:41:20 -06:00
Michael Bucari-Tovo
984119c7ee Exit download loop if zero bytes are read. 2022-06-10 21:00:04 -06:00
Michael Bucari-Tovo
f8f5eac109 Refactor 2022-06-10 20:45:10 -06:00
Michael Bucari-Tovo
4111d5fa48 Remove redundant declarations. 2022-06-10 19:37:50 -06:00
Michael Bucari-Tovo
2eca9056b9 Reorder api calls 2022-06-10 19:36:00 -06:00
Michael Bucari-Tovo
60e96572ff Always refresh token, regardless of expiration date. 2022-06-10 19:34:49 -06:00
Michael Bucari-Tovo
52193933b2 Add scan and remove books tomain view, remove separate dialog. 2022-06-10 19:22:54 -06:00
Michael Bucari-Tovo
7bcabdda38 FindInactiveBooks now fires ScanBegin and ScanEnd events 2022-06-10 18:30:16 -06:00
Mbucari
d993941c4d Merge branch 'rmcrackan:master' into master 2022-06-10 15:41:07 -06:00
Michael Bucari-Tovo
b447bff9a6 Add audible-cli import/export accounts 2022-06-10 15:19:05 -06:00
Robert McRackan
73cb5ffba4 clearly Hoopla integration isn't going to happen. delete temp files 2022-06-09 17:01:33 -04:00
56 changed files with 2031 additions and 2463 deletions

View File

@@ -221,9 +221,10 @@ namespace AaxDecrypter
try
{
int bytesRead;
do
{
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
@@ -237,7 +238,7 @@ namespace AaxDecrypter
downloadedPiece.Set();
}
} while (downloadPosition < ContentLength && !IsCancelled);
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
_writeFile.Close();
_networkStream.Close();

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Version>7.11.0.1</Version>
<Version>8.0.0.1</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -5,10 +5,10 @@ using System.Linq;
using System.Reflection;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using Serilog;
@@ -405,25 +405,71 @@ namespace AppScaffolding
public static void migrate_from_7_10_1(Configuration config)
{
//This migration removes books and series with SERIES_ prefix that were created
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
var lastNigrationThres = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
var migrated = config.GetNonString<bool>(nameof(migrate_from_7_10_1));
if (lastNigrationThres) return;
if (migrated) return;
try
{
using var context = DbContexts.GetContext();
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
//and those with improperly identified or missing series. This does not solve cases
//where individual episodes are in the db with a valid series link, but said series'
//parents have not been imported into the database. For those cases, Libation will
//attempt fixup by retrieving parents from the catalog endpoint
var booksToRemove = context.Books.Where(b => b.AudibleProductId.StartsWith("SERIES_")).ToArray();
var seriesToRemove = context.Series.Where(s => s.AudibleSeriesId.StartsWith("SERIES_")).ToArray();
var lbToRemove = context.LibraryBooks.Where(lb => booksToRemove.Any(b => b == lb.Book)).ToArray();
using var context = DbContexts.GetContext();
context.LibraryBooks.RemoveRange(lbToRemove);
context.Books.RemoveRange(booksToRemove);
context.Series.RemoveRange(seriesToRemove);
//This migration removes books and series with SERIES_ prefix that were created
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
string removeHackSeries = "delete " +
"from series " +
"where AudibleSeriesId like 'SERIES%'";
LibraryCommands.SaveContext(context);
config.SetObject(nameof(migrate_from_7_10_1), true);
string removeHackBooks = "delete " +
"from books " +
"where AudibleProductId like 'SERIES%'";
//Detect series parents that were added to the database as books with ContentType.Episode,
//and change them to ContentType.Parent
string updateContentType =
"UPDATE books " +
"SET contenttype = 4 " +
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
"FROM books " +
"INNER JOIN series " +
"ON ( books.audibleproductid = " +
"series.audibleseriesid) " +
"WHERE books.contenttype = 2)";
//Then detect series parents that were added to the database as books with ContentType.Parent
//but are missing a series link, and add the link (don't know how this happened)
string addMissingSeriesLink =
"INSERT INTO seriesbook " +
"SELECT series.seriesid, " +
"books.bookid, " +
"'- 1' " +
"FROM books " +
"LEFT OUTER JOIN seriesbook " +
"ON books.bookid = seriesbook.bookid " +
"INNER JOIN series " +
"ON books.audibleproductid = series.audibleseriesid " +
"WHERE books.contenttype = 4 " +
"AND seriesbook.seriesid IS NULL";
context.Database.ExecuteSqlRaw(removeHackSeries);
context.Database.ExecuteSqlRaw(removeHackBooks);
context.Database.ExecuteSqlRaw(updateContentType);
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
LibraryCommands.SaveContext(context);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occured while running database migrations in {0}", nameof(migrate_from_7_10_1));
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
}
}
}
}

View File

@@ -27,10 +27,17 @@ namespace ApplicationServices
ScanEnd += (_, __) => Scanning = false;
}
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
lock (_lock)
{
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
@@ -83,6 +90,7 @@ namespace ApplicationServices
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
@@ -100,8 +108,8 @@ namespace ApplicationServices
{
if (Scanning)
return (0, 0);
ScanBegin?.Invoke(null, accounts.Length);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
@@ -118,6 +126,22 @@ namespace ApplicationServices
if (totalCount == 0)
return default;
Log.Logger.Information("Begin scan for orphaned episode parents");
var newParents = await findAndAddMissingParents(apiExtendedfunc, accounts);
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
if (newParents >= 0)
{
//If any episodes are still orphaned, their series have been
//removed from the catalog and wel'll never be able to find them.
//only do this if findAndAddMissingParents returned >= 0. If it
//returned < 0, an error happened and there's still a chance that
//a future successful run will find missing parents.
removedOrphanedEpisodes();
}
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
@@ -199,7 +223,7 @@ namespace ApplicationServices
using var context = DbContexts.GetContext();
var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
@@ -211,7 +235,85 @@ namespace ApplicationServices
return newCount;
}
public static int SaveContext(LibationContext context)
static void removedOrphanedEpisodes()
{
using var context = DbContexts.GetContext();
try
{
var orphanedEpisodes =
context
.GetLibrary_Flat_NoTracking(includeParents: true)
.FindOrphanedEpisodes();
context.LibraryBooks.RemoveRange(orphanedEpisodes);
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occured while trying to remove orphaned episodes from the database");
}
}
static async Task<int> findAndAddMissingParents(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts)
{
using var context = DbContexts.GetContext();
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
try
{
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
if (!orphanedEpisodes.Any())
return -1;
var orphanedSeries =
orphanedEpisodes
.SelectMany(lb => lb.Book.SeriesLink)
.DistinctBy(s => s.Series.AudibleSeriesId)
.ToList();
// We're only calling the Catalog endpoint, so it doesn't matter which account we use.
var apiExtended = await apiExtendedfunc(accounts[0]);
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
var items = await apiExtended.Api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
List<ImportItem> newParentsImportItems = new();
foreach (var sp in orphanedSeries)
{
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
if (seriesItem.Relationships is null)
continue;
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
seriesItem.Series = new AudibleApi.Common.Series[]
{
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
};
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
}
var newCoutn = new LibraryBookImporter(context)
.Import(newParentsImportItems);
await context.SaveChangesAsync();
return newCoutn;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occured while trying to scan for orphaned episode parents.");
return -1;
}
}
public static int SaveContext(LibationContext context)
{
try
{

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Authorization;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AudibleUtilities
{
public partial class Mkb79Auth : IIdentityMaintainer
{
[JsonProperty("website_cookies")]
private JObject _websiteCookies { get; set; }
[JsonProperty("adp_token")]
public string AdpToken { get; private set; }
[JsonProperty("access_token")]
public string AccessToken { get; private set; }
[JsonProperty("refresh_token")]
public string RefreshToken { get; private set; }
[JsonProperty("device_private_key")]
public string DevicePrivateKey { get; private set; }
[JsonProperty("store_authentication_cookie")]
private JObject _storeAuthenticationCookie { get; set; }
[JsonProperty("device_info")]
public DeviceInfo DeviceInfo { get; private set; }
[JsonProperty("customer_info")]
public CustomerInfo CustomerInfo { get; private set; }
[JsonProperty("expires")]
private double _expires { get; set; }
[JsonProperty("locale_code")]
public string LocaleCode { get; private set; }
[JsonProperty("activation_bytes")]
public string ActivationBytes { get; private set; }
[JsonIgnore]
public Dictionary<string, string> WebsiteCookies
{
get => _websiteCookies.ToObject<Dictionary<string, string>>();
private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
}
[JsonIgnore]
public string StoreAuthenticationCookie
{
get => _storeAuthenticationCookie.ToObject<Dictionary<string, string>>()["cookie"];
private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary<string, string>() { { "cookie", value } }, Converter.Settings));
}
[JsonIgnore]
public DateTime AccessTokenExpires
{
get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime;
private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d;
}
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
public Task<AccessToken> GetAccessTokenAsync()
=> Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires));
public Task<AdpToken> GetAdpTokenAsync()
=> Task.FromResult(new AdpToken(AdpToken));
public Task<PrivateKey> GetPrivateKeyAsync()
=> Task.FromResult(new PrivateKey(DevicePrivateKey));
}
public partial class CustomerInfo
{
[JsonProperty("account_pool")]
public string AccountPool { get; set; }
[JsonProperty("user_id")]
public string UserId { get; set; }
[JsonProperty("home_region")]
public string HomeRegion { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("given_name")]
public string GivenName { get; set; }
}
public partial class DeviceInfo
{
[JsonProperty("device_name")]
public string DeviceName { get; set; }
[JsonProperty("device_serial_number")]
public string DeviceSerialNumber { get; set; }
[JsonProperty("device_type")]
public string DeviceType { get; set; }
}
public partial class Mkb79Auth
{
public static Mkb79Auth FromJson(string json)
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
public string ToJson()
=> JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented);
public async Task<Account> ToAccountAsync()
{
var refreshToken = new RefreshToken(RefreshToken);
var authorize = new Authorize(Locale);
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
AccessToken = newToken.TokenValue;
AccessTokenExpires = newToken.Expires;
var api = new Api(this);
var email = await api.GetEmailAsync();
var account = new Account(email)
{
DecryptKey = ActivationBytes,
AccountName = $"{email} - {Locale.Name}",
IdentityTokens = new Identity(Locale)
};
account.IdentityTokens.Update(
await GetPrivateKeyAsync(),
await GetAdpTokenAsync(),
await GetAccessTokenAsync(),
refreshToken,
WebsiteCookies.Select(c => new KeyValuePair<string, string>(c.Key, c.Value)),
DeviceSerialNumber,
DeviceType,
AmazonAccountId,
DeviceInfo.DeviceName,
StoreAuthenticationCookie);
return account;
}
public static Mkb79Auth FromAccount(Account account)
=> new()
{
AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue,
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
AdpToken = account.IdentityTokens.AdpToken.Value,
CustomerInfo = new CustomerInfo
{
AccountPool = "Amazon",
GivenName = string.Empty,
HomeRegion = "NA",
Name = string.Empty,
UserId = account.IdentityTokens.AmazonAccountId
},
DeviceInfo = new DeviceInfo
{
DeviceName = account.IdentityTokens.DeviceName,
DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber,
DeviceType = account.IdentityTokens.DeviceType,
},
DevicePrivateKey = account.IdentityTokens.PrivateKey,
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
LocaleCode = account.Locale.CountryCode,
RefreshToken = account.IdentityTokens.RefreshToken.Value,
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
};
}
public static class Serialize
{
public static string ToJson(this Mkb79Auth self)
=> JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented);
}
internal static class Converter
{
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
};
}
}

View File

@@ -44,6 +44,8 @@ namespace DataLayer
public static bool IsEpisodeParent(this Book book)
=> book.ContentType is ContentType.Parent;
public static bool HasLiberated(this Book book)
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
}
}

View File

@@ -43,18 +43,37 @@ namespace DataLayer
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks
.Where(lb => lb.Book.IsEpisodeChild())
.ExceptBy(
libraryBooks
.ParentedEpisodes()
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
#nullable enable
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> libraryBooks, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return libraryBooks.FirstOrDefault(
lb =>
lb.Book.IsEpisodeParent() &&
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return libraryBooks.FirstOrDefault(
lb =>
lb.Book.IsEpisodeParent() &&
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (System.Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
#nullable disable

View File

@@ -1,56 +1,70 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using Dinah.Core;
using FileManager;
using LibationFileManager;
namespace FileLiberator
{
public static class AudioFileStorageExt
{
private class MultipartRenamer
{
private LibraryBook libraryBook { get; }
public static class AudioFileStorageExt
{
private class MultipartRenamer
{
private LibraryBook libraryBook { get; }
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
}
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
}
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
=> new MultipartRenamer(libraryBook).MultipartFilename;
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
=> new MultipartRenamer(libraryBook).MultipartFilename;
/// <summary>
/// DownloadDecryptBook:
/// File path for where to move files into.
/// Path: directory nested inside of Books directory
/// File name: n/a
/// </summary>
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
=> Templates.Folder.GetFilename(libraryBook.ToDto());
/// <summary>
/// DownloadDecryptBook:
/// File path for where to move files into.
/// Path: directory nested inside of Books directory
/// File name: n/a
/// </summary>
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
{
if (libraryBook.Book.IsEpisodeChild() && Configuration.Instance.SavePodcastsToParentFolder)
{
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
/// <summary>
/// DownloadDecryptBook:
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
if (seriesParent is not null)
{
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
}
}
}
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
return Templates.Folder.GetFilename(libraryBook.ToDto());
}
/// <summary>
/// PDF: audio file already exists
/// </summary>
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
}
/// <summary>
/// DownloadDecryptBook:
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file already exists
/// </summary>
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
}
}

View File

@@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -1,847 +0,0 @@
<#
.SYNOPSIS
Downloads, decrypts and repackages content from Hoopla
.DESCRIPTION
Uses a HooplaDigital.com account to download DRM-free copies of ebooks, comics,
and/or audiobooks available on the platform. Content that is not already borrowed
on the account will be borrowed if slots are available. Content that is not borrowed
cannot be downloaded.
* E-Books are downloaded to epub files (most) or cbz (rare, picture books).
* Comic books are downloaded to cbz files.
* Audiobooks are downloaded to m4a files. (single file, and very little metadata available
from Hoopla, such as chapters)
.PARAMETER Credential
Credential to use for logging into Hoopla site.
(Cannot be used with Username and Password parameters)
.PARAMETER Username
Username to use for logging into Hoopla site.
(Cannot be used with Credential parameter)
.PARAMETER Password
Password to use for logging into Hoopla site.
(Cannot be used with Credential parameter)
.PARAMETER TitleId
Specifies one or more title IDs of content to download.
.PARAMETER OutputFolder
Sets the output folder for downloaded content. Defaults to current directory.
.PARAMETER PatronId
Override default patron id for Hoopla. (This is rarely required as most user accounts are only tied
to a single patron).
.PARAMETER EpubZipBin
Specifies path to epubzip binary. Else look for one beside script, or in system path.
.PARAMETER FfmpegBin
Specifies path to ffmpeg binary. Else look for one beside script, or in system path.
.PARAMETER KeepDecryptedData
If set, don't delete the intermediary data after decryption, before final output file.
For ebooks, this is xml, images, and the manifest. For comics, it is images. For audiobooks,
it is mp4 ts files. This is typically only useful for development or troubleshooting.
.PARAMETER KeepEncryptedData
If set, don't delete the encrypted data as downloaded from Hoopla's servers. This is typically
only useful for development or troubleshooting.
.PARAMETER AllBorrowed
This parameter is deprecated. If TitleId is not set, it is implied that all borrowed titles will
be downloaded.
.PARAMETER AudioBookForceSingleFile
If set, leave audiobook as single file, as if chapter data is not present.
.EXAMPLE
.\Invoke-HooplaDownload.ps1 123456
Downloads Hoopla content with title id 123456
.NOTES
Author: kabutops728 - My Anonamouse
Version: 2.9
#>
[CmdletBinding(DefaultParameterSetName='CredentialSingleTitle')]
param(
[int64[]]
$TitleId,
[Parameter(Mandatory,ParameterSetName='CredentialSingleTitle')]
[Management.Automation.PSCredential]
$Credential,
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
[string]
$Username,
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
[string]
$Password,
[ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Container})]
[string]$OutputFolder = $PSScriptRoot,
[int64]$PatronId,
[string]$EpubZipBin,
[string]$FfmpegBin,
[switch]$KeepDecryptedData,
[switch]$KeepEncryptedData,
[switch]$AudioBookForceSingleFile,
# Deprecated
[switch]$AllBorrowed
)
$USER_AGENT = 'Hoopla Android/4.27'
$HEADERS = @{
'app' = 'ANDROID'
'app-version' = '4.27.1'
'device-module' = 'KFKAWI'
'device-version' = ''
'hoopla-verson' = '4.27.1'
'kids-mode' = 'false'
'os' = 'ANDROID'
'os-version' = '6.0.1'
'ws-api' = '2.1'
'Host' = 'hoopla-ws.hoopladigital.com'
}
$URL_HOOPLA_WS_BASE = 'https://hoopla-ws.hoopladigital.com'
$URL_HOOPLA_LIC_BASE = 'https://hoopla-license2.hoopladigital.com'
$COMIC_IMAGE_EXTS = @('.jpg','.png','.jpeg','.gif','.bmp','.tif','.tiff')
enum HooplaKind
{
EBOOK = 5
MUSIC = 6
MOVIE = 7
AUDIOBOOK = 8
TELEVISION = 9
COMIC = 10
}
$SUPPORTED_KINDS = @([HooplaKind]::EBOOK, [HooplaKind]::COMIC, [HooplaKind]::AUDIOBOOK)
Function Connect-Hoopla
{
param(
[Parameter(Mandatory)][Management.Automation.PSCredential]$Credential
)
$username = $Credential.UserName
$password = $Credential.GetNetworkCredential().Password
$res = Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/tokens" -Method Post -Headers $HEADERS -UserAgent $USER_AGENT -Body @{username = $username; password = $password}
if ($res.tokenStatus -ne 'SUCCESS')
{
throw $res.message
}
$res.token
}
Function Get-HooplaUsers
{
param(
[Parameter(Mandatory)][string]$Token
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-HooplaTitleInfo
{
param(
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$TitleId
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/v2/titles/$TitleId" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-HooplaBorrowsRemaining
{
param(
[Parameter(Mandatory)][string]$UserId,
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrows-remaining" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-HooplaBorrowedTitles
{
param(
[Parameter(Mandatory)][string]$UserId,
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/borrowed-titles" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Invoke-HooplaBorrow
{
param(
[Parameter(Mandatory)][string]$UserId,
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$TitleId
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrowed-titles/$TitleId" -Method Post -Headers $h -UserAgent $USER_AGENT
}
Function Invoke-HooplaZipDownload
{
param(
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$CircId,
[Parameter(Mandatory)][ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Leaf})][string]$OutFile
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
$res = Invoke-WebRequest -Uri "$URL_HOOPLA_WS_BASE/patrons/downloads/$CircId/url" -Method Get -Headers $h -UserAgent $USER_AGENT -UseBasicParsing
if ($PSVersionTable.PSVersion.Major -ge 6)
{
Invoke-WebRequest -Uri $res.Headers['Location'][0] -Method Get -UseBasicParsing -OutFile $OutFile
}
else
{
Invoke-WebRequest -Uri $res.Headers['Location'] -Method Get -UseBasicParsing -OutFile $OutFile
}
}
Function Get-HooplaKey
{
param(
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$CircId
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_LIC_BASE/downloads/$CircId/key" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-FileKeyKey
{
param(
[Parameter(Mandatory)][int64]$CircId,
[Parameter(Mandatory)][DateTime]$Due,
[Parameter(Mandatory)][int64]$PatronId
)
$combined = '{0:yyyyMMddHHmmss}:{1}:{2}' -f $Due, $PatronId, $CircId
[Security.Cryptography.HashAlgorithm]::Create('SHA1').ComputeHash([Text.Encoding]::UTF8.GetBytes($combined)) | Select-Object -First 16
}
Function Decrypt-FileKey
{
param(
[Parameter(Mandatory)][byte[]]$FileKeyEnc,
[Parameter(Mandatory)][byte[]]$FileKeyKey
)
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
$aesManaged.Mode = [Security.Cryptography.CipherMode]::ECB
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
$aesManaged.BlockSize = 128
$aesManaged.KeySize = 128
$aesManaged.Key = $FileKeyKey
$decryptor = $aesManaged.CreateDecryptor();
$unencryptedData = $decryptor.TransformFinalBlock($FileKeyEnc, 0, $FileKeyEnc.Length);
$aesManaged.Dispose()
$unencryptedData
}
Function Decrypt-File
{
param(
[Parameter(Mandatory)][byte[]]$FileKey,
[Parameter(Mandatory)][string]$MediaKey,
[Parameter(Mandatory)][string]$InputFileName,
[Parameter(Mandatory)][string]$OutputFileName
)
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
$aesManaged.Mode = [Security.Cryptography.CipherMode]::CBC
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
$aesManaged.BlockSize = 128
$aesManaged.KeySize = 256
$aesManaged.Key = $FileKey
$aesManaged.IV = [Text.Encoding]::UTF8.GetBytes($MediaKey) | Select-Object -First 16
$fileStreamReader = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $InputFileName, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
$fileStreamWriter = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $OutputFileName, ([IO.FileMode]::Create)
$FileStreamReader.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
$decryptor = $aesManaged.CreateDecryptor()
$cryptoStream = New-Object -TypeName 'System.Security.Cryptography.CryptoStream' -ArgumentList $fileStreamWriter, $decryptor, ([Security.Cryptography.CryptoStreamMode]::Write)
$fileStreamReader.CopyTo($cryptoStream)
$cryptoStream.FlushFinalBlock()
$cryptoStream.Close()
$fileStreamReader.Close()
$fileStreamWriter.Close()
$aesManaged.Dispose()
}
Function Test-Mp4
{
param(
[Parameter(Mandatory, Position=0)]
[Alias('LiteralPath')]
[string]$Path
)
$fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $Path, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
$fileReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList $fileStream -ErrorAction Stop
$head = $fileReader.ReadBytes(8)
$fileReader.Dispose()
$fileStream.Dispose()
return [Text.Encoding]::ASCII.GetString(($head | Select-Object -Skip 4)) -eq 'ftyp'
}
Function Remove-InvalidFileNameChars
{
param(
[Parameter(Mandatory,Position=0,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true)]
[String]$Name
)
$invalidChars = [IO.Path]::GetInvalidFileNameChars() -join ''
$re = "[{0}]" -f [RegEx]::Escape($invalidChars)
$Name -replace $re, '_'
}
Function Convert-HooplaDecryptedToEpub
{
param(
[Parameter(Mandatory)][string]$InputFolder,
[Parameter(Mandatory)][string]$OutFolder
)
$container = [xml](Get-Content -LiteralPath (Join-Path -Path $InputFolder -ChildPath 'META-INF\container.xml') -Raw)
$rootFile = $container.container.rootfiles.rootfile | Select-Object -ExpandProperty Full-Path
$contentFile = (Join-Path -Path $InputFolder -ChildPath $rootFile).Trim()
$contentRoot = Get-Item -LiteralPath $contentFile | Select-Object -ExpandProperty Directory
$content = [xml](Get-Content -LiteralPath $contentFile)
$fileList = $content.package.manifest.item | Select-Object -ExpandProperty href | ForEach-Object -Process { (Join-Path -Path $contentRoot -ChildPath ([Web.HttpUtility]::UrlDecode($_))).Trim() }
$fileList += $contentFile
$fileList = $fileList | Sort-Object -Unique
$title = $content.package.metadata.title | Select-Object -First 1
if ($title.GetType() -ne [String])
{
$title = $content.package.metadata.title | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
}
$author = $content.package.metadata.creator | Select-Object -First 1
if ($author.GetType() -ne [String])
{
$author = $content.package.metadata.creator | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
}
# Usually, content root is a subfolder of the input folder. But sometimes, they are the same. Make sure we declutter the input root if they differ, and always keep the mimetype file.
$mimeTypeFile = Join-Path -Path $InputFolder -ChildPath 'mimetype'
$extra = @(Get-ChildItem -LiteralPath $contentRoot -File -Recurse | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) })
$extra += Get-ChildItem -LiteralPath $InputFolder -File | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) }
$extra = $extra | Sort-Object -Property FullName -Unique
$extra | Remove-Item
$containerXmlFolder = Join-Path -Path $contentRoot.FullName -ChildPath 'META-INF'
$containerXmlPath = Join-Path -Path $containerXmlFolder -ChildPath 'container.xml'
if (!(Test-Path -LiteralPath $containerXmlPath -PathType Leaf))
{
New-Item -Path $containerXmlFolder -ItemType Directory -Force | Out-Null
$xml = @"
<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"@
$xml | Out-File -LiteralPath $containerXmlPath -Encoding ascii
}
$finalFile = ('{0} - {1}.epub' -f $title, $author) | Remove-InvalidFileNameChars
Push-Location
Set-Location -LiteralPath $InputFolder
$finalFileFullPath = (Join-Path -Path $OutFolder -ChildPath $finalFile)
if ($VerbosePreference -eq 'Continue')
{
& $EpubZipBin $finalFileFullPath
}
else
{
& $EpubZipBin $finalFileFullPath >$null 2>&1
}
Pop-Location
Get-Item -LiteralPath $finalFileFullPath
}
Function Convert-HooplaDecryptedToCbz
{
param(
[Parameter(Mandatory)][string]$InputFolder,
[Parameter(Mandatory)][string]$OutFolder,
[Parameter(Mandatory)][string]$Name
)
$fileName = $Name | Remove-InvalidFileNameChars
$tempOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.zip"
$finalOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.cbz"
Compress-Archive -Path (
Get-ChildItem -LiteralPath $InputFolder | Where-Object -FilterScript { $_.Extension -in $COMIC_IMAGE_EXTS } | Select-Object -ExpandProperty FullName
) -CompressionLevel Fastest -DestinationPath $tempOutFile
Rename-Item -LiteralPath $tempOutFile -NewName $finalOutFile
Get-Item $finalOutFile
}
Function Convert-HooplaDecryptedToM4a
{
param(
[Parameter(Mandatory)][string]$InputFolder,
[Parameter(Mandatory)][string]$OutFolder,
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Title,
[Parameter(Mandatory)][string]$Author,
[Parameter(Mandatory)][int]$Year,
[string]$Subtitle,
[object]$ChapterData
)
if ($Author)
{
$baseFileName = ('{0} - {1}' -f $Name, $Author) | Remove-InvalidFileNameChars
}
else
{
$baseFileName = $Name | Remove-InvalidFileNameChars
}
$finalOutFile = Join-Path -Path $OutFolder -ChildPath ('{0}.m4a' -f $baseFileName)
$inFile = Get-ChildItem -LiteralPath $InputFolder -Filter '*.m3u8' | Select-Object -First 1 | Select-Object -ExpandProperty FullName
Push-Location
Set-Location $InputFolder
$ffArgs = @(
'-y',
'-i', $infile,
'-metadata', ('title="{0}"' -f $Title),
'-metadata', ('year="{0}"' -f $Year),
'-metadata', ('author="{0}"' -f $Author),
'-metadata', 'genre="Audiobook"'
)
if ($Subtitle)
{
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
}
$ffArgs += @(
'-c:a', 'copy',
$finalOutFile
)
if ($VerbosePreference -eq 'Continue')
{
& $FfmpegBin @ffArgs
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile
}
else
{
& $FfmpegBin @ffArgs >$null 2>&1
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile >$null 2>&1
}
if ($ChapterData -and (!$AudioBookForceSingleFile))
{
$outDir = New-Item -Path (Join-Path -Path $OutFolder -ChildPath $baseFileName) -ItemType Directory
$chapterCount = $ChapterData | Select-Object -ExpandProperty chapter | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum
$ChapterData | ForEach-Object -Process {
$ffArgs = @(
'-y',
'-i', $finalOutFile,
'-ss', $_.start,
'-t', $_.duration,
'-metadata', ('title="{0}"' -f $_.title),
'-metadata', ('album="{0}"' -f $Title),
'-metadata', ('year="{0}"' -f $Year),
'-metadata', ('author="{0}"' -f $Author),
'-metadata', 'genre="Audiobook"'
'-metadata', ('track={0}/{1}' -f $_.ordinal, $chapterCount)
)
if ($Subtitle)
{
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
}
$ffArgs += @(
'-c', 'copy',
(Join-Path -Path $outDir.FullName -ChildPath ('{0} - {1} - {2}.m4a' -f $baseFileName, $_.ordinal, ($_.title | Remove-InvalidFileNameChars)))
)
if ($VerbosePreference -eq 'Continue')
{
& $FfmpegBin @ffArgs
}
else
{
& $FfmpegBin @ffArgs >$null 2>&1
}
}
Remove-Item $finalOutFile
$finalOutFile = $outDir
}
Pop-Location
Get-Item $finalOutFile
}
if (!$Credential)
{
$ssPassword = ConvertTo-SecureString $Password -AsPlainText -Force
$Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $Username, $ssPassword
}
if ((!$AllBorrowed) -and ($null -eq $TitleId))
{
Write-Warning 'No -TitleId specified. All currently-borrowed titles will be downloaded.'
$AllBorrowed = $true
}
$AppExtension = ''
if (($PSVersionTable.PSVersion -lt '6.0') -or $IsWindows)
{
$AppExtension = '.exe'
}
$cmd = ''
if ($EpubZipBin)
{
$cmd = Get-Command -Name $EpubZipBin -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "Epubzip binary specified was not found ($EpubZipBin). Will try to use alternate version if available."
}
}
if (!$cmd)
{
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "epubzip$AppExtension") -ErrorAction SilentlyContinue
if (!$cmd)
{
$cmd = Get-Command -Name "epubzip$AppExtension" -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "Epubzip binary not found ($EpubZipBin). If you are downloading ebooks (rather than comics or audiobooks), you may wish to download the binary from https://github.com/dino-/epub-tools/releases, specify a different path with -EpubZipBin, or specify -KeepDecryptedData so that you can manually pack afterward."
}
}
$EpubZipBin = $cmd.Source
}
Write-Verbose ('Using epubzip bin: "{0}"' -f $EpubZipBin)
$cmd = ''
if ($FfmpegBin)
{
$cmd = Get-Command -Name $FfmpegBin -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "FFMpeg binary specified was not found ($FfmpegBin). Will try to use alternate version if available."
}
}
if (!$cmd)
{
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "ffmpeg$AppExtension") -ErrorAction SilentlyContinue
if (!$cmd)
{
$cmd = Get-Command -Name "ffmpeg$AppExtension" -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "FFmpeg binary not found. If you are downloading audiobooks (rather than ebooks or comics), you may wish to download the binary from https://ffmpeg.zeranoe.com/builds/, specify a different path with -FfmpegBin, or specify -KeepDecryptedData so that you can manually convert afterward."
}
}
$FfmpegBin = $cmd.Source
}
Write-Verbose ('Using ffpmeg bin: "{0}"' -f $FfmpegBin)
if (!(Test-Path -LiteralPath $OutputFolder -PathType Container))
{
Write-Warning "Output folder doesn't exist. Creating."
New-Item -Path $OutputFolder -ItemType Directory | Out-Null
}
$OutputFolder = Get-Item -LiteralPath $OutputFolder | Select-Object -ExpandProperty $_.FullName
$token = Connect-Hoopla -Credential $Credential
Write-Verbose "Logged in. Received token $($token -replace '\-.*', '-****-****-****-************')"
$users = Get-HooplaUsers $token
Write-Verbose "Found $($users.patrons.Count) patrons"
$userId = $users.id
if (!$PatronId)
{
if ($users.patrons.Count -eq 0)
{
throw "No patrons found on account. Account may not be correctly set up with library."
}
elseif ($users.patrons.Count -gt 1)
{
Write-Warning (
"Multiple patrons found on account. Using first one, {0} ({1}). You can specify -PatronId to override" -f $users.patrons[0].id, $users.patrons[0].libraryName
)
}
$PatronId = $users.patrons[0].id
Write-Verbose "Using PatronId $PatronId"
}
$borrowedRaw = Get-HooplaBorrowedTitles -Token $token -UserId $userId -PatronId $PatronId
$borrowed = $borrowedRaw | Where-Object -FilterScript { $_.kind.id -in $SUPPORTED_KINDS }
Write-Verbose "Found $($borrowed.Count) ($($borrowedRaw.Count)) titles already borrowed"
$toDownload = @()
if ($AllBorrowed)
{
$toDownload = $borrowed
}
else
{
$toDownload = $borrowed | Where-Object -FilterScript { $_.id -in $TitleId }
$allBorrowedTitles = $borrowed | Select-Object -ExpandProperty id
$toBorrow = $TitleId | Where-Object -FilterScript { $_ -notin $allBorrowedTitles }
if ($toBorrow)
{
$borrowsRemainingData = Get-HooplaBorrowsRemaining -UserId $userId -PatronId $PatronId -Token $token
Write-Host $borrowsRemainingData.borrowsRemainingMessage
$borrowsRemaining = $borrowsRemainingData.borrowsRemaining
$toBorrow | ForEach-Object -Process {
Write-Host "Title $_ is not already borrowed or is not a supported kind. Looking up data about it."
$titleInfo = Get-HooplaTitleInfo -PatronId $PatronId -Token $token -TitleId $_
if ($titleInfo.kind.id -in $SUPPORTED_KINDS)
{
if ((--$borrowsRemaining) -le 0)
{
Write-Warning "Title $_ ($($titleInfo.Title)) not borrowed already, but we're out of remaining borrows allowed. Skipping..."
}
else
{
Write-Host "Borrowing title $_ ($($titleInfo.Title))..."
$res = Invoke-HooplaBorrow -UserId $userId -PatronId $PatronId -Token $token -TitleId $titleInfo.id
Write-Host "Response: $($res.message)"
$newToDownload = $res.titles | Where-Object -FilterScript { $_.id -eq $titleInfo.id }
if ($newToDownload)
{
$toDownload += $newToDownload
}
else
{
Write-Warning "Failed to borrow title $_ ($($titleInfo.Title))..."
}
}
}
else
{
Write-Warning "Title $_ is not a supported kind ($($titleInfo.kind.name)). Skipping..."
}
}
}
}
$tempFolder = [IO.Path]::GetTempPath()
$now = Get-Date
$toDownload | ForEach-Object -Process {
$info = $_
$contentKind = [HooplaKind]$_.kind.id
if ($_.contents.mediaType)
{
$contentKind = [HooplaKind]$_.contents.mediaType
}
$contents = $info.contents
$circId = $contents.circId
$mediaKey = $contents.mediaKey
$dueUnix = [Math]::Truncate($info.contents.due / 1000)
$due = (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)).AddSeconds($dueUnix)
$circFileName = (Join-Path -Path $tempFolder -ChildPath "$($circId).zip")
Invoke-HooplaZipDownload -PatronId $patronId -Token $token -CircId $circId -OutFile $circFileName
$keyData = Get-HooplaKey -PatronId $patronId -Token $token -CircId $circId
$fileKeyKey = Get-FileKeyKey -CircId $circId -Due $due -PatronId $patronId
$fileKey = Decrypt-FileKey -FileKeyEnc ([Convert]::FromBase64String($keyData."$mediaKey")) -FileKeyKey $fileKeyKey
$encDir = Join-Path -Path $tempFolder -ChildPath ('enc-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
New-Item -Path $encDir -ItemType Directory | Out-Null
Expand-Archive -LiteralPath $circFileName -DestinationPath $encDir
Remove-Item -LiteralPath $circFileName
$decDir = Join-Path -Path $tempFolder -ChildPath ('dec-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
New-Item -Path $decDir -ItemType Directory | Out-Null
$activity = 'Decrypting Content ({0})' -f $_.title
Write-Progress -Activity $activity -PercentComplete 0
$zipFiles = Get-ChildItem $encDir -Recurse -File
$decDone = 0
$decTotal = $zipFiles.Count
$zipFiles | ForEach-Object -Process {
$outFile = $_.FullName.Replace($encDir, $decDir)
$outDir = $_.DirectoryName.Replace($encDir, $decDir)
if (!(Test-Path -LiteralPath $outDir))
{
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
}
if (($contentKind -eq [HooplaKind]::AUDIOBOOK) -and ($_.Extension -eq '.m3u8'))
{
$lines = Get-Content -LiteralPath $_.FullName | Where-Object -FilterScript {$_ -notmatch '^#EXT-X-KEY'}
# Out-File doesn't support utf8 w/o BOM
[IO.File]::WriteAllLines($outFile, $lines)
return
}
if ($_.Length)
{
# Hack. Some ebooks contain audio files that download as unencrypted
if (($_.Extension -eq '.m4a') -and (Test-Mp4 -LiteralPath $_.FullName))
{
Write-Verbose -Message ('Coping unencrypted {0}' -f $_.FullName)
Copy-Item -LiteralPath $_.FullName -Destination $outFile
}
else
{
Write-Verbose -Message ('Decrypting {0}' -f $_.FullName)
Decrypt-File -FileKey $fileKey -MediaKey $mediaKey -InputFileName $_.FullName -OutputFileName $outFile
}
}
else
{
Write-Verbose -Message ('Writing empty file {0}' -f $_.FullName)
'' | Out-File -LiteralPath $outFile
}
Write-Progress -Activity $activity -PercentComplete ((++$decDone) / $decTotal * 100)
}
Write-Progress -Activity $activity -Completed
switch ($contentKind)
{
([HooplaKind]::EBOOK) {
Convert-HooplaDecryptedToEpub -InputFolder $decDir -OutFolder $OutputFolder
}
([HooplaKind]::COMIC) {
$title = $contents.title
$subtitle = $contents.subtitle
$name = $title
if ($subtitle) {
$name += ", $subtitle"
}
Convert-HooplaDecryptedToCbz -InputFolder $decDir -OutFolder $OutputFolder -Name $name
}
([HooplaKind]::AUDIOBOOK) {
Convert-HooplaDecryptedToM4a -InputFolder $decDir -OutFolder $OutputFolder -Name $info.title -Title $info.title `
-Year $info.year -Author $info.artist.name -Subtitle $contents.subtitle -ChapterData $contents.chapters
}
}
if (!$KeepDecryptedData)
{
Remove-Item -LiteralPath $decDir -Recurse
}
else
{
Write-Host ('Decrypted data for {0} ({1}) stored in {2}' -f $_.id, $_.title, $decDir)
}
if (!$KeepEncryptedData)
{
Remove-Item -LiteralPath $encDir -Recurse
}
}

View File

@@ -1,13 +0,0 @@
From a Libation user about possibility of integrating Hoopla:
I have a powershell script. I didn't write it, and neither did the person that gave it to me. It works most of the time (98%). Some titles, it doesn't play well with, but does allow to keep the downloaded data, whether the decrypt was successful, or not, and then you can mess with the data, from there.
If you run the script with no parameters, then all the books in your library will download, and decrypt into the same directory as the script, into a folder named Completed.
If you run the script with the command:
'.\HooplaDownloader.newer.ps1 -KeepDecryptedData'
then it will, and will notify you, when complete, where it was stored.
There is a parameter to download a specific titleID#, whether it's in your library, or not, but I've not played with it that far, as the method to accomplish it still reserves it to your library, and then proceeds as normal. I can tell you, if it's a "trial and error concern", the title will not be removed from your library, after you run the script, whether it succeeds or not. So, if it fails, you can retry, or try the -KeepDecryptedData option. I received no documentation for it, which is why I'm telling you as much as I know about using it.
[ see HooplaDownloader.newer.ps1 ]

View File

@@ -1,9 +0,0 @@
using System;
namespace Hoopla
{
public class temp
{
// placeholder
}
}

View File

@@ -5,11 +5,11 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
ProjectSection(SolutionItems) = preProject
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
__TODO.txt = __TODO.txt
REFERENCE.txt = REFERENCE.txt
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
_DB_NOTES.txt = _DB_NOTES.txt
REFERENCE.txt = REFERENCE.txt
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
__TODO.txt = __TODO.txt
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 Domain Utilities (db aware)", "5 Domain Utilities (db aware)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}"
@@ -50,8 +50,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
@@ -114,10 +112,6 @@ Global
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -165,7 +159,6 @@ Global
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}

View File

@@ -275,6 +275,13 @@ namespace LibationFileManager
set => persistentDictionary.SetNonString(nameof(AutoDownloadEpisodes), value);
}
[Description("Save all podcast episodes in a series to the series parent folder?")]
public bool SavePodcastsToParentFolder
{
get => persistentDictionary.GetNonString<bool>(nameof(SavePodcastsToParentFolder));
set => persistentDictionary.SetNonString(nameof(SavePodcastsToParentFolder), value);
}
#region templates: custom file naming
[Description("How to format the folders in which files will be saved")]

View File

@@ -207,8 +207,8 @@ namespace LibationFileManager
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, AudibleFileStorage.BooksDirectory, null)
public string GetFilename(LibraryBookDto libraryBookDto, string baseDir = null)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null)
.GetFilePath();
#endregion
}

View File

@@ -31,7 +31,9 @@
this.cancelBtn = new System.Windows.Forms.Button();
this.saveBtn = new System.Windows.Forms.Button();
this.dataGridView1 = new System.Windows.Forms.DataGridView();
this.importBtn = new System.Windows.Forms.Button();
this.DeleteAccount = new System.Windows.Forms.DataGridViewButtonColumn();
this.ExportAccount = new System.Windows.Forms.DataGridViewButtonColumn();
this.LibraryScan = new System.Windows.Forms.DataGridViewCheckBoxColumn();
this.AccountId = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.Locale = new System.Windows.Forms.DataGridViewComboBoxColumn();
@@ -43,9 +45,10 @@
//
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.cancelBtn.Location = new System.Drawing.Point(713, 415);
this.cancelBtn.Location = new System.Drawing.Point(832, 479);
this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.cancelBtn.Name = "cancelBtn";
this.cancelBtn.Size = new System.Drawing.Size(75, 23);
this.cancelBtn.Size = new System.Drawing.Size(88, 27);
this.cancelBtn.TabIndex = 2;
this.cancelBtn.Text = "Cancel";
this.cancelBtn.UseVisualStyleBackColor = true;
@@ -54,9 +57,10 @@
// saveBtn
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(612, 415);
this.saveBtn.Location = new System.Drawing.Point(714, 479);
this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.Size = new System.Drawing.Size(88, 27);
this.saveBtn.TabIndex = 1;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
@@ -71,60 +75,83 @@
this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.DeleteAccount,
this.ExportAccount,
this.LibraryScan,
this.AccountId,
this.Locale,
this.AccountName});
this.dataGridView1.Location = new System.Drawing.Point(12, 12);
this.dataGridView1.Location = new System.Drawing.Point(14, 14);
this.dataGridView1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.dataGridView1.MultiSelect = false;
this.dataGridView1.Name = "dataGridView1";
this.dataGridView1.Size = new System.Drawing.Size(776, 397);
this.dataGridView1.Size = new System.Drawing.Size(905, 458);
this.dataGridView1.TabIndex = 0;
this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick);
this.dataGridView1.DefaultValuesNeeded += new System.Windows.Forms.DataGridViewRowEventHandler(this.dataGridView1_DefaultValuesNeeded);
//
// importBtn
//
this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.importBtn.Location = new System.Drawing.Point(14, 480);
this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.importBtn.Name = "importBtn";
this.importBtn.Size = new System.Drawing.Size(156, 27);
this.importBtn.TabIndex = 1;
this.importBtn.Text = "Import from audible-cli";
this.importBtn.UseVisualStyleBackColor = true;
this.importBtn.Click += new System.EventHandler(this.importBtn_Click);
//
// DeleteAccount
//
this.DeleteAccount.HeaderText = "Delete";
this.DeleteAccount.Name = "DeleteAccount";
this.DeleteAccount.ReadOnly = true;
this.DeleteAccount.Text = "x";
this.DeleteAccount.Width = 44;
this.DeleteAccount.Width = 46;
//
// ExportAccount
//
this.ExportAccount.HeaderText = "Export";
this.ExportAccount.Name = "ExportAccount";
this.ExportAccount.Text = "Export to audible-cli";
this.ExportAccount.Width = 47;
//
// LibraryScan
//
this.LibraryScan.HeaderText = "Include in library scan?";
this.LibraryScan.Name = "LibraryScan";
this.LibraryScan.Width = 83;
this.LibraryScan.Width = 94;
//
// AccountId
//
this.AccountId.HeaderText = "Audible email/login";
this.AccountId.Name = "AccountId";
this.AccountId.Width = 111;
this.AccountId.Width = 125;
//
// Locale
//
this.Locale.HeaderText = "Locale";
this.Locale.Name = "Locale";
this.Locale.Width = 45;
this.Locale.Width = 47;
//
// AccountName
//
this.AccountName.HeaderText = "Account nickname (optional)";
this.AccountName.Name = "AccountName";
this.AccountName.Width = 152;
this.AccountName.Width = 170;
//
// AccountsDialog
//
this.AcceptButton = this.saveBtn;
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.cancelBtn;
this.ClientSize = new System.Drawing.Size(800, 450);
this.ClientSize = new System.Drawing.Size(933, 519);
this.Controls.Add(this.dataGridView1);
this.Controls.Add(this.importBtn);
this.Controls.Add(this.saveBtn);
this.Controls.Add(this.cancelBtn);
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.Name = "AccountsDialog";
this.Text = "Audible Accounts";
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
@@ -137,7 +164,9 @@
private System.Windows.Forms.Button cancelBtn;
private System.Windows.Forms.Button saveBtn;
private System.Windows.Forms.DataGridView dataGridView1;
private System.Windows.Forms.Button importBtn;
private System.Windows.Forms.DataGridViewButtonColumn DeleteAccount;
private System.Windows.Forms.DataGridViewButtonColumn ExportAccount;
private System.Windows.Forms.DataGridViewCheckBoxColumn LibraryScan;
private System.Windows.Forms.DataGridViewTextBoxColumn AccountId;
private System.Windows.Forms.DataGridViewComboBoxColumn Locale;

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using AudibleApi;
@@ -10,6 +11,7 @@ namespace LibationWinForms.Dialogs
public partial class AccountsDialog : Form
{
private const string COL_Delete = nameof(DeleteAccount);
private const string COL_Export = nameof(ExportAccount);
private const string COL_LibraryScan = nameof(LibraryScan);
private const string COL_AccountId = nameof(AccountId);
private const string COL_AccountName = nameof(AccountName);
@@ -44,12 +46,20 @@ namespace LibationWinForms.Dialogs
return;
foreach (var account in accounts)
dataGridView1.Rows.Add(
AddAccountToGrid(account);
}
private void AddAccountToGrid(Account account)
{
int row = dataGridView1.Rows.Add(
"X",
"Export",
account.LibraryScan,
account.AccountId,
account.Locale.Name,
account.AccountName);
dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli";
}
private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e)
@@ -73,6 +83,11 @@ namespace LibationWinForms.Dialogs
if (e.RowIndex < dgv.RowCount - 1)
dgv.Rows.Remove(row);
break;
case COL_Export:
// if final/edit row: do nothing
if (e.RowIndex < dgv.RowCount - 1)
Export((string)row.Cells[COL_AccountId].Value, (string)row.Cells[COL_Locale].Value);
break;
//case COL_MoveUp:
// // if top: do nothing
// if (e.RowIndex < 1)
@@ -136,13 +151,13 @@ namespace LibationWinForms.Dialogs
{
if (string.IsNullOrWhiteSpace(dto.AccountId))
{
MessageBox.Show("Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
if (string.IsNullOrWhiteSpace(dto.LocaleName))
{
MessageBox.Show("Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
}
@@ -194,5 +209,95 @@ namespace LibationWinForms.Dialogs
LibraryScan = (bool)r.Cells[COL_LibraryScan].Value
})
.ToList();
private string GetAudibleCliAppDataPath()
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible");
private void Export(string accountId, string locale)
{
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountId && a.Locale.Name == locale);
if (account is null)
return;
if (account.IdentityTokens?.IsValid != true)
{
MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated");
return;
}
SaveFileDialog sfd = new();
sfd.Filter = "JSON File|*.json";
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
sfd.InitialDirectory = audibleAppDataDir;
if (sfd.ShowDialog() != DialogResult.OK) return;
try
{
var mkbAuth = Mkb79Auth.FromAccount(account);
var jsonText = mkbAuth.ToJson();
File.WriteAllText(sfd.FileName, jsonText);
MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!");
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
$"An error occured while exporting account:\r\n{account.AccountName}",
"Error Exporting Account",
ex);
}
}
private async void importBtn_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new();
ofd.Filter = "JSON File|*.json";
ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
ofd.InitialDirectory = audibleAppDataDir;
if (ofd.ShowDialog() != DialogResult.OK) return;
try
{
var jsonText = File.ReadAllText(ofd.FileName);
var mkbAuth = Mkb79Auth.FromJson(jsonText);
var account = await mkbAuth.ToAccountAsync();
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name))
{
MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account");
return;
}
persister.AccountsSettings.Add(account);
AddAccountToGrid(account);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
$"An error occured while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?",
"Error Importing Account",
ex);
}
}
}
}

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<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">
@@ -58,10 +57,10 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="Original.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<metadata name="DeleteAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="DeleteAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<metadata name="ExportAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="LibraryScan.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
@@ -70,10 +69,10 @@
<metadata name="AccountId.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="AccountName.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="Locale.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="AccountName.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
</root>

View File

@@ -1,189 +0,0 @@

namespace LibationWinForms.Dialogs
{
partial class RemoveBooksDialog
{
/// <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()
{
this.components = new System.ComponentModel.Container();
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle();
this._dataGridView = new System.Windows.Forms.DataGridView();
this.removeDataGridViewCheckBoxColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
this.coverDataGridViewImageColumn = new System.Windows.Forms.DataGridViewImageColumn();
this.titleDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.authorsDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.miscDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.purchaseDateGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.gridEntryBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
this.btnRemoveBooks = new System.Windows.Forms.Button();
this.label1 = new System.Windows.Forms.Label();
((System.ComponentModel.ISupportInitialize)(this._dataGridView)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
this.SuspendLayout();
//
// _dataGridView
//
this._dataGridView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this._dataGridView.AutoGenerateColumns = false;
this._dataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this._dataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.removeDataGridViewCheckBoxColumn,
this.coverDataGridViewImageColumn,
this.titleDataGridViewTextBoxColumn,
this.authorsDataGridViewTextBoxColumn,
this.miscDataGridViewTextBoxColumn,
this.purchaseDateGridViewTextBoxColumn});
this._dataGridView.DataSource = this.gridEntryBindingSource;
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
dataGridViewCellStyle1.BackColor = System.Drawing.SystemColors.Window;
dataGridViewCellStyle1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
dataGridViewCellStyle1.ForeColor = System.Drawing.SystemColors.ControlText;
dataGridViewCellStyle1.SelectionBackColor = System.Drawing.SystemColors.Highlight;
dataGridViewCellStyle1.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
this._dataGridView.DefaultCellStyle = dataGridViewCellStyle1;
this._dataGridView.Location = new System.Drawing.Point(0, 0);
this._dataGridView.Name = "_dataGridView";
this._dataGridView.RowHeadersVisible = false;
this._dataGridView.RowTemplate.Height = 82;
this._dataGridView.Size = new System.Drawing.Size(730, 409);
this._dataGridView.TabIndex = 0;
//
// removeDataGridViewCheckBoxColumn
//
this.removeDataGridViewCheckBoxColumn.DataPropertyName = "Remove";
this.removeDataGridViewCheckBoxColumn.FalseValue = "False";
this.removeDataGridViewCheckBoxColumn.Frozen = true;
this.removeDataGridViewCheckBoxColumn.HeaderText = "Remove";
this.removeDataGridViewCheckBoxColumn.MinimumWidth = 80;
this.removeDataGridViewCheckBoxColumn.Name = "removeDataGridViewCheckBoxColumn";
this.removeDataGridViewCheckBoxColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
this.removeDataGridViewCheckBoxColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
this.removeDataGridViewCheckBoxColumn.TrueValue = "True";
this.removeDataGridViewCheckBoxColumn.Width = 80;
//
// coverDataGridViewImageColumn
//
this.coverDataGridViewImageColumn.DataPropertyName = "Cover";
this.coverDataGridViewImageColumn.HeaderText = "Cover";
this.coverDataGridViewImageColumn.MinimumWidth = 80;
this.coverDataGridViewImageColumn.Name = "coverDataGridViewImageColumn";
this.coverDataGridViewImageColumn.ReadOnly = true;
this.coverDataGridViewImageColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
this.coverDataGridViewImageColumn.Width = 80;
//
// titleDataGridViewTextBoxColumn
//
this.titleDataGridViewTextBoxColumn.DataPropertyName = "Title";
this.titleDataGridViewTextBoxColumn.HeaderText = "Title";
this.titleDataGridViewTextBoxColumn.Name = "titleDataGridViewTextBoxColumn";
this.titleDataGridViewTextBoxColumn.ReadOnly = true;
this.titleDataGridViewTextBoxColumn.Width = 200;
//
// authorsDataGridViewTextBoxColumn
//
this.authorsDataGridViewTextBoxColumn.DataPropertyName = "Authors";
this.authorsDataGridViewTextBoxColumn.HeaderText = "Authors";
this.authorsDataGridViewTextBoxColumn.Name = "authorsDataGridViewTextBoxColumn";
this.authorsDataGridViewTextBoxColumn.ReadOnly = true;
//
// miscDataGridViewTextBoxColumn
//
this.miscDataGridViewTextBoxColumn.DataPropertyName = "Misc";
this.miscDataGridViewTextBoxColumn.HeaderText = "Misc";
this.miscDataGridViewTextBoxColumn.Name = "miscDataGridViewTextBoxColumn";
this.miscDataGridViewTextBoxColumn.ReadOnly = true;
this.miscDataGridViewTextBoxColumn.Width = 150;
//
// purchaseDateGridViewTextBoxColumn
//
this.purchaseDateGridViewTextBoxColumn.DataPropertyName = "PurchaseDate";
this.purchaseDateGridViewTextBoxColumn.HeaderText = "Purchase Date";
this.purchaseDateGridViewTextBoxColumn.Name = "purchaseDateGridViewTextBoxColumn";
this.purchaseDateGridViewTextBoxColumn.ReadOnly = true;
this.purchaseDateGridViewTextBoxColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
//
// gridEntryBindingSource
//
this.gridEntryBindingSource.AllowNew = false;
this.gridEntryBindingSource.DataSource = typeof(LibationWinForms.Dialogs.RemovableGridEntry);
//
// btnRemoveBooks
//
this.btnRemoveBooks.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.btnRemoveBooks.Location = new System.Drawing.Point(500, 419);
this.btnRemoveBooks.Name = "btnRemoveBooks";
this.btnRemoveBooks.Size = new System.Drawing.Size(218, 23);
this.btnRemoveBooks.TabIndex = 1;
this.btnRemoveBooks.Text = "Remove Selected Books from Libation";
this.btnRemoveBooks.UseVisualStyleBackColor = true;
this.btnRemoveBooks.Click += new System.EventHandler(this.btnRemoveBooks_Click);
//
// label1
//
this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(12, 423);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(178, 15);
this.label1.TabIndex = 2;
this.label1.Text = "{0} book{1} selected for removal.";
//
// RemoveBooksDialog
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(730, 450);
this.Controls.Add(this.label1);
this.Controls.Add(this.btnRemoveBooks);
this.Controls.Add(this._dataGridView);
this.Name = "RemoveBooksDialog";
this.Text = "Remove Books from Libation's Database";
this.Shown += new System.EventHandler(this.RemoveBooksDialog_Shown);
((System.ComponentModel.ISupportInitialize)(this._dataGridView)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.DataGridView _dataGridView;
private LibationWinForms.GridView.SyncBindingSource gridEntryBindingSource;
private System.Windows.Forms.Button btnRemoveBooks;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.DataGridViewCheckBoxColumn removeDataGridViewCheckBoxColumn;
private System.Windows.Forms.DataGridViewImageColumn coverDataGridViewImageColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn titleDataGridViewTextBoxColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn authorsDataGridViewTextBoxColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscDataGridViewTextBoxColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGridViewTextBoxColumn;
}
}

View File

@@ -1,149 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Linq;
using System.Windows.Forms;
using ApplicationServices;
using AudibleUtilities;
using DataLayer;
using Dinah.Core.DataBinding;
using LibationFileManager;
using LibationWinForms.Login;
namespace LibationWinForms.Dialogs
{
public partial class RemoveBooksDialog : Form
{
private Account[] _accounts { get; }
private List<LibraryBook> _libraryBooks { get; }
private SortableBindingList<RemovableGridEntry> _removableGridEntries { get; }
private string _labelFormat { get; }
private int SelectedCount => SelectedEntries?.Count() ?? 0;
private IEnumerable<RemovableGridEntry> SelectedEntries => _removableGridEntries?.Where(b => b.Remove);
public RemoveBooksDialog(params Account[] accounts)
{
_libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
_accounts = accounts;
InitializeComponent();
this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
_labelFormat = label1.Text;
_dataGridView.CellContentClick += (_, _) => _dataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit);
_dataGridView.CellValueChanged += (_, _) => UpdateSelection();
_dataGridView.BindingContextChanged += _dataGridView_BindingContextChanged;
var orderedGridEntries = _libraryBooks
.Select(lb => new RemovableGridEntry(lb))
.OrderByDescending(ge => (DateTime)ge.GetMemberValue(nameof(ge.PurchaseDate)))
.ToList();
_removableGridEntries = new SortableBindingList<RemovableGridEntry>(orderedGridEntries);
gridEntryBindingSource.DataSource = _removableGridEntries;
_dataGridView.Enabled = false;
this.SetLibationIcon();
}
private void _dataGridView_BindingContextChanged(object sender, EventArgs e)
{
_dataGridView.Sort(_dataGridView.Columns[0], ListSortDirection.Descending);
UpdateSelection();
}
private async void RemoveBooksDialog_Shown(object sender, EventArgs e)
{
if (_accounts is null || _accounts.Length == 0)
return;
try
{
var removedBooks = await LibraryCommands.FindInactiveBooks(WinformLoginChoiceEager.ApiExtendedFunc, _libraryBooks, _accounts);
var removable = _removableGridEntries.Where(rge => removedBooks.Any(rb => rb.Book.AudibleProductId == rge.AudibleProductId)).ToList();
if (!removable.Any())
return;
foreach (var r in removable)
r.Remove = true;
UpdateSelection();
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
"Error scanning library. You may still manually select books to remove from Libation's library.",
"Error scanning library",
ex);
}
finally
{
_dataGridView.Enabled = true;
}
}
private async void btnRemoveBooks_Click(object sender, EventArgs e)
{
var selectedBooks = SelectedEntries.ToList();
if (selectedBooks.Count == 0)
return;
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var result = MessageBoxLib.ShowConfirmationDialog(
libraryBooks,
$"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
if (result != DialogResult.Yes)
return;
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
foreach (var rEntry in selectedBooks)
_removableGridEntries.Remove(rEntry);
UpdateSelection();
}
private void UpdateSelection()
{
var selectedCount = SelectedCount;
label1.Text = string.Format(_labelFormat, selectedCount, selectedCount != 1 ? "s" : string.Empty);
btnRemoveBooks.Enabled = selectedCount > 0;
}
}
internal class RemovableGridEntry : GridView.LibraryBookEntry
{
private bool _remove = false;
public RemovableGridEntry(LibraryBook libraryBook) : base(libraryBook) { }
public bool Remove
{
get
{
return _remove;
}
set
{
_remove = value;
NotifyPropertyChanged();
}
}
public override object GetMemberValue(string memberName)
{
if (memberName == nameof(Remove))
return Remove;
return base.GetMemberValue(memberName);
}
}
}

View File

@@ -1,63 +0,0 @@
<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=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="gridEntryBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
</root>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@ namespace LibationWinForms.Dialogs
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
this.createCueSheetCbox.Text = desc(nameof(config.CreateCueSheet));
this.downloadCoverArtCbox.Text = desc(nameof(config.DownloadCoverArt));
this.saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder));
booksSelectControl.SetSearchTitle("books location");
booksSelectControl.SetDirectoryItems(
@@ -59,6 +60,8 @@ namespace LibationWinForms.Dialogs
"Books");
booksSelectControl.SelectDirectory(config.Books);
saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder;
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
createCueSheetCbox.Checked = config.CreateCueSheet;
retainAaxFileCbox.Checked = config.RetainAaxFile;
@@ -186,6 +189,8 @@ namespace LibationWinForms.Dialogs
MessageBoxLib.VerboseLoggingWarning_ShowIfTrue();
}
config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked;
config.AllowLibationFixup = allowLibationFixupCbox.Checked;
config.CreateCueSheet = createCueSheetCbox.Checked;
config.RetainAaxFile = retainAaxFileCbox.Checked;

View File

@@ -74,6 +74,8 @@
this.panel1 = new System.Windows.Forms.Panel();
this.productsDisplay = new LibationWinForms.GridView.ProductsDisplay();
this.toggleQueueHideBtn = new System.Windows.Forms.Button();
this.doneRemovingBtn = new System.Windows.Forms.Button();
this.removeBooksBtn = new System.Windows.Forms.Button();
this.processBookQueue1 = new LibationWinForms.ProcessQueue.ProcessQueueControl();
this.menuStrip1.SuspendLayout();
this.statusStrip1.SuspendLayout();
@@ -98,7 +100,7 @@
// filterBtn
//
this.filterBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
this.filterBtn.Location = new System.Drawing.Point(916, 3);
this.filterBtn.Location = new System.Drawing.Point(892, 3);
this.filterBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.filterBtn.Name = "filterBtn";
this.filterBtn.Size = new System.Drawing.Size(88, 27);
@@ -111,10 +113,11 @@
//
this.filterSearchTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.filterSearchTb.Location = new System.Drawing.Point(196, 7);
this.filterSearchTb.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.filterSearchTb.Location = new System.Drawing.Point(195, 5);
this.filterSearchTb.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.filterSearchTb.Name = "filterSearchTb";
this.filterSearchTb.Size = new System.Drawing.Size(712, 23);
this.filterSearchTb.Size = new System.Drawing.Size(689, 25);
this.filterSearchTb.TabIndex = 1;
this.filterSearchTb.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.filterSearchTb_KeyPress);
//
@@ -132,7 +135,7 @@
this.menuStrip1.Location = new System.Drawing.Point(0, 0);
this.menuStrip1.Name = "menuStrip1";
this.menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2);
this.menuStrip1.Size = new System.Drawing.Size(1061, 24);
this.menuStrip1.Size = new System.Drawing.Size(1037, 24);
this.menuStrip1.TabIndex = 0;
this.menuStrip1.Text = "menuStrip1";
//
@@ -396,7 +399,8 @@
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);
this.statusStrip1.Size = new System.Drawing.Size(1061, 22);
this.statusStrip1.ShowItemToolTips = true;
this.statusStrip1.Size = new System.Drawing.Size(1037, 22);
this.statusStrip1.TabIndex = 6;
this.statusStrip1.Text = "statusStrip1";
//
@@ -410,7 +414,7 @@
// springLbl
//
this.springLbl.Name = "springLbl";
this.springLbl.Size = new System.Drawing.Size(547, 17);
this.springLbl.Size = new System.Drawing.Size(523, 17);
this.springLbl.Spring = true;
//
// backupsCountsLbl
@@ -440,6 +444,7 @@
// splitContainer1
//
this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill;
this.splitContainer1.FixedPanel = System.Windows.Forms.FixedPanel.Panel2;
this.splitContainer1.Location = new System.Drawing.Point(0, 0);
this.splitContainer1.Name = "splitContainer1";
//
@@ -453,7 +458,7 @@
//
this.splitContainer1.Panel2.Controls.Add(this.processBookQueue1);
this.splitContainer1.Size = new System.Drawing.Size(1463, 640);
this.splitContainer1.SplitterDistance = 1061;
this.splitContainer1.SplitterDistance = 1037;
this.splitContainer1.SplitterWidth = 8;
this.splitContainer1.TabIndex = 7;
//
@@ -462,6 +467,8 @@
this.panel1.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
this.panel1.Controls.Add(this.productsDisplay);
this.panel1.Controls.Add(this.toggleQueueHideBtn);
this.panel1.Controls.Add(this.doneRemovingBtn);
this.panel1.Controls.Add(this.removeBooksBtn);
this.panel1.Controls.Add(this.addQuickFilterBtn);
this.panel1.Controls.Add(this.filterHelpBtn);
this.panel1.Controls.Add(this.filterSearchTb);
@@ -470,7 +477,7 @@
this.panel1.Location = new System.Drawing.Point(0, 24);
this.panel1.Margin = new System.Windows.Forms.Padding(0);
this.panel1.Name = "panel1";
this.panel1.Size = new System.Drawing.Size(1061, 594);
this.panel1.Size = new System.Drawing.Size(1037, 594);
this.panel1.TabIndex = 7;
//
// productsDisplay
@@ -482,16 +489,17 @@
this.productsDisplay.Location = new System.Drawing.Point(15, 36);
this.productsDisplay.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.productsDisplay.Name = "productsDisplay";
this.productsDisplay.Size = new System.Drawing.Size(1031, 555);
this.productsDisplay.Size = new System.Drawing.Size(1007, 555);
this.productsDisplay.TabIndex = 9;
this.productsDisplay.VisibleCountChanged += new System.EventHandler<int>(this.productsDisplay_VisibleCountChanged);
this.productsDisplay.RemovableCountChanged += new System.EventHandler<int>(this.productsDisplay_RemovableCountChanged);
this.productsDisplay.LiberateClicked += new System.EventHandler<DataLayer.LibraryBook>(this.ProductsDisplay_LiberateClicked);
this.productsDisplay.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded);
//
// toggleQueueHideBtn
//
this.toggleQueueHideBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
this.toggleQueueHideBtn.Location = new System.Drawing.Point(1013, 3);
this.toggleQueueHideBtn.Location = new System.Drawing.Point(989, 3);
this.toggleQueueHideBtn.Margin = new System.Windows.Forms.Padding(4, 3, 15, 3);
this.toggleQueueHideBtn.Name = "toggleQueueHideBtn";
this.toggleQueueHideBtn.Size = new System.Drawing.Size(33, 27);
@@ -500,6 +508,31 @@
this.toggleQueueHideBtn.UseVisualStyleBackColor = true;
this.toggleQueueHideBtn.Click += new System.EventHandler(this.ToggleQueueHideBtn_Click);
//
// doneRemovingBtn
//
this.doneRemovingBtn.Location = new System.Drawing.Point(406, 3);
this.doneRemovingBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.doneRemovingBtn.Name = "doneRemovingBtn";
this.doneRemovingBtn.Size = new System.Drawing.Size(145, 27);
this.doneRemovingBtn.TabIndex = 4;
this.doneRemovingBtn.Text = "Done Removing Books";
this.doneRemovingBtn.UseVisualStyleBackColor = true;
this.doneRemovingBtn.Visible = false;
this.doneRemovingBtn.Click += new System.EventHandler(this.doneRemovingBtn_Click);
//
// removeBooksBtn
//
this.removeBooksBtn.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
this.removeBooksBtn.Location = new System.Drawing.Point(206, 3);
this.removeBooksBtn.Margin = new System.Windows.Forms.Padding(15, 3, 4, 3);
this.removeBooksBtn.Name = "removeBooksBtn";
this.removeBooksBtn.Size = new System.Drawing.Size(192, 27);
this.removeBooksBtn.TabIndex = 4;
this.removeBooksBtn.Text = "Remove # Books from Libation";
this.removeBooksBtn.UseVisualStyleBackColor = true;
this.removeBooksBtn.Visible = false;
this.removeBooksBtn.Click += new System.EventHandler(this.removeBooksBtn_Click);
//
// processBookQueue1
//
this.processBookQueue1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
@@ -507,7 +540,7 @@
this.processBookQueue1.Location = new System.Drawing.Point(0, 0);
this.processBookQueue1.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4);
this.processBookQueue1.Name = "processBookQueue1";
this.processBookQueue1.Size = new System.Drawing.Size(394, 640);
this.processBookQueue1.Size = new System.Drawing.Size(418, 640);
this.processBookQueue1.TabIndex = 0;
//
// Form1
@@ -584,5 +617,7 @@
private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.Button toggleQueueHideBtn;
private LibationWinForms.GridView.ProductsDisplay productsDisplay;
private System.Windows.Forms.Button removeBooksBtn;
private System.Windows.Forms.Button doneRemovingBtn;
}
}

View File

@@ -17,7 +17,9 @@ namespace LibationWinForms
processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut;
var coppalseState = Configuration.Instance.GetNonString<bool>(nameof(splitContainer1.Panel2Collapsed));
WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth;
int width = this.Width;
SetQueueCollapseState(coppalseState);
this.Width = width;
}
private void ProductsDisplay_LiberateClicked(object sender, LibraryBook e)

View File

@@ -0,0 +1,92 @@
using AudibleUtilities;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForms
{
public partial class Form1
{
public void Configure_RemoveBooks() { }
private async void removeBooksBtn_Click(object sender, EventArgs e)
=> await productsDisplay.RemoveCheckedBooksAsync();
private void doneRemovingBtn_Click(object sender, EventArgs e)
{
removeBooksBtn.Visible = false;
doneRemovingBtn.Visible = false;
productsDisplay.CloseRemoveBooksColumn();
//Restore the filter
filterSearchTb.Enabled = true;
filterSearchTb.Visible = true;
performFilter(filterSearchTb.Text);
}
private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e)
{
// if 0 accounts, this will not be visible
// if 1 account, run scanLibrariesRemovedBooks() on this account
// if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks()
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll();
if (accounts.Count != 1)
return;
var firstAccount = accounts.Single();
scanLibrariesRemovedBooks(firstAccount);
}
// selectively remove books from all accounts
private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var allAccounts = persister.AccountsSettings.GetAll();
scanLibrariesRemovedBooks(allAccounts.ToArray());
}
// selectively remove books from some accounts
private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var scanAccountsDialog = new ScanAccountsDialog();
if (scanAccountsDialog.ShowDialog() != DialogResult.OK)
return;
if (!scanAccountsDialog.CheckedAccounts.Any())
return;
scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray());
}
private async void scanLibrariesRemovedBooks(params Account[] accounts)
{
//This action is meant to operate on the entire library.
//For removing books within a filter set, use
//Visible Books > Remove from library
filterSearchTb.Enabled = false;
filterSearchTb.Visible = false;
productsDisplay.Filter(null);
removeBooksBtn.Visible = true;
doneRemovingBtn.Visible = true;
await productsDisplay.ScanAndRemoveBooksAsync(accounts);
}
private void productsDisplay_RemovableCountChanged(object sender, int removeCount)
{
removeBooksBtn.Text = removeCount switch
{
1 => "Remove 1 Book from Libation",
_ => $"Remove {removeCount} Books from Libation"
};
}
}
}

View File

@@ -67,50 +67,7 @@ namespace LibationWinForms
return;
await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts);
}
private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e)
{
// if 0 accounts, this will not be visible
// if 1 account, run scanLibrariesRemovedBooks() on this account
// if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks()
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll();
if (accounts.Count != 1)
return;
var firstAccount = accounts.Single();
scanLibrariesRemovedBooks(firstAccount);
}
// selectively remove books from all accounts
private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var allAccounts = persister.AccountsSettings.GetAll();
scanLibrariesRemovedBooks(allAccounts.ToArray());
}
// selectively remove books from some accounts
private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e)
{
using var scanAccountsDialog = new ScanAccountsDialog();
if (scanAccountsDialog.ShowDialog() != DialogResult.OK)
return;
if (!scanAccountsDialog.CheckedAccounts.Any())
return;
scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray());
}
private void scanLibrariesRemovedBooks(params Account[] accounts)
{
using var dialog = new RemoveBooksDialog(accounts);
dialog.ShowDialog();
}
}
private async Task scanLibrariesAsync(IEnumerable<Account> accounts) => await scanLibrariesAsync(accounts.ToArray());
private async Task scanLibrariesAsync(params Account[] accounts)

View File

@@ -14,6 +14,9 @@ namespace LibationWinForms
private void LibraryCommands_ScanBegin(object sender, int accountsLength)
{
removeLibraryBooksToolStripMenuItem.Enabled = false;
removeAllAccountsToolStripMenuItem.Enabled = false;
removeSomeAccountsToolStripMenuItem.Enabled = false;
scanLibraryToolStripMenuItem.Enabled = false;
scanLibraryOfAllAccountsToolStripMenuItem.Enabled = false;
scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = false;
@@ -27,6 +30,9 @@ namespace LibationWinForms
private void LibraryCommands_ScanEnd(object sender, EventArgs e)
{
removeLibraryBooksToolStripMenuItem.Enabled = true;
removeAllAccountsToolStripMenuItem.Enabled = true;
removeSomeAccountsToolStripMenuItem.Enabled = true;
scanLibraryToolStripMenuItem.Enabled = true;
scanLibraryOfAllAccountsToolStripMenuItem.Enabled = true;
scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = true;

View File

@@ -20,7 +20,7 @@ namespace LibationWinForms
// Before calling anything else, including subscribing to events, ensure database exists. If we wait and let it happen lazily, race conditions and errors are likely during new installs
using var _ = DbContexts.GetContext();
this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance);
this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
// this looks like a perfect opportunity to refactor per below.
@@ -44,6 +44,7 @@ namespace LibationWinForms
Configure_VisibleBooks();
Configure_QuickFilters();
Configure_ScanManual();
Configure_RemoveBooks();
Configure_Liberate();
Configure_Export();
Configure_Settings();

View File

@@ -93,6 +93,12 @@
<metadata name="toggleQueueHideBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="doneRemovingBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="removeBooksBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="processBookQueue1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>

View File

@@ -12,6 +12,12 @@ using System.Linq;
namespace LibationWinForms.GridView
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
{
@@ -24,6 +30,9 @@ namespace LibationWinForms.GridView
#region Model properties exposed to the view
protected RemoveStatus _remove = RemoveStatus.NotRemoved;
public abstract RemoveStatus Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public Image Cover
{

View File

@@ -19,18 +19,19 @@ namespace LibationWinForms.GridView
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
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)
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (value is LiberateButtonStatus status)
{
if (status.BookStatus is LiberatedStatus.Error)
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null)
{
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR;
}
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (status.IsSeries)
{
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus: Properties.Resources.plus, cellBounds);
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus : Properties.Resources.plus, cellBounds);
ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand";
}
@@ -48,7 +49,7 @@ namespace LibationWinForms.GridView
private static (string mouseoverText, Bitmap buttonImage) GetLiberateDisplay(LiberatedStatus liberatedStatus, LiberatedStatus? pdfStatus)
{
if (liberatedStatus == LiberatedStatus.Error)
return ("Book downloaded ERROR", SystemIcons.Error.ToBitmap());
return ("Book downloaded ERROR", Properties.Resources.error);
(string libState, string image_lib) = liberatedStatus switch
{

View File

@@ -20,6 +20,20 @@ namespace LibationWinForms.GridView
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
Parent?.ChildRemoveUpdate();
NotifyPropertyChanged();
}
}
public override LiberateButtonStatus Liberate
{
get

View File

@@ -39,11 +39,12 @@
this.productsGrid.Name = "productsGrid";
this.productsGrid.Size = new System.Drawing.Size(1510, 380);
this.productsGrid.TabIndex = 0;
this.productsGrid.VisibleCountChanged += new System.EventHandler<int>(this.productsGrid_VisibleCountChanged);
this.productsGrid.LiberateClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked);
this.productsGrid.CoverClicked += new LibationWinForms.GridView.GridEntryClickedEventHandler(this.productsGrid_CoverClicked);
this.productsGrid.DetailsClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked);
this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.GridEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked);
this.productsGrid.VisibleCountChanged += new System.EventHandler<int>(this.productsGrid_VisibleCountChanged);
this.productsGrid.RemovableCountChanged += new System.EventHandler(this.productsGrid_RemovableCountChanged);
//
// ProductsDisplay
//

View File

@@ -1,4 +1,5 @@
using ApplicationServices;
using AudibleUtilities;
using DataLayer;
using FileLiberator;
using LibationFileManager;
@@ -16,6 +17,7 @@ namespace LibationWinForms.GridView
{
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event EventHandler<int> RemovableCountChanged;
public event EventHandler<LibraryBook> LiberateClicked;
public event EventHandler InitialLoaded;
@@ -80,7 +82,69 @@ namespace LibationWinForms.GridView
#endregion
#region UI display functions
#region Scan and Remove Books
public void CloseRemoveBooksColumn()
=> productsGrid.RemoveColumnVisible = false;
public async Task RemoveCheckedBooksAsync()
{
var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is RemoveStatus.Removed).ToList();
if (selectedBooks.Count == 0)
return;
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var result = MessageBoxLib.ShowConfirmationDialog(
libraryBooks,
$"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?",
"Remove books from Libation?");
if (result != DialogResult.Yes)
return;
productsGrid.RemoveBooks(selectedBooks);
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
}
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
{
RemovableCountChanged?.Invoke(this, 0);
productsGrid.RemoveColumnVisible = true;
try
{
if (accounts is null || accounts.Length == 0)
return;
var allBooks = productsGrid.GetAllBookEntries();
var lib = allBooks
.Select(lbe => lbe.LibraryBook)
.Where(lb => !lb.Book.HasLiberated());
var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts);
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
foreach (var r in removable)
r.Remove = RemoveStatus.Removed;
productsGrid_RemovableCountChanged(this, null);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
"Error scanning library. You may still manually select books to remove from Libation's library.",
"Error scanning library",
ex);
}
}
#endregion
#region UI display functions
public void Display()
{
@@ -123,7 +187,13 @@ namespace LibationWinForms.GridView
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
{
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}
private void productsGrid_RemovableCountChanged(object sender, EventArgs e)
{
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is RemoveStatus.Removed));
}
}
}

View File

@@ -29,8 +29,9 @@
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle();
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle();
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.removeGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
this.liberateGVColumn = new LibationWinForms.GridView.LiberateDataGridViewImageButtonColumn();
this.coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn();
this.titleGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
@@ -60,41 +61,56 @@
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.liberateGVColumn,
this.coverGVColumn,
this.titleGVColumn,
this.authorsGVColumn,
this.narratorsGVColumn,
this.lengthGVColumn,
this.seriesGVColumn,
this.descriptionGVColumn,
this.categoryGVColumn,
this.productRatingGVColumn,
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.removeGVColumn,
this.liberateGVColumn,
this.coverGVColumn,
this.titleGVColumn,
this.authorsGVColumn,
this.narratorsGVColumn,
this.lengthGVColumn,
this.seriesGVColumn,
this.descriptionGVColumn,
this.categoryGVColumn,
this.productRatingGVColumn,
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.gridEntryDataGridView.ContextMenuStrip = this.contextMenuStrip1;
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
dataGridViewCellStyle2.BackColor = System.Drawing.SystemColors.Window;
dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
dataGridViewCellStyle2.ForeColor = System.Drawing.SystemColors.ControlText;
dataGridViewCellStyle2.SelectionBackColor = System.Drawing.SystemColors.Highlight;
dataGridViewCellStyle2.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
dataGridViewCellStyle2.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle2;
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
dataGridViewCellStyle1.BackColor = System.Drawing.SystemColors.Window;
dataGridViewCellStyle1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
dataGridViewCellStyle1.ForeColor = System.Drawing.SystemColors.ControlText;
dataGridViewCellStyle1.SelectionBackColor = System.Drawing.SystemColors.Highlight;
dataGridViewCellStyle1.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle1;
this.gridEntryDataGridView.Dock = System.Windows.Forms.DockStyle.Fill;
this.gridEntryDataGridView.Location = new System.Drawing.Point(0, 0);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.ReadOnly = true;
this.gridEntryDataGridView.RowHeadersVisible = false;
this.gridEntryDataGridView.RowTemplate.Height = 82;
this.gridEntryDataGridView.Size = new System.Drawing.Size(1510, 380);
this.gridEntryDataGridView.Size = new System.Drawing.Size(1570, 380);
this.gridEntryDataGridView.TabIndex = 0;
this.gridEntryDataGridView.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView_CellContentClick);
this.gridEntryDataGridView.CellToolTipTextNeeded += new System.Windows.Forms.DataGridViewCellToolTipTextNeededEventHandler(this.gridEntryDataGridView_CellToolTipTextNeeded);
//
// removeGVColumn
//
this.removeGVColumn.DataPropertyName = "Remove";
this.removeGVColumn.FalseValue = "";
this.removeGVColumn.Frozen = true;
this.removeGVColumn.HeaderText = "Remove";
this.removeGVColumn.IndeterminateValue = "";
this.removeGVColumn.MinimumWidth = 60;
this.removeGVColumn.Name = "removeGVColumn";
this.removeGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False;
this.removeGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
this.removeGVColumn.ThreeState = true;
this.removeGVColumn.TrueValue = "";
this.removeGVColumn.Width = 60;
//
// liberateGVColumn
//
this.liberateGVColumn.DataPropertyName = "Liberate";
@@ -223,7 +239,7 @@
this.AutoScroll = true;
this.Controls.Add(this.gridEntryDataGridView);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(1510, 380);
this.Size = new System.Drawing.Size(1570, 380);
this.Load += new System.EventHandler(this.ProductsGrid_Load);
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).EndInit();
@@ -235,6 +251,8 @@
#endregion
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.ContextMenuStrip contextMenuStrip1;
private SyncBindingSource syncBindingSource;
private System.Windows.Forms.DataGridViewCheckBoxColumn removeGVColumn;
private LiberateDataGridViewImageButtonColumn liberateGVColumn;
private System.Windows.Forms.DataGridViewImageColumn coverGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn titleGVColumn;
@@ -249,6 +267,5 @@
private System.Windows.Forms.DataGridViewTextBoxColumn myRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
private SyncBindingSource syncBindingSource;
}
}

View File

@@ -23,12 +23,15 @@ namespace LibationWinForms.GridView
public event LibraryBookEntryClickedEventHandler DetailsClicked;
public event GridEntryRectangleClickedEventHandler DescriptionClicked;
public new event EventHandler<ScrollEventArgs> Scroll;
public event EventHandler RemovableCountChanged;
private GridEntryBindingList bindingList;
internal IEnumerable<LibraryBook> GetVisibleBooks()
=> bindingList
.BookEntries()
.Select(lbe => lbe.LibraryBook);
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> bindingList.AllItems().BookEntries();
public ProductsGrid()
{
@@ -81,6 +84,12 @@ namespace LibationWinForms.GridView
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(sEntry);
}
if (e.ColumnIndex == removeGVColumn.Index)
{
gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit);
RemovableCountChanged?.Invoke(this, EventArgs.Empty);
}
}
private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);
@@ -89,13 +98,33 @@ namespace LibationWinForms.GridView
#region UI display functions
internal bool RemoveColumnVisible
{
get => removeGVColumn.Visible;
set
{
if (value)
{
foreach (var book in bindingList.AllItems())
book.Remove = RemoveStatus.NotRemoved;
}
removeGVColumn.Visible = value;
}
}
internal void BindToGrid(List<LibraryBook> dbBooks)
{
var geList = dbBooks.Where(lb => lb.Book.IsProduct()).Select(b => new LibraryBookEntry(b)).Cast<GridEntry>().ToList();
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
.Cast<GridEntry>()
.ToList();
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);
@@ -127,6 +156,7 @@ namespace LibationWinForms.GridView
var allEntries = bindingList.AllItems().BookEntries();
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes();
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
@@ -134,7 +164,8 @@ namespace LibationWinForms.GridView
if (libraryBook.Book.IsProduct())
AddOrUpdateBook(libraryBook, existingEntry);
else if(libraryBook.Book.IsEpisodeChild())
else if(parentedEpisodes.Any(lb => lb == libraryBook))
//Only try to add or update is this LibraryBook is a know child of a parent
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
@@ -153,6 +184,11 @@ namespace LibationWinForms.GridView
.BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
RemoveBooks(removedBooks);
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
{
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
{
@@ -188,6 +224,7 @@ namespace LibationWinForms.GridView
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
if (seriesEntry is null)
@@ -197,13 +234,14 @@ namespace LibationWinForms.GridView
if (seriesBook is null)
{
//This should be impossible because the importer ensures every episode has a parent.
var ex = new ApplicationException($"Episode's series parent not found in database.");
var seriesLinks = string.Join("\r\n", episodeBook.Book.SeriesLink?.Select(sb => $"{nameof(sb.Series.Name)}={sb.Series.Name}, {nameof(sb.Series.AudibleSeriesId)}={sb.Series.AudibleSeriesId}"));
Serilog.Log.Logger.Error(ex, "Episode={episodeBook}, Series: {seriesLinks}", episodeBook, seriesLinks);
throw ex;
//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, episodeBook);
seriesEntries.Add(seriesEntry);
@@ -312,6 +350,14 @@ namespace LibationWinForms.GridView
column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index);
}
//Remove column is always first;
removeGVColumn.DisplayIndex = 0;
removeGVColumn.Visible = false;
removeGVColumn.ValueType = typeof(RemoveStatus);
removeGVColumn.FalseValue = RemoveStatus.NotRemoved;
removeGVColumn.TrueValue = RemoveStatus.Removed;
removeGVColumn.IndeterminateValue = RemoveStatus.SomeRemoved;
}
private void HideMenuItem_Click(object sender, EventArgs e)

View File

@@ -57,16 +57,13 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="removeGVColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="contextMenuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>171, 17</value>
</metadata>
<metadata name="syncBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="bindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>326, 17</value>
</metadata>
<metadata name="bindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>326, 17</value>
</metadata>
</root>

View File

@@ -24,12 +24,20 @@ namespace LibationWinForms.GridView
{
if (seriesEpisode.Book.SeriesLink is null) return null;
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return gridEntries.SeriesEntries().FirstOrDefault(
lb =>
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId));
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return gridEntries.SeriesEntries().FirstOrDefault(
lb =>
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
}
#nullable disable

View File

@@ -13,7 +13,43 @@ namespace LibationWinForms.GridView
[Browsable(false)] public List<LibraryBookEntry> Children { get; }
[Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove is RemoveStatus.Removed);
if (removeCount == 0)
_remove = RemoveStatus.NotRemoved;
else if (removeCount == Children.Count)
_remove = RemoveStatus.Removed;
else
_remove = RemoveStatus.SomeRemoved;
NotifyPropertyChanged(nameof(Remove));
}
#region Model properties exposed to the view
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
NotifyPropertyChanged();
}
}
public override LiberateButtonStatus Liberate { get; }
public override string DisplayTags { get; } = string.Empty;

View File

@@ -130,6 +130,16 @@ namespace LibationWinForms.Properties {
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap error {
get {
object obj = ResourceManager.GetObject("error", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>

View File

@@ -139,6 +139,9 @@
<data name="edit_tags_50x50" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\edit-tags-50x50.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="error" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\error.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="import_16x16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\import_16x16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 B

After

Width:  |  Height:  |  Size: 6.0 KiB