Compare commits

...

42 Commits

Author SHA1 Message Date
Robert McRackan
a72c3f069b ver # 2021-07-29 14:56:38 -04:00
Robert McRackan
1fcacb9cfb Much faster for grid refresh 2021-07-29 14:55:48 -04:00
Robert McRackan
a3542c53e2 Remove duplicate logic 2021-07-29 11:32:16 -04:00
Robert McRackan
9e44a95ba2 Double buffer grid 2021-07-29 10:57:55 -04:00
Robert McRackan
204e77008b TransitionalFileLocator to trust Book values, not hit db directly 2021-07-29 10:20:27 -04:00
Robert McRackan
621fb68cd8 _2faCodeDialog login debug 2021-07-29 08:40:03 -04:00
Robert McRackan
0c265a9010 Centralize audio/aaxc files GetPath and Exists into temp TransitionalFileLocator 2021-07-29 07:50:37 -04:00
Robert McRackan
d4fbb03577 Login debugging 2021-07-29 07:39:14 -04:00
Robert McRackan
69a7ab5b0c tiny changes 2021-07-28 18:04:39 -04:00
Robert McRackan
53a46b5dfc Pull non-presentation logic out of main form 2021-07-28 17:20:16 -04:00
Robert McRackan
fb3126b0c6 ver # 2021-07-28 16:05:27 -04:00
Robert McRackan
5c6b5c0af2 Populate new values for book tracking state. Not using them yet, but getting much closer 2021-07-28 16:05:00 -04:00
Robert McRackan
8de8e50829 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-07-28 14:54:34 -04:00
rmcrackan
5d15d6c2c7 Merge pull request #75 from Mbucari/master
Fixed race condition.
2021-07-28 14:54:19 -04:00
Robert McRackan
85c18c8334 NoTracking() to simplify confusing EF Core state. Tracking is now only used during mass import, not in UI 2021-07-28 14:51:35 -04:00
Michael Bucari-Tovo
9de85b649b Fixed race condition. 2021-07-28 12:25:05 -06:00
Robert McRackan
3c1db55a95 tiny bug fix 2021-07-28 10:38:16 -04:00
Robert McRackan
4e6011711a Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-07-28 10:02:27 -04:00
rmcrackan
1440b3fcf6 Merge pull request #74 from Mbucari/master
Added Convert all M4b to Mp3 action.
2021-07-28 10:02:10 -04:00
Robert McRackan
f2f0725c68 comment out until after vacation 2021-07-28 09:58:09 -04:00
Robert McRackan
75f1d987fc Next iterative step toward replacing live scanning with db state. FilePaths.json => db 2021-07-28 09:40:27 -04:00
Mbucari
de8589fb84 Update ProcessorAutomationController.cs 2021-07-28 00:34:31 -06:00
Michael Bucari-Tovo
54ceba816a Minor refactoring. 2021-07-27 22:50:50 -06:00
Michael Bucari-Tovo
05d52e64e5 Added Convert all M4b to Mp3 action. 2021-07-27 22:20:38 -06:00
Robert McRackan
5c6bf300c6 Start laying the ground work to transition from background file scan => saved liberated-status state 2021-07-27 14:55:44 -04:00
rmcrackan
10ff95161b Merge pull request #73 from Mbucari/master
Removed redundant logging
2021-07-27 13:43:38 -04:00
Michael Bucari-Tovo
112671cf9f Merge branch 'master' of https://github.com/Mbucari/Libation 2021-07-27 11:40:15 -06:00
Michael Bucari-Tovo
1a37b2346e Logging is redundant because download license is logged in the api call. 2021-07-27 11:40:13 -06:00
Robert McRackan
54cceba4e3 New background file watcher for file location 2021-07-27 13:23:26 -04:00
rmcrackan
1502936cd0 Merge pull request #72 from Mbucari/master
Added a background file system scanner.
2021-07-27 13:20:33 -04:00
Michael Bucari-Tovo
f06b04ede4 Fixed possible race condition. 2021-07-27 10:26:15 -06:00
Michael Bucari-Tovo
406aea6ead More thread safety. 2021-07-27 10:23:34 -06:00
Michael Bucari-Tovo
5f8c40962a Merge branch 'master' of https://github.com/Mbucari/Libation 2021-07-27 10:21:38 -06:00
Michael Bucari-Tovo
a77405c632 Make thread safe. 2021-07-27 10:21:17 -06:00
Mbucari
fdff31b69f Merge branch 'rmcrackan:master' into master 2021-07-27 10:13:55 -06:00
Michael Bucari-Tovo
f5e1667368 Added a background file system watcher. 2021-07-27 10:13:37 -06:00
Robert McRackan
af81367b46 Revert "rename BookTags.json to UserDefinedItems.json"
This reverts commit cd418e877d.
2021-07-27 11:54:47 -04:00
Robert McRackan
cd418e877d rename BookTags.json to UserDefinedItems.json 2021-07-27 11:12:40 -04:00
Robert McRackan
b6c9a82c68 Minor refactoring 2021-07-27 11:03:26 -04:00
Robert McRackan
efca1f9c1d added license debugging 2021-07-26 18:30:13 -04:00
Robert McRackan
ca14db79b9 Found the NRE. Underlying problem persists. Now it will be reported correctly 2021-07-26 17:12:40 -04:00
Robert McRackan
9d00da006c Trim title 2021-07-26 17:11:49 -04:00
42 changed files with 1268 additions and 312 deletions

View File

@@ -21,8 +21,8 @@ namespace AaxDecrypter
public event EventHandler<int> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public string AppName { get; set; } = nameof(AaxcDownloadConverter);
public string OutputFileName { get; private set; }
private string outputFileName { get; }
private string cacheDir { get; }
private DownloadLicense downloadLicense { get; }
private AaxFile aaxFile;
@@ -32,19 +32,19 @@ namespace AaxDecrypter
private StepSequence steps { get; }
private NetworkFileStreamPersister nfsPersister;
private bool isCanceled { get; set; }
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(OutputFileName) + ".json");
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(outputFileName) + ".json");
private string tempFile => PathLib.ReplaceExtension(jsonDownloadState, ".aaxc");
public AaxcDownloadConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
OutputFileName = outFileName;
var outDir = Path.GetDirectoryName(OutputFileName);
outputFileName = outFileName;
var outDir = Path.GetDirectoryName(outputFileName);
if (!Directory.Exists(outDir))
throw new ArgumentNullException(nameof(outDir), "Directory does not exist");
if (File.Exists(OutputFileName))
File.Delete(OutputFileName);
if (File.Exists(outputFileName))
File.Delete(outputFileName);
if (!Directory.Exists(cacheDirectory))
throw new ArgumentNullException(nameof(cacheDirectory), "Directory does not exist");
@@ -53,8 +53,6 @@ namespace AaxDecrypter
downloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
OutputFormat = outputFormat;
Serilog.Log.Logger.Verbose("Download License. {@DebugInfo}", downloadLicense);
steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + (outputFormat == OutputFormat.Mp4a ? "M4b" : "Mp3"),
@@ -127,10 +125,12 @@ namespace AaxDecrypter
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection();
headers.Add("User-Agent", downloadLicense.UserAgent);
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", downloadLicense.UserAgent }
};
NetworkFileStream networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
var networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
@@ -139,10 +139,10 @@ namespace AaxDecrypter
DecryptProgressUpdate?.Invoke(this, 0);
if (File.Exists(OutputFileName))
FileExt.SafeDelete(OutputFileName);
if (File.Exists(outputFileName))
FileExt.SafeDelete(outputFileName);
FileStream outFile = File.OpenWrite(OutputFileName);
FileStream outFile = File.OpenWrite(outputFileName);
aaxFile.SetDecryptionKey(downloadLicense.AudibleKey, downloadLicense.AudibleIV);
@@ -161,7 +161,7 @@ namespace AaxDecrypter
{
//This handles a special case where the aaxc file doesn't contain cover art and
//Libation downloaded it instead (Animal Farm). Currently only works for Mp4a files.
using var decryptedBook = new Mp4File(OutputFileName, FileAccess.ReadWrite);
using var decryptedBook = new Mp4File(outputFileName, FileAccess.ReadWrite);
decryptedBook.AppleTags?.SetCoverArt(coverArt);
decryptedBook.Save();
decryptedBook.Close();
@@ -193,7 +193,7 @@ namespace AaxDecrypter
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(OutputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(OutputFileName), downloadLicense.ChapterInfo));
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), downloadLicense.ChapterInfo));
}
catch (Exception ex)
{
@@ -207,7 +207,7 @@ namespace AaxDecrypter
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(OutputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
}
catch (Exception ex)
{

View File

@@ -204,7 +204,7 @@ namespace AaxDecrypter
_networkStream = response.GetResponseStream();
//Download the file in the background.
Thread downloadThread = new Thread(() => DownloadFile());
Thread downloadThread = new Thread(() => DownloadFile()) { IsBackground = true };
downloadThread.Start();
hasBegunDownloading = true;

View File

@@ -11,8 +11,16 @@ using Serilog;
namespace ApplicationServices
{
// subtly different from DataLayer.LiberatedStatus
// - DataLayer.LiberatedStatus: has no concept of partially downloaded
// - ApplicationServices.LiberatedState: has no concept of Error/skipped
public enum LiberatedState { NotDownloaded, PartialDownload, Liberated }
public enum PdfState { NoPdf, Downloaded, NotDownloaded }
public static class LibraryCommands
{
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
{
if (accounts is null || accounts.Length == 0)
@@ -41,7 +49,8 @@ namespace ApplicationServices
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new {
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
@@ -99,13 +108,23 @@ namespace ApplicationServices
return newCount;
}
#endregion
public static int UpdateTags(this LibationContext context, Book book, string newTags)
#region Update book details
public static int UpdateTags(Book book, string newTags)
{
try
{
book.UserDefinedItem.Tags = newTags;
using var context = DbContexts.GetContext();
var udi = book.UserDefinedItem;
if (udi.Tags == newTags)
return 0;
// Attach() NoTracking entities before SaveChanges()
udi.Tags = newTags;
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
@@ -119,5 +138,99 @@ namespace ApplicationServices
throw;
}
}
public static int UpdateBook(LibraryBook libraryBook, LiberatedStatus liberatedStatus, string finalAudioPath)
{
try
{
using var context = DbContexts.GetContext();
var udi = libraryBook.Book.UserDefinedItem;
if (udi.BookStatus == liberatedStatus && udi.BookLocation == finalAudioPath)
return 0;
// Attach() NoTracking entities before SaveChanges()
udi.BookStatus = liberatedStatus;
udi.BookLocation = finalAudioPath;
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
SearchEngineCommands.UpdateLiberatedStatus(libraryBook.Book);
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error updating tags");
throw;
}
}
public static int UpdatePdf(LibraryBook libraryBook, LiberatedStatus liberatedStatus)
{
try
{
using var context = DbContexts.GetContext();
var udi = libraryBook.Book.UserDefinedItem;
if (udi.PdfStatus == liberatedStatus)
return 0;
// Attach() NoTracking entities before SaveChanges()
udi.PdfStatus = liberatedStatus;
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error updating tags");
throw;
}
}
#endregion
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public static LiberatedState Liberated_Status(Book book)
=> TransitionalFileLocator.Audio_Exists(book) ? LiberatedState.Liberated
: TransitionalFileLocator.AAXC_Exists(book) ? LiberatedState.PartialDownload
: LiberatedState.NotDownloaded;
public static PdfState Pdf_Status(Book book)
=> !book.Supplements.Any() ? PdfState.NoPdf
: TransitionalFileLocator.PDF_Exists(book) ? PdfState.Downloaded
: PdfState.NotDownloaded;
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int pdfsDownloaded, int pdfsNotDownloaded) { }
public static LibraryStats GetCounts()
{
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedState.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedState.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedState.NotDownloaded);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress });
var boolResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.Supplements.Any())
.Select(lb => Pdf_Status(lb.Book))
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == PdfState.Downloaded);
var pdfsNotDownloaded = boolResults.Count(r => r == PdfState.NotDownloaded);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, pdfsDownloaded, pdfsNotDownloaded);
}
}
}

View File

@@ -7,10 +7,10 @@ namespace ApplicationServices
{
public static class SearchEngineCommands
{
public static void FullReIndex()
public static void FullReIndex(SearchEngine engine = null)
{
var engine = new SearchEngine(DbContexts.GetContext());
engine.CreateNewIndex();
engine ??= new SearchEngine();
engine.CreateNewIndex(DbContexts.GetContext());
}
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
@@ -21,34 +21,34 @@ namespace ApplicationServices
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
);
public static void UpdateIsLiberated(Book book) => performSearchEngineAction_safe(e =>
e.UpdateIsLiberated(book.AudibleProductId)
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
e.UpdateLiberatedStatus(book)
);
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
{
var engine = new SearchEngine(DbContexts.GetContext());
var engine = new SearchEngine();
try
{
action(engine);
}
catch (FileNotFoundException)
{
FullReIndex();
FullReIndex(engine);
action(engine);
}
}
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action)
{
var engine = new SearchEngine(DbContexts.GetContext());
var engine = new SearchEngine();
try
{
return action(engine);
}
catch (FileNotFoundException)
{
FullReIndex();
FullReIndex(engine);
return action(engine);
}
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using FileManager;
namespace ApplicationServices
{
public static class TransitionalFileLocator
{
public static string Audio_GetPath(Book book)
{
var loc = book?.UserDefinedItem?.BookLocation ?? "";
if (File.Exists(loc))
return loc;
return AudibleFileStorage.Audio.GetPath(book.AudibleProductId);
}
public static bool PDF_Exists(Book book)
{
var status = book?.UserDefinedItem?.PdfStatus;
if (status.HasValue && status.Value == LiberatedStatus.Liberated)
return true;
return AudibleFileStorage.PDF.Exists(book.AudibleProductId);
}
public static bool Audio_Exists(Book book)
{
var status = book?.UserDefinedItem?.BookStatus;
// true since Error == libhack
if (status.HasValue && status.Value != LiberatedStatus.NotLiberated)
return true;
return AudibleFileStorage.Audio.Exists(book.AudibleProductId);
}
public static bool AAXC_Exists(Book book)
{
// this one will actually stay the same. centralizing helps with organization in the interim though
return AudibleFileStorage.AAXC.Exists(book.AudibleProductId);
}
}
}

View File

@@ -88,8 +88,8 @@ namespace DataLayer
Category = category;
// simple assigns
Title = title;
Description = description;
Title = title.Trim();
Description = description.Trim();
LengthInMinutes = lengthInMinutes;
// assigns with biz logic
@@ -220,8 +220,11 @@ namespace DataLayer
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
if (!_supplements.Any(s => url.EqualsInsensitive(url)))
_supplements.Add(new Supplement(this, url));
if (_supplements.Any(s => url.EqualsInsensitive(url)))
return;
_supplements.Add(new Supplement(this, url));
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
}
#endregion

View File

@@ -2,6 +2,8 @@
namespace DataLayer
{
public enum Role { Author = 1, Narrator = 2, Publisher = 3 }
public class BookContributor
{
internal int BookId { get; private set; }

View File

@@ -1,4 +0,0 @@
namespace DataLayer
{
public enum Role { Author = 1, Narrator = 2, Publisher = 3 }
}

View File

@@ -6,6 +6,17 @@ using Dinah.Core;
namespace DataLayer
{
/// <summary>
/// Do not track in-process state. In-process state is determined by the presence of temp file.
/// </summary>
public enum LiberatedStatus
{
NotLiberated = 0,
Liberated = 1,
/// <summary>Error occurred during liberation. Don't retry</summary>
Error = 2
}
public class UserDefinedItem
{
internal int BookId { get; private set; }
@@ -22,6 +33,7 @@ namespace DataLayer
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
}
#region Tags
private string _tags = "";
public string Tags
{
@@ -71,14 +83,23 @@ namespace DataLayer
return string.Join(" ", unique);
}
#endregion
#endregion
#region Rating
// owned: not an optional one-to-one
/// <summary>The user's individual book rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
#endregion
public override string ToString() => $"{Book} {Rating} {Tags}";
#region LiberatedStatuses and book file location
public LiberatedStatus BookStatus { get; set; }
public string BookLocation { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
#endregion
public override string ToString() => $"{Book} {Rating} {Tags}";
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210727180408_AddLiberatedStatus")]
partial class AddLiberatedStatus
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.5");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.ToTable("Library");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("BookLocation")
.HasColumnType("TEXT");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class AddLiberatedStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BookLocation",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BookStatus",
table: "UserDefinedItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "PdfStatus",
table: "UserDefinedItem",
type: "INTEGER",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BookLocation",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "BookStatus",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "PdfStatus",
table: "UserDefinedItem");
}
}
}

View File

@@ -253,6 +253,15 @@ namespace DataLayer.Migrations
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("BookLocation")
.HasColumnType("TEXT");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");

View File

@@ -6,12 +6,14 @@ using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
// only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook
public static class BookQueries
{
public static Book GetBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.Books
.AsNoTracking()
.AsNoTrackingWithIdentityResolution()
.GetBook(productId);
public static Book GetBook(this IQueryable<Book> books, string productId)
@@ -25,6 +27,7 @@ namespace DataLayer
.GetBooks()
.Where(predicate);
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<Book> GetBooks(this IQueryable<Book> books)
=> books
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements

View File

@@ -4,27 +4,35 @@ using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
// only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook
public static class LibraryQueries
{
public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
=> context
.Library
.GetLibrary()
.ToList();
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
//public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
// => context
// .Library
// .GetLibrary()
// .ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
=> context
.Library
.AsNoTracking()
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.Library
.AsNoTracking()
.AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId);
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
=> library
@@ -32,10 +40,5 @@ namespace DataLayer
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(le => le.Book.AudibleProductId == productId);
}
}

View File

@@ -67,9 +67,6 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
//Add any subtitle after the title title.
var title = item.Title + (!string.IsNullOrWhiteSpace(item.Subtitle) ? $": {item.Subtitle}" : "");
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
item.Authors = new[] { new Person { Name = "", Asin = null } };
@@ -105,7 +102,7 @@ namespace DtoImporterService
var book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
title,
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
authors,

View File

@@ -20,11 +20,11 @@ namespace FileLiberator
public event EventHandler<string> StatusUpdate;
public event EventHandler<LibraryBook> Completed;
public DownloadDecryptBook DecryptBook { get; } = new DownloadDecryptBook();
public DownloadDecryptBook DownloadDecryptBook { get; } = new DownloadDecryptBook();
public DownloadPdf DownloadPdf { get; } = new DownloadPdf();
public bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
=> !ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
// do NOT use ConfigureAwait(false) on ProcessAsync()
// often calls events which prints to forms in the UI context
@@ -35,7 +35,7 @@ namespace FileLiberator
try
{
{
var statusHandler = await DecryptBook.TryProcessAsync(libraryBook);
var statusHandler = await DownloadDecryptBook.TryProcessAsync(libraryBook);
if (statusHandler.HasErrors)
return statusHandler;
}

View File

@@ -0,0 +1,98 @@
using AAXClean;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.IO;
using FileManager;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace FileLiberator
{
public class ConvertToMp3 : IDecryptable
{
public event EventHandler<string> DecryptBegin;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageFilepathDiscovered;
public event EventHandler<int> UpdateProgress;
public event EventHandler<TimeSpan> UpdateRemainingTime;
public event EventHandler<string> DecryptCompleted;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<LibraryBook> Completed;
public event EventHandler<string> StatusUpdate;
public event EventHandler<Action<byte[]>> RequestCoverArt;
private Mp4File m4bBook;
private string Mp3FileName(string m4bPath) => m4bPath is null ? string.Empty : PathLib.ReplaceExtension(m4bPath, ".mp3");
public void Cancel() => m4bBook?.Cancel();
public bool Validate(LibraryBook libraryBook)
{
var path = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book);
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
}
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
Begin?.Invoke(this, libraryBook);
DecryptBegin?.Invoke(this, $"Begin converting {libraryBook} to mp3");
try
{
var m4bPath = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book);
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
TitleDiscovered?.Invoke(this, m4bBook.AppleTags.Title);
AuthorsDiscovered?.Invoke(this, m4bBook.AppleTags.FirstAuthor);
NarratorsDiscovered?.Invoke(this, m4bBook.AppleTags.Narrator);
CoverImageFilepathDiscovered?.Invoke(this, m4bBook.AppleTags.Cover);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File));
m4bBook.InputStream.Close();
mp3File.Close();
var mp3Path = Mp3FileName(m4bPath);
FileExt.SafeMove(mp3File.Name, mp3Path);
var statusHandler = new StatusHandler();
if (result == ConversionResult.Failed)
statusHandler.AddError("Conversion failed");
return statusHandler;
}
finally
{
DecryptCompleted?.Invoke(this, $"Completed converting to mp3: {libraryBook.Book.Title}");
Completed?.Invoke(this, libraryBook);
}
}
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = m4bBook.Duration;
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
UpdateRemainingTime?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
UpdateProgress?.Invoke(this, (int)progressPercent);
}
}
}

View File

@@ -1,15 +1,14 @@
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileManager;
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AaxDecrypter;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileManager;
namespace FileLiberator
{
@@ -35,7 +34,7 @@ namespace FileLiberator
try
{
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
if (ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book))
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
var outputAudioFilename = await aaxToM4bConverterDecryptAsync(AudibleFileStorage.DownloadsInProgress, AudibleFileStorage.DecryptInProgress, libraryBook);
@@ -47,10 +46,13 @@ namespace FileLiberator
// moves files and returns dest dir
_ = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
var finalAudioExists = ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
if (!finalAudioExists)
return new StatusHandler { "Cannot find final audio file after decryption" };
// only need to update if success. if failure, it will remain at 0 == NotLiberated
ApplicationServices.LibraryCommands.UpdateBook(libraryBook, LiberatedStatus.Liberated, outputAudioFilename);
return new StatusHandler();
}
finally
@@ -67,35 +69,15 @@ namespace FileLiberator
{
validate(libraryBook);
Serilog.Log.Logger.Debug("Trying to debug mysterious null ref exception {@DebugInfo}", new {
libraryBookIsNull = libraryBook is null,
BookIsNull = libraryBook?.Book is null,
libraryBook?.Book?.Locale,
libraryBook?.Book?.AudibleProductId,
AccountLength = libraryBook?.Account?.Length
});
var api = await InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
Serilog.Log.Logger.Debug("Trying to debug mysterious null ref exception {@DebugInfo}", new { apiIsNull = api is null });
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
Serilog.Log.Logger.Debug("Trying to debug mysterious null ref exception {@DebugInfo}", new {
contentLicIsNull = contentLic is null,
contentMetadataIsNull = contentLic?.ContentMetadata is null,
voucherIsNull = contentLic?.Voucher is null,
keyIsNull = contentLic?.Voucher?.Key is null,
keyLength = contentLic?.Voucher?.Key?.Length,
ivIsNull = contentLic?.Voucher?.Iv is null,
ivLength = contentLic?.Voucher?.Iv?.Length,
});
var aaxcDecryptDlLic = new DownloadLicense
(
contentLic.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic.Voucher?.Key,
contentLic.Voucher?.Iv,
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic?.Voucher?.Key,
contentLic?.Voucher?.Iv,
Resources.UserAgent
);
@@ -117,10 +99,10 @@ namespace FileLiberator
_ => throw new NotImplementedException(),
};
var proposedOutputFile = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
var outFileName = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
aaxcDownloader = new AaxcDownloadConverter(proposedOutputFile, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" };
aaxcDownloader = new AaxcDownloadConverter(outFileName, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" };
aaxcDownloader.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => UpdateRemainingTime?.Invoke(this, remaining);
aaxcDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
@@ -133,7 +115,7 @@ namespace FileLiberator
if (!success)
return null;
return aaxcDownloader.OutputFileName;
return outFileName;
}
finally
{
@@ -190,6 +172,8 @@ namespace FileLiberator
File.Move(f.FullName, dest);
}
AudibleFileStorage.Audio.Refresh();
return destinationDir;
}
@@ -235,7 +219,7 @@ namespace FileLiberator
}
public bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
=> !ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
public void Cancel()
{

View File

@@ -5,7 +5,6 @@ using Dinah.Core.Net.Http;
namespace FileLiberator
{
// frustratingly copy pasta from DownloadableBase and DownloadPdf
// currently only used to download the .zip flies for upgrade
public class DownloadFile : IDownloadable
{

View File

@@ -1,4 +1,6 @@
using System.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
@@ -13,24 +15,24 @@ namespace FileLiberator
{
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId);
&& !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book);
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
return verifyDownload(libraryBook);
}
var result = verifyDownload(libraryBook);
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId)
? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler();
var liberatedStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
ApplicationServices.LibraryCommands.UpdatePdf(libraryBook, liberatedStatus);
return result;
}
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
{
// if audio file exists, get it's dir. else return base Book dir
var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
var existingPath = Path.GetDirectoryName(ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book));
var file = getdownloadUrl(libraryBook);
if (existingPath != null)
@@ -44,6 +46,9 @@ namespace FileLiberator
return full;
}
private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
{
var api = await GetApiAsync(libraryBook);
@@ -55,7 +60,9 @@ namespace FileLiberator
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
}
private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book)
? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler();
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
@@ -22,16 +23,6 @@ namespace FileLiberator
.GetLibrary_Flat_NoTracking()
.Where(libraryBook => processable.Validate(libraryBook));
public static LibraryBook GetSingleLibraryBook(string productId)
{
using var context = DbContexts.GetContext();
var libraryBook = context
.Library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
return libraryBook;
}
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook)
{
if (!processable.Validate(libraryBook))

View File

@@ -8,19 +8,11 @@ using Dinah.Core.Collections.Generic;
namespace FileManager
{
// could add images here, but for now images are stored in a well-known location
public enum FileType { Unknown, Audio, AAXC, PDF }
/// <summary>
/// Files are large. File contents are never read by app.
/// Paths are varied.
/// Files are written during download/decrypt/backup/liberate.
/// Paths are read at app launch and during download/decrypt/backup/liberate.
/// Many files are often looked up at once
/// </summary>
public abstract class AudibleFileStorage : Enumeration<AudibleFileStorage>
{
public abstract string[] Extensions { get; }
protected abstract string[] Extensions { get; }
public abstract string StorageDirectory { get; }
#region static
@@ -41,6 +33,8 @@ namespace FileManager
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
}
}
private static BackgroundFileSystem BookDirectoryFiles { get; } = new BackgroundFileSystem();
#endregion
#region instance
@@ -53,7 +47,12 @@ namespace FileManager
{
extensions_noDots = Extensions.Select(ext => ext.Trim('.')).ToList();
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
}
}
public void Refresh()
{
BookDirectoryFiles.RefreshFiles();
}
/// <summary>
/// Example for full books:
@@ -63,16 +62,31 @@ namespace FileManager
/// </summary>
public bool Exists(string productId) => GetPath(productId) != null;
public string GetPath(string productId)
public string GetPath(string productId)
{
var cachedFile = FilePathCache.GetPath(productId, FileType);
if (cachedFile != null)
return cachedFile;
var firstOrNull =
Directory
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase));
string storageDir = StorageDirectory;
string regexPattern = $@"{productId}.*?\.({extAggr})$";
string firstOrNull;
if (storageDir == BooksDirectory)
{
//If user changed the BooksDirectory, reinitialize.
if (storageDir != BookDirectoryFiles.RootDirectory)
BookDirectoryFiles.Init(storageDir, "*.*", SearchOption.AllDirectories);
firstOrNull = BookDirectoryFiles.FindFile(regexPattern, RegexOptions.IgnoreCase);
}
else
{
firstOrNull =
Directory
.EnumerateFiles(storageDir, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, RegexOptions.IgnoreCase));
}
if (firstOrNull is null)
return null;
@@ -102,7 +116,7 @@ namespace FileManager
{
public const string SKIP_FILE_EXT = "libhack";
public override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
protected override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
@@ -125,7 +139,7 @@ namespace FileManager
public class AaxcFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "aaxc" };
protected override string[] Extensions { get; } = new[] { "aaxc" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
@@ -137,7 +151,7 @@ namespace FileManager
public class PdfFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "pdf", "zip" };
protected override string[] Extensions { get; } = new[] { "pdf", "zip" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace FileManager
{
class BackgroundFileSystem
{
public string RootDirectory { get; private set; }
public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; }
private FileSystemWatcher fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
private Task backgroundScanner { get; set; }
private List<string> fsCache { get; set; }
public string FindFile(string regexPattern, RegexOptions options)
{
lock (fsCache)
{
return fsCache.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, options));
}
}
public void RefreshFiles()
{
if (fsCache is null) return;
lock (fsCache)
{
fsCache.Clear();
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
}
}
public void Init(string rootDirectory, string searchPattern, SearchOption searchOptions)
{
RootDirectory = rootDirectory;
SearchPattern = searchPattern;
SearchOption = searchOptions;
//Calling CompleteAdding() will cause background scanner to terminate.
directoryChangesEvents?.CompleteAdding();
fsCache?.Clear();
directoryChangesEvents?.Dispose();
fileSystemWatcher?.Dispose();
fsCache = Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption).ToList();
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory);
fileSystemWatcher.Created += FileSystemWatcher_Changed;
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
fileSystemWatcher.Error += FileSystemWatcher_Error;
fileSystemWatcher.IncludeSubdirectories = true;
fileSystemWatcher.EnableRaisingEvents = true;
//Wait for background scanner to terminate before reinitializing.
backgroundScanner?.Wait();
backgroundScanner = new Task(BackgroundScanner);
backgroundScanner.Start();
}
private void AddUniqueFiles(IEnumerable<string> newFiles)
{
foreach (var file in newFiles)
{
AddUniqueFile(file);
}
}
private void AddUniqueFile(string newFile)
{
if (!fsCache.Contains(newFile))
fsCache.Add(newFile);
}
private void FileSystemWatcher_Error(object sender, ErrorEventArgs e)
{
Init(RootDirectory, SearchPattern, SearchOption);
}
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
{
directoryChangesEvents.Add(e);
}
#region Background Thread
private void BackgroundScanner()
{
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
UpdateLocalCache(change);
}
private void UpdateLocalCache(FileSystemEventArgs change)
{
lock (fsCache)
{
if (change.ChangeType == WatcherChangeTypes.Deleted)
{
RemovePath(change.FullPath);
}
else if (change.ChangeType == WatcherChangeTypes.Created)
{
AddPath(change.FullPath);
}
else if (change.ChangeType == WatcherChangeTypes.Renamed)
{
var renameChange = change as RenamedEventArgs;
RemovePath(renameChange.OldFullPath);
AddPath(renameChange.FullPath);
}
}
}
private void RemovePath(string path)
{
var pathsToRemove = fsCache.Where(p => p.StartsWith(path)).ToArray();
foreach (var p in pathsToRemove)
fsCache.Remove(p);
}
private void AddPath(string path)
{
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
AddUniqueFiles(Directory.EnumerateFiles(path, SearchPattern, SearchOption));
else
AddUniqueFile(path);
}
#endregion
}
}

View File

@@ -16,16 +16,16 @@ namespace FileManager
public string Path { get; set; }
}
static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
static FilePathCache()
{
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (File.Exists(JsonFile))
if (File.Exists(jsonFile))
{
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(JsonFile));
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
cache = new Cache<CacheEntry>(list);
}
}
@@ -74,7 +74,7 @@ namespace FileManager
private static void save()
{
// create json if not exists
static void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
lock (locker)
{

View File

@@ -31,6 +31,9 @@ namespace FileManager
{
ensureCache();
if (!tagsCollection.Any())
return;
// on initial reload, there's a huge benefit to adding to cache individually then updating the file only once
foreach ((string productId, string tags) in tagsCollection)
cache[productId] = tags;

View File

@@ -13,7 +13,7 @@
<!-- <PublishSingleFile>true</PublishSingleFile> -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Version>5.3.8.1</Version>
<Version>5.4.8.1</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using AudibleApi;
using AudibleApi.Authorization;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using FileManager;
@@ -51,6 +54,7 @@ namespace LibationLauncher
migrate_to_v5_0_0(config);
migrate_to_v5_2_0__post_config(config);
//migrate_to_v5_4_1(config);// comment out until after vacation
ensureSerilogConfig(config);
configureLogging(config);
@@ -141,7 +145,7 @@ namespace LibationLauncher
CancelInstallation();
}
#region migrate_to_v5_0_0 re-register device if device info not in settings
#region migrate to v5.0.0 re-register device if device info not in settings
private static void migrate_to_v5_0_0(Configuration config)
{
if (!config.Exists(nameof(config.AllowLibationFixup)))
@@ -229,6 +233,72 @@ namespace LibationLauncher
}
#endregion
#region migrate to v5.4.1 see comment
// this 'migration' is a bit different. it intentionally runs each time Libation is started. its job will be fulfilled when I eventually
// implement the portion which removes FilePaths.json, at which time this method will be a proper migration
//
// I'm iterating through safe steps toward getting rid of the live scanner except to track audiobook files as a convenience
// such as clicking the stop light to open its location. live scanning will be replaced with state tracking in the database.
// FilePaths.json => db. long running. fire and forget
private static void migrate_to_v5_4_1(Configuration config)
=> new System.Threading.Thread(() => migrate_to_v5_4_1_thread(config)) { IsBackground = true }.Start();
private static void migrate_to_v5_4_1_thread(Configuration config)
{
var debugStopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
var filePaths = Path.Combine(config.LibationFiles, "FilePaths.json");
if (!File.Exists(filePaths))
return;
using var context = ApplicationServices.DbContexts.GetContext();
context.Books.Load();
var jArr = JArray.Parse(File.ReadAllText(filePaths));
foreach (var jToken in jArr)
{
var asinToken = jToken["Id"];
var fileTypeToken = jToken["FileType"];
var pathToken = jToken["Path"];
if (asinToken is null || fileTypeToken is null || pathToken is null ||
asinToken.Type != JTokenType.String || fileTypeToken.Type != JTokenType.Integer || pathToken.Type != JTokenType.String)
continue;
var asin = asinToken.Value<string>();
var fileType = (FileType)fileTypeToken.Value<int>();
var path = pathToken.Value<string>();
if (fileType == FileType.Unknown || fileType == FileType.AAXC)
continue;
var book = context.Books.Local.FirstOrDefault(b => b.AudibleProductId == asin);
if (book is null)
continue;
// assign these strings and enums/ints unconditionally. EFCore will only update if changed
if (fileType == FileType.PDF)
book.UserDefinedItem.PdfStatus = LiberatedStatus.Liberated;
if (fileType == FileType.Audio)
{
book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
book.UserDefinedItem.BookLocation = path;
}
}
context.SaveChanges();
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error attempting to insert FilePaths into db");
}
debugStopwatch.Stop();
var debugTotal = debugStopwatch.Elapsed;
}
#endregion
private static void ensureSerilogConfig(Configuration config)
{
if (config.GetObject("Serilog") != null)
@@ -291,7 +361,7 @@ namespace LibationLauncher
config.ConfigureLogging();
// Fwd Console to serilog.
// Serilog also write to Console (should probably change this) so it might be asking for trouble.
// Serilog also writes to Console (should probably change this) so it might be asking for trouble.
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
// Empirical testing so far has shown no issues.
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
@@ -308,6 +378,11 @@ namespace LibationLauncher
Log.Logger.Information("Begin Libation. {@DebugInfo}", new
{
Version = BuildVersion.ToString(),
#if DEBUG
Mode = "Debug",
#else
Mode = "Release",
#endif
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),

View File

@@ -18,8 +18,6 @@ namespace LibationSearchEngine
{
public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30;
private LibationContext context { get; }
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
@@ -32,8 +30,6 @@ namespace LibationSearchEngine
// the workaround which allows displaying all books when query is empty
public const string ALL_QUERY = "*:*";
public SearchEngine(LibationContext context) => this.context = context;
#region index rules
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
@@ -130,8 +126,9 @@ namespace LibationSearchEngine
["Abridged"] = lb => lb.Book.IsAbridged,
// this will only be evaluated at time of re-index. ie: state of files moved later will be out of sync until next re-index
["IsLiberated"] = lb => isLiberated(lb.Book.AudibleProductId),
["Liberated"] = lb => isLiberated(lb.Book.AudibleProductId),
["IsLiberated"] = lb => isLiberated(lb.Book),
["Liberated"] = lb => isLiberated(lb.Book),
["LiberatedError"] = lb => liberatedError(lb.Book),
}
);
@@ -142,7 +139,10 @@ namespace LibationSearchEngine
return authors.Intersect(narrators).Any();
}
private static bool isLiberated(string id) => AudibleFileStorage.Audio.Exists(id);
private static bool isLiberated(Book book)
=> book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated
|| AudibleFileStorage.Audio.Exists(book.AudibleProductId);
private static bool liberatedError(Book book) => book.UserDefinedItem.BookStatus == LiberatedStatus.Error;
// use these common fields in the "all" default search field
private static IEnumerable<Func<LibraryBook, string>> allFieldIndexRules { get; }
@@ -198,11 +198,10 @@ namespace LibationSearchEngine
/// create new. ie: full re-index
/// </summary>
/// <param name="overwrite"></param>
public void CreateNewIndex(bool overwrite = true)
public void CreateNewIndex(LibationContext context, bool overwrite = true)
{
// 300 products
// 1st run after app is started: 400ms
// subsequent runs: 200ms
// 300 titles: 200- 400 ms
// 1021 titles: 1777-2250 ms
var sw = System.Diagnostics.Stopwatch.StartNew();
var stamps = new List<long>();
void log() => stamps.Add(sw.ElapsedMilliseconds);
@@ -232,7 +231,7 @@ namespace LibationSearchEngine
}
/// <summary>Long running. Use await Task.Run(() => UpdateBook(productId))</summary>
public void UpdateBook(string productId)
public void UpdateBook(LibationContext context, string productId)
{
var libraryBook = context.GetLibraryBook_Flat_NoTracking(productId);
var term = new Term(_ID_, productId);
@@ -301,18 +300,22 @@ namespace LibationSearchEngine
});
// update single document entry
public void UpdateIsLiberated(string productId)
public void UpdateLiberatedStatus(Book book)
=> updateDocument(
productId,
book.AudibleProductId,
d =>
{
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
// ie: must remove old before adding new else will create unwanted duplicates.
var v = isLiberated(productId);
var v1 = isLiberated(book);
d.RemoveField("IsLiberated");
d.AddBool("IsLiberated", v);
d.AddBool("IsLiberated", v1);
d.RemoveField("Liberated");
d.AddBool("Liberated", v);
d.AddBool("Liberated", v1);
var v2 = liberatedError(book);
d.RemoveField("LiberatedError");
d.AddBool("LiberatedError", v2);
});
private static void updateDocument(string productId, Action<Document> action)

View File

@@ -13,9 +13,9 @@ namespace LibationWinForms.BookLiberation
private string authorNames;
private string narratorNames;
public void SetTitle(string title)
public void SetTitle(string actionName, string title)
{
this.UIThread(() => this.Text = "Decrypting " + title);
this.UIThread(() => this.Text = actionName + " " + title);
this.title = title;
updateBookInfo();
}

View File

@@ -51,15 +51,14 @@ namespace LibationWinForms.BookLiberation
public static class ProcessorAutomationController
{
public static async Task BackupSingleBookAsync(string productId, EventHandler<LibraryBook> completedAction = null)
public static async Task BackupSingleBookAsync(LibraryBook libraryBook, EventHandler<LibraryBook> completedAction = null)
{
Serilog.Log.Logger.Information("Begin " + nameof(BackupSingleBookAsync) + " {@DebugInfo}", new { productId });
Serilog.Log.Logger.Information("Begin backup single {@DebugInfo}", new { libraryBook?.Book?.AudibleProductId });
var backupBook = getWiredUpBackupBook(completedAction);
(Action unsubscribeEvents, LogMe logMe) = attachToBackupsForm(backupBook);
var libraryBook = IProcessableExt.GetSingleLibraryBook(productId);
// continue even if libraryBook is null. we'll display even that in the processing box
await new BackupSingle(logMe, backupBook, libraryBook).RunBackupAsync();
@@ -80,33 +79,47 @@ namespace LibationWinForms.BookLiberation
unsubscribeEvents();
}
public static async Task ConvertAllBooksAsync()
{
Serilog.Log.Logger.Information("Begin " + nameof(ConvertAllBooksAsync));
var convertBook = new ConvertToMp3();
convertBook.Begin += (_, l) => wireUpEvents(convertBook, l, "Converting");
var automatedBackupsForm = new AutomatedBackupsForm();
var logMe = LogMe.RegisterForm(automatedBackupsForm);
void statusUpdate(object _, string str) => logMe.Info("- " + str);
void convertBookBegin(object _, LibraryBook libraryBook) => logMe.Info($"Convert Step, Begin: {libraryBook.Book}");
void convertBookCompleted(object _, LibraryBook libraryBook) => logMe.Info($"Convert Step, Completed: {libraryBook.Book}{Environment.NewLine}");
convertBook.Begin += convertBookBegin;
convertBook.StatusUpdate += statusUpdate;
convertBook.Completed += convertBookCompleted;
await new BackupLoop(logMe, convertBook, automatedBackupsForm).RunBackupAsync();
convertBook.Begin -= convertBookBegin;
convertBook.StatusUpdate -= statusUpdate;
convertBook.Completed -= convertBookCompleted;
}
private static BackupBook getWiredUpBackupBook(EventHandler<LibraryBook> completedAction)
{
var backupBook = new BackupBook();
backupBook.DecryptBook.Begin += (_, l) => wireUpEvents(backupBook.DecryptBook, l);
backupBook.DownloadDecryptBook.Begin += (_, l) => wireUpEvents(backupBook.DownloadDecryptBook, l);
backupBook.DownloadPdf.Begin += (_, __) => wireUpEvents(backupBook.DownloadPdf);
// must occur before completedAction. A common use case is:
// - filter by -liberated
// - liberate only that book
// completedAction is to refresh grid
// - want to see that book disappear from grid
// also for this to work, updateIsLiberated can NOT be async
backupBook.DecryptBook.Completed += updateIsLiberated;
backupBook.DownloadPdf.Completed += updateIsLiberated;
if (completedAction != null)
{
backupBook.DecryptBook.Completed += completedAction;
backupBook.DownloadDecryptBook.Completed += completedAction;
backupBook.DownloadPdf.Completed += completedAction;
}
return backupBook;
}
private static void updateIsLiberated(object sender, LibraryBook e) => ApplicationServices.SearchEngineCommands.UpdateIsLiberated(e.Book);
private static (Action unsubscribeEvents, LogMe) attachToBackupsForm(BackupBook backupBook, AutomatedBackupsForm automatedBackupsForm = null)
{
#region create logger
@@ -124,9 +137,9 @@ namespace LibationWinForms.BookLiberation
#endregion
#region subscribe new form to model's events
backupBook.DecryptBook.Begin += decryptBookBegin;
backupBook.DecryptBook.StatusUpdate += statusUpdate;
backupBook.DecryptBook.Completed += decryptBookCompleted;
backupBook.DownloadDecryptBook.Begin += decryptBookBegin;
backupBook.DownloadDecryptBook.StatusUpdate += statusUpdate;
backupBook.DownloadDecryptBook.Completed += decryptBookCompleted;
backupBook.DownloadPdf.Begin += downloadPdfBegin;
backupBook.DownloadPdf.StatusUpdate += statusUpdate;
backupBook.DownloadPdf.Completed += downloadPdfCompleted;
@@ -136,9 +149,9 @@ namespace LibationWinForms.BookLiberation
// unsubscribe so disposed forms aren't still trying to receive notifications
Action unsubscribe = () =>
{
backupBook.DecryptBook.Begin -= decryptBookBegin;
backupBook.DecryptBook.StatusUpdate -= statusUpdate;
backupBook.DecryptBook.Completed -= decryptBookCompleted;
backupBook.DownloadDecryptBook.Begin -= decryptBookBegin;
backupBook.DownloadDecryptBook.StatusUpdate -= statusUpdate;
backupBook.DownloadDecryptBook.Completed -= decryptBookCompleted;
backupBook.DownloadPdf.Begin -= downloadPdfBegin;
backupBook.DownloadPdf.StatusUpdate -= statusUpdate;
backupBook.DownloadPdf.Completed -= downloadPdfCompleted;
@@ -176,7 +189,8 @@ namespace LibationWinForms.BookLiberation
downloadDialog.UpdateFilename(destination);
downloadDialog.Show();
new System.Threading.Thread(() => {
new System.Threading.Thread(() =>
{
var downloadFile = new DownloadFile();
downloadFile.DownloadProgressChanged += (_, progress) => downloadDialog.UIThread(() =>
@@ -190,7 +204,9 @@ namespace LibationWinForms.BookLiberation
});
downloadFile.PerformDownloadFileAsync(url, destination).GetAwaiter().GetResult();
}).Start();
})
{ IsBackground = true }
.Start();
}
// subscribed to Begin event because a new form should be created+processed+closed on each iteration
@@ -258,14 +274,14 @@ namespace LibationWinForms.BookLiberation
}
// subscribed to Begin event because a new form should be created+processed+closed on each iteration
private static void wireUpEvents(IDecryptable decryptBook, LibraryBook libraryBook)
private static void wireUpEvents(IDecryptable decryptBook, LibraryBook libraryBook, string actionName = "Decrypting")
{
#region create form
var decryptDialog = new DecryptForm();
#endregion
#region Set initially displayed book properties from library info.
decryptDialog.SetTitle(libraryBook.Book.Title);
decryptDialog.SetTitle(actionName, libraryBook.Book.Title);
decryptDialog.SetAuthorNames(string.Join(", ", libraryBook.Book.Authors));
decryptDialog.SetNarratorNames(string.Join(", ", libraryBook.Book.NarratorNames));
decryptDialog.SetCoverImage(
@@ -278,7 +294,7 @@ namespace LibationWinForms.BookLiberation
#region define how model actions will affect form behavior
void decryptBegin(object _, string __) => decryptDialog.Show();
void titleDiscovered(object _, string title) => decryptDialog.SetTitle(title);
void titleDiscovered(object _, string title) => decryptDialog.SetTitle(actionName, title);
void authorsDiscovered(object _, string authors) => decryptDialog.SetAuthorNames(authors);
void narratorsDiscovered(object _, string narrators) => decryptDialog.SetNarratorNames(narrators);
void coverImageFilepathDiscovered(object _, byte[] coverBytes) => decryptDialog.SetCoverImage(Dinah.Core.Drawing.ImageReader.ToImage(coverBytes));
@@ -467,6 +483,7 @@ $@" Title: {libraryBook.Book.Title}
if (dialogResult == CreateSkipFileResult)
{
ApplicationServices.LibraryCommands.UpdateBook(libraryBook, LiberatedStatus.Error, null);
var path = FileManager.AudibleFileStorage.Audio.CreateSkipFile(libraryBook.Book.Title, libraryBook.Book.AudibleProductId, logMessage);
LogMe.Info($@"
Created new 'skip' file

View File

@@ -1,6 +1,6 @@
namespace LibationWinForms.Dialogs
{
partial class EditTagsDialog
partial class BookDetailsDialog
{
/// <summary>
/// Required designer variable.

View File

@@ -3,15 +3,15 @@ using System.Windows.Forms;
namespace LibationWinForms.Dialogs
{
public partial class EditTagsDialog : Form
public partial class BookDetailsDialog : Form
{
public string NewTags { get; private set; }
public EditTagsDialog()
public BookDetailsDialog()
{
InitializeComponent();
}
public EditTagsDialog(string title, string rawTags) : this()
public BookDetailsDialog(string title, string rawTags) : this()
{
this.Text = $"Edit Tags - {title}";

View File

@@ -12,6 +12,8 @@ namespace LibationWinForms.Dialogs.Login
private void approvedBtn_Click(object sender, EventArgs e)
{
Serilog.Log.Logger.Information("Submit button clicked");
DialogResult = DialogResult.OK;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Windows.Forms;
using Dinah.Core;
using InternalUtilities;
namespace LibationWinForms.Dialogs.Login
@@ -29,6 +30,8 @@ namespace LibationWinForms.Dialogs.Login
Email = accountId;
Password = this.passwordTb.Text;
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { email = Email?.ToMask(), passwordLength = Password.Length });
DialogResult = DialogResult.OK;
// Close() not needed for AcceptButton
}

View File

@@ -26,6 +26,8 @@ namespace LibationWinForms.Dialogs.Login
{
Answer = this.answerTb.Text;
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { Answer });
DialogResult = DialogResult.OK;
// Close() not needed for AcceptButton
}

View File

@@ -63,26 +63,29 @@ namespace LibationWinForms.Dialogs.Login
public string SelectedValue { get; private set; }
private void submitBtn_Click(object sender, EventArgs e)
{
Serilog.Log.Logger.Information("RadioButton states: {@DebugInfo}", new {
var selected = radioButtons.FirstOrDefault(rb => rb.Checked);
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new {
rb1_visible = radioButton1.Visible,
rb1_checked = radioButton1.Checked,
r21_visible = radioButton2.Visible,
r21_checked = radioButton2.Checked,
rb2_visible = radioButton2.Visible,
rb2_checked = radioButton2.Checked,
rb3_visible = radioButton3.Visible,
rb3_checked = radioButton3.Checked
rb3_checked = radioButton3.Checked,
isSelected = selected is not null,
name = selected?.Name,
value = selected?.Tag
});
var selected = radioButtons.FirstOrDefault(rb => rb.Checked);
if (selected is null)
{
MessageBox.Show("No MFA option selected", "None selected", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Serilog.Log.Logger.Information("Selected: {@DebugInfo}", new { isSelected = selected is not null, name = selected?.Name, value = selected?.Tag });
SelectedName = selected.Name;
SelectedValue = (string)selected.Tag;

View File

@@ -14,7 +14,10 @@ namespace LibationWinForms.Dialogs.Login
private void submitBtn_Click(object sender, EventArgs e)
{
Code = this.codeTb.Text;
Code = this.codeTb.Text.Trim();
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { Code });
DialogResult = DialogResult.OK;
}
}

View File

@@ -42,6 +42,7 @@
this.liberateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.beginBookBackupsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.beginPdfBackupsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.convertAllM4bToMp3ToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.exportToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.exportLibraryToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.quickFiltersToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -164,7 +165,8 @@
//
this.liberateToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.beginBookBackupsToolStripMenuItem,
this.beginPdfBackupsToolStripMenuItem});
this.beginPdfBackupsToolStripMenuItem,
this.convertAllM4bToMp3ToolStripMenuItem});
this.liberateToolStripMenuItem.Name = "liberateToolStripMenuItem";
this.liberateToolStripMenuItem.Size = new System.Drawing.Size(61, 20);
this.liberateToolStripMenuItem.Text = "&Liberate";
@@ -172,17 +174,25 @@
// beginBookBackupsToolStripMenuItem
//
this.beginBookBackupsToolStripMenuItem.Name = "beginBookBackupsToolStripMenuItem";
this.beginBookBackupsToolStripMenuItem.Size = new System.Drawing.Size(248, 22);
this.beginBookBackupsToolStripMenuItem.Size = new System.Drawing.Size(284, 22);
this.beginBookBackupsToolStripMenuItem.Text = "Begin &Book and PDF Backups: {0}";
this.beginBookBackupsToolStripMenuItem.Click += new System.EventHandler(this.beginBookBackupsToolStripMenuItem_Click);
//
// beginPdfBackupsToolStripMenuItem
//
this.beginPdfBackupsToolStripMenuItem.Name = "beginPdfBackupsToolStripMenuItem";
this.beginPdfBackupsToolStripMenuItem.Size = new System.Drawing.Size(248, 22);
this.beginPdfBackupsToolStripMenuItem.Size = new System.Drawing.Size(284, 22);
this.beginPdfBackupsToolStripMenuItem.Text = "Begin &PDF Only Backups: {0}";
this.beginPdfBackupsToolStripMenuItem.Click += new System.EventHandler(this.beginPdfBackupsToolStripMenuItem_Click);
//
// convertAllM4bToMp3ToolStripMenuItem
//
this.convertAllM4bToMp3ToolStripMenuItem.Name = "convertAllM4bToMp3ToolStripMenuItem";
this.convertAllM4bToMp3ToolStripMenuItem.Size = new System.Drawing.Size(284, 22);
this.convertAllM4bToMp3ToolStripMenuItem.Text = "Convert all M4b to Mp3 [Long-running]";
this.convertAllM4bToMp3ToolStripMenuItem.Visible = false;
this.convertAllM4bToMp3ToolStripMenuItem.Click += new System.EventHandler(this.convertAllM4bToMp3ToolStripMenuItem_Click);
//
// exportToolStripMenuItem
//
this.exportToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
@@ -357,5 +367,6 @@
private System.Windows.Forms.ToolStripMenuItem noAccountsYetAddAccountToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem exportToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem exportLibraryToolStripMenuItem;
}
private System.Windows.Forms.ToolStripMenuItem convertAllM4bToMp3ToolStripMenuItem;
}
}

View File

@@ -191,45 +191,24 @@ namespace LibationWinForms
#region bottom: backup counts
private async void setBackupCountsAsync(object _, object __)
{
await Task.Run(() => {
var books = DbContexts.GetContext()
.GetLibrary_Flat_NoTracking()
.Select(sp => sp.Book)
.ToList();
LibraryCommands.LibraryStats libraryStats = null;
await Task.Run(() => libraryStats = LibraryCommands.GetCounts());
setBookBackupCounts(books);
setPdfBackupCounts(books);
});
}
enum AudioFileState { full, aax, none }
private void setBookBackupCounts(IEnumerable<Book> books)
setBookBackupCounts(libraryStats.booksFullyBackedUp, libraryStats.booksDownloadedOnly, libraryStats.booksNoProgress);
setPdfBackupCounts(libraryStats.pdfsDownloaded, libraryStats.pdfsNotDownloaded);
}
private void setBookBackupCounts(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress)
{
static AudioFileState getAudioFileState(string productId)
{
if (AudibleFileStorage.Audio.Exists(productId))
return AudioFileState.full;
if (AudibleFileStorage.AAXC.Exists(productId))
return AudioFileState.aax;
return AudioFileState.none;
}
var results = books
.AsParallel()
.Select(b => getAudioFileState(b.AudibleProductId))
.ToList();
var fullyBackedUp = results.Count(r => r == AudioFileState.full);
var downloadedOnly = results.Count(r => r == AudioFileState.aax);
var noProgress = results.Count(r => r == AudioFileState.none);
// enable/disable export
exportLibraryToolStripMenuItem.Enabled = results.Any();
var hasResults = 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress);
exportLibraryToolStripMenuItem.Enabled = hasResults;
// update bottom numbers
var pending = noProgress + downloadedOnly;
var pending = booksNoProgress + booksDownloadedOnly;
var statusStripText
= !results.Any() ? "No books. Begin by importing your library"
: pending > 0 ? string.Format(backupsCountsLbl_Format, noProgress, downloadedOnly, fullyBackedUp)
: $"All {"book".PluralizeWithCount(fullyBackedUp)} backed up";
= !hasResults ? "No books. Begin by importing your library"
: pending > 0 ? string.Format(backupsCountsLbl_Format, booksNoProgress, booksDownloadedOnly, booksFullyBackedUp)
: $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
// update menu item
var menuItemText
@@ -237,40 +216,29 @@ namespace LibationWinForms
? $"{pending} remaining"
: "All books have been liberated";
Serilog.Log.Logger.Information("Book counts. {@DebugInfo}", new { fullyBackedUp, downloadedOnly, noProgress, pending, statusStripText, menuItemText });
// update UI
statusStrip1.UIThread(() => backupsCountsLbl.Text = statusStripText);
menuStrip1.UIThread(() => beginBookBackupsToolStripMenuItem.Enabled = pending > 0);
menuStrip1.UIThread(() => beginBookBackupsToolStripMenuItem.Text = string.Format(beginBookBackupsToolStripMenuItem_format, menuItemText));
}
private void setPdfBackupCounts(IEnumerable<Book> books)
private void setPdfBackupCounts(int pdfsDownloaded, int pdfsNotDownloaded)
{
var boolResults = books
.AsParallel()
.Where(b => b.Supplements.Any())
.Select(b => AudibleFileStorage.PDF.Exists(b.AudibleProductId))
.ToList();
var downloaded = boolResults.Count(r => r);
var notDownloaded = boolResults.Count(r => !r);
// update bottom numbers
var hasResults = 0 < (pdfsNotDownloaded + pdfsDownloaded);
var statusStripText
= !boolResults.Any() ? ""
: notDownloaded > 0 ? string.Format(pdfsCountsLbl_Format, notDownloaded, downloaded)
: $"| All {downloaded} PDFs downloaded";
= !hasResults ? ""
: pdfsNotDownloaded > 0 ? string.Format(pdfsCountsLbl_Format, pdfsNotDownloaded, pdfsDownloaded)
: $"| All {pdfsDownloaded} PDFs downloaded";
// update menu item
var menuItemText
= notDownloaded > 0
? $"{notDownloaded} remaining"
= pdfsNotDownloaded > 0
? $"{pdfsNotDownloaded} remaining"
: "All PDFs have been downloaded";
Serilog.Log.Logger.Information("PDF counts. {@DebugInfo}", new { downloaded, notDownloaded, statusStripText, menuItemText });
// update UI
statusStrip1.UIThread(() => pdfsCountsLbl.Text = statusStripText);
menuStrip1.UIThread(() => beginPdfBackupsToolStripMenuItem.Enabled = notDownloaded > 0);
menuStrip1.UIThread(() => beginPdfBackupsToolStripMenuItem.Enabled = pdfsNotDownloaded > 0);
menuStrip1.UIThread(() => beginPdfBackupsToolStripMenuItem.Text = string.Format(beginPdfBackupsToolStripMenuItem_format, menuItemText));
}
#endregion
@@ -390,6 +358,9 @@ namespace LibationWinForms
private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e)
=> await BookLiberation.ProcessorAutomationController.BackupAllPdfsAsync(updateGridRow);
private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e)
=> await BookLiberation.ProcessorAutomationController.ConvertAllBooksAsync();
private void updateGridRow(object _, LibraryBook libraryBook) => currProductsGrid.RefreshRow(libraryBook.Book.AudibleProductId);
#endregion
@@ -483,6 +454,6 @@ namespace LibationWinForms
private void accountsToolStripMenuItem_Click(object sender, EventArgs e) => new AccountsDialog(this).ShowDialog();
private void basicSettingsToolStripMenuItem_Click(object sender, EventArgs e) => new SettingsDialog().ShowDialog();
#endregion
}
#endregion
}
}

View File

@@ -3,42 +3,35 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using ApplicationServices;
using DataLayer;
namespace LibationWinForms
{
internal class GridEntry
{
private LibraryBook libraryBook;
private LibraryBook libraryBook { get; }
private Book book => libraryBook.Book;
public Book GetBook() => book;
// this special case is obvious and ugly
public void REPLACE_Library_Book(LibraryBook libraryBook) => this.libraryBook = libraryBook;
public LibraryBook GetLibraryBook() => libraryBook;
public GridEntry(LibraryBook libraryBook) => this.libraryBook = libraryBook;
// hide from public fields from Data Source GUI with [Browsable(false)]
[Browsable(false)]
public string AudibleProductId => book.AudibleProductId;
[Browsable(false)]
public string Tags => book.UserDefinedItem.Tags;
[Browsable(false)]
public IEnumerable<string> TagsEnumerated => book.UserDefinedItem.TagsEnumerated;
public enum LiberatedState { NotDownloaded, PartialDownload, Liberated }
[Browsable(false)]
public LiberatedState Liberated_Status
=> FileManager.AudibleFileStorage.Audio.Exists(book.AudibleProductId) ? LiberatedState.Liberated
: FileManager.AudibleFileStorage.AAXC.Exists(book.AudibleProductId) ? LiberatedState.PartialDownload
: LiberatedState.NotDownloaded;
public enum PdfState { NoPdf, Downloaded, NotDownloaded }
public string PictureId => book.PictureId;
[Browsable(false)]
public PdfState Pdf_Status
=> !book.Supplements.Any() ? PdfState.NoPdf
: FileManager.AudibleFileStorage.PDF.Exists(book.AudibleProductId) ? PdfState.Downloaded
: PdfState.NotDownloaded;
public LiberatedState Liberated_Status => LibraryCommands.Liberated_Status(book);
[Browsable(false)]
public PdfState Pdf_Status => LibraryCommands.Pdf_Status(book);
// displayValues is what gets displayed
// the value that gets returned from the property is the cell's value

View File

@@ -36,21 +36,29 @@ namespace LibationWinForms
// alias
private DataGridView dataGridView => gridEntryDataGridView;
private LibationContext context;
public ProductsGrid()
{
InitializeComponent();
formatDataGridView();
addLiberateButtons();
addEditTagsButtons();
formatColumns();
Disposed += (_, __) => context?.Dispose();
formatDataGridView();
addLiberateButtons();
addEditTagsButtons();
formatColumns();
manageLiveImageUpdateSubscriptions();
enableDoubleBuffering();
}
private void formatDataGridView()
private void enableDoubleBuffering()
{
var propertyInfo = dataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
//var before = (bool)propertyInfo.GetValue(dataGridView);
propertyInfo.SetValue(dataGridView, true, null);
//var after = (bool)propertyInfo.GetValue(dataGridView);
}
private void formatDataGridView()
{
dataGridView.Dock = DockStyle.Fill;
dataGridView.AllowUserToAddRows = false;
@@ -126,25 +134,25 @@ namespace LibationWinForms
{
var libState = liberatedStatus switch
{
GridEntry.LiberatedState.Liberated => "Liberated",
GridEntry.LiberatedState.PartialDownload => "File has been at least\r\npartially downloaded",
GridEntry.LiberatedState.NotDownloaded => "Book NOT downloaded",
LiberatedState.Liberated => "Liberated",
LiberatedState.PartialDownload => "File has been at least\r\npartially downloaded",
LiberatedState.NotDownloaded => "Book NOT downloaded",
_ => throw new Exception("Unexpected liberation state")
};
var pdfState = pdfStatus switch
{
GridEntry.PdfState.Downloaded => "\r\nPDF downloaded",
GridEntry.PdfState.NotDownloaded => "\r\nPDF NOT downloaded",
GridEntry.PdfState.NoPdf => "",
PdfState.Downloaded => "\r\nPDF downloaded",
PdfState.NotDownloaded => "\r\nPDF NOT downloaded",
PdfState.NoPdf => "",
_ => throw new Exception("Unexpected PDF state")
};
var text = libState + pdfState;
if (liberatedStatus == GridEntry.LiberatedState.NotDownloaded ||
liberatedStatus == GridEntry.LiberatedState.PartialDownload ||
pdfStatus == GridEntry.PdfState.NotDownloaded)
if (liberatedStatus == LiberatedState.NotDownloaded ||
liberatedStatus == LiberatedState.PartialDownload ||
pdfStatus == PdfState.NotDownloaded)
text += "\r\nClick to complete";
//DEBUG//cell.Value = text;
@@ -154,14 +162,14 @@ namespace LibationWinForms
// draw img
{
var image_lib
= liberatedStatus == GridEntry.LiberatedState.NotDownloaded ? "red"
: liberatedStatus == GridEntry.LiberatedState.PartialDownload ? "yellow"
: liberatedStatus == GridEntry.LiberatedState.Liberated ? "green"
= liberatedStatus == LiberatedState.NotDownloaded ? "red"
: liberatedStatus == LiberatedState.PartialDownload ? "yellow"
: liberatedStatus == LiberatedState.Liberated ? "green"
: throw new Exception("Unexpected liberation state");
var image_pdf
= pdfStatus == GridEntry.PdfState.NoPdf ? ""
: pdfStatus == GridEntry.PdfState.NotDownloaded ? "_pdf_no"
: pdfStatus == GridEntry.PdfState.Downloaded ? "_pdf_yes"
= pdfStatus == PdfState.NoPdf ? ""
: pdfStatus == PdfState.NotDownloaded ? "_pdf_no"
: pdfStatus == PdfState.Downloaded ? "_pdf_yes"
: throw new Exception("Unexpected PDF state");
var image = (Bitmap)Properties.Resources.ResourceManager.GetObject($"liberate_{image_lib}{image_pdf}");
drawImage(e, image);
@@ -172,24 +180,26 @@ namespace LibationWinForms
{
if (!isColumnValid(e, LIBERATE))
return;
var productId = getGridEntry(e.RowIndex).GetBook().AudibleProductId;
var libraryBook = getGridEntry(e.RowIndex).GetLibraryBook();
// liberated: open explorer to file
if (FileManager.AudibleFileStorage.Audio.Exists(productId))
if (TransitionalFileLocator.Audio_Exists(libraryBook.Book))
{
var filePath = FileManager.AudibleFileStorage.Audio.GetPath(productId);
Go.To.File(filePath);
var filePath = TransitionalFileLocator.Audio_GetPath(libraryBook.Book);
if (!Go.To.File(filePath))
MessageBox.Show($"File not found:\r\n{filePath}");
return;
}
await BookLiberation.ProcessorAutomationController.BackupSingleBookAsync(productId, (_, __) => RefreshRow(productId));
// else: liberate
await BookLiberation.ProcessorAutomationController.BackupSingleBookAsync(libraryBook, (_, __) => RefreshRow(libraryBook.Book.AudibleProductId));
}
#endregion
public void RefreshRow(string productId)
{
var rowId = getRowId((ge) => ge.GetBook().AudibleProductId == productId);
var rowId = getRowId((ge) => ge.AudibleProductId == productId);
// update cells incl Liberate button text
dataGridView.InvalidateRow(rowId);
@@ -245,11 +255,11 @@ namespace LibationWinForms
// EditTagsDialog should display better-formatted title
liveGridEntry.TryDisplayValue(nameof(liveGridEntry.Title), out string value);
var editTagsForm = new EditTagsDialog(value, liveGridEntry.Tags);
if (editTagsForm.ShowDialog() != DialogResult.OK)
var bookDetailsForm = new BookDetailsDialog(value, liveGridEntry.Tags);
if (bookDetailsForm.ShowDialog() != DialogResult.OK)
return;
var qtyChanges = context.UpdateTags(liveGridEntry.GetBook(), editTagsForm.NewTags);
var qtyChanges = LibraryCommands.UpdateTags(liveGridEntry.GetBook(), bookDetailsForm.NewTags);
if (qtyChanges == 0)
return;
@@ -320,7 +330,7 @@ namespace LibationWinForms
=> dataGridView.UIThread(() => updateRowImage(pictureId));
private void updateRowImage(string pictureId)
{
var rowId = getRowId((ge) => ge.GetBook().PictureId == pictureId);
var rowId = getRowId((ge) => ge.PictureId == pictureId);
if (rowId > -1)
dataGridView.InvalidateRow(rowId);
}
@@ -336,8 +346,8 @@ namespace LibationWinForms
//
// transform into sorted GridEntry.s BEFORE binding
//
context = DbContexts.GetContext();
var lib = context.GetLibrary_Flat_WithTracking();
using var context = DbContexts.GetContext();
var lib = context.GetLibrary_Flat_NoTracking();
// if no data. hide all columns. return
if (!lib.Any())
@@ -388,7 +398,7 @@ namespace LibationWinForms
currencyManager.SuspendBinding();
{
for (var r = dataGridView.RowCount - 1; r >= 0; r--)
dataGridView.Rows[r].Visible = productIds.Contains(getGridEntry(r).GetBook().AudibleProductId);
dataGridView.Rows[r].Visible = productIds.Contains(getGridEntry(r).AudibleProductId);
}
currencyManager.ResumeBinding();
VisibleCountChanged?.Invoke(this, dataGridView.AsEnumerable().Count(r => r.Visible));