Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09dc5e9846 | ||
|
|
cf35a87d85 | ||
|
|
9f25f619a8 | ||
|
|
7e989c730c | ||
|
|
0926e86956 | ||
|
|
75967730fd | ||
|
|
a3be3e354f | ||
|
|
58c52196f1 | ||
|
|
b7b49a60cf | ||
|
|
fa195483d6 | ||
|
|
2341f6ea3b | ||
|
|
ffe0f0730d | ||
|
|
23b512910e | ||
|
|
b1c624b104 | ||
|
|
fe35be6682 | ||
|
|
2d3eb29bd5 | ||
|
|
26f0ff62df | ||
|
|
5e145846bd | ||
|
|
1ae5f99bf0 | ||
|
|
984119c7ee | ||
|
|
f8f5eac109 | ||
|
|
4111d5fa48 | ||
|
|
2eca9056b9 | ||
|
|
60e96572ff | ||
|
|
52193933b2 | ||
|
|
7bcabdda38 | ||
|
|
d993941c4d | ||
|
|
b447bff9a6 | ||
|
|
73cb5ffba4 |
@@ -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();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<Version>7.11.0.1</Version>
|
||||
<Version>8.0.0.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
199
Source/AudibleUtilities/Mkb79Auth.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ]
|
||||
@@ -1,9 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Hoopla
|
||||
{
|
||||
public class temp
|
||||
{
|
||||
// placeholder
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
1951
Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs
generated
@@ -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;
|
||||
|
||||
57
Source/LibationWinForms/Form1.Designer.cs
generated
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
92
Source/LibationWinForms/Form1.RemoveBooks.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
Source/LibationWinForms/Resources/Stoplight1 with pdf.psd
Normal file
BIN
Source/LibationWinForms/Resources/Stoplight1.psd
Normal file
BIN
Source/LibationWinForms/Resources/error.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 689 B After Width: | Height: | Size: 6.0 KiB |