Compare commits

..

29 Commits

Author SHA1 Message Date
rmcrackan
176294cc55 Merge pull request #1245 from Mbucari/master
Minor bugfixes
2025-05-07 19:28:45 -04:00
Michael Bucari-Tovo
152b0e362d Update message box icons 2025-05-07 16:10:03 -06:00
Michael Bucari-Tovo
4600d029dc Re-add converter resource inadvertantly removed in 0df17a22 2025-05-07 14:23:58 -06:00
Michael Bucari-Tovo
1a5684799c Update Hangover styles and behaviors 2025-05-07 13:16:44 -06:00
Michael Bucari-Tovo
0df17a2296 Remove retired ItemsRepeater control 2025-05-07 13:12:12 -06:00
Michael Bucari-Tovo
45472abd1f Update dependencies 2025-05-07 11:15:32 -06:00
Mbucari
f2ea4539f2 Merge branch 'rmcrackan:master' into master 2025-05-07 11:13:32 -06:00
Michael Bucari-Tovo
52d3b9cb67 Disable warning 2025-05-07 11:13:26 -06:00
rmcrackan
3d87f2cd9b Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-07 12:39:10 -04:00
rmcrackan
e4a3d2ac79 better logging for api errors #1240 2025-05-07 12:39:02 -04:00
Michael Bucari-Tovo
8aa157f2f6 Re-add completed audiobooks to queue (#1219) 2025-05-06 15:43:58 -06:00
Michael Bucari-Tovo
5ab6c1fe70 Update AAXClean to fix metadata reader (#1243 ) 2025-05-06 15:33:38 -06:00
Michael Bucari-Tovo
b23c46f79f Fix incorrect chapters in some audiobooks (#1210) 2025-05-06 15:32:59 -06:00
Mbucari
0e987eef00 Fix error in download speed throttle (#1242) 2025-05-06 14:48:40 -06:00
rmcrackan
ace3d80e41 Merge pull request #1241 from cherez/patch-1
Fixed doubled first name in templates
2025-05-06 16:29:29 -04:00
Mbucari
4bfb4e73ce Fix aax file getting inadvertently deleted (#1236) 2025-05-06 12:45:43 -06:00
Steven Wallace
7805a3ef11 Fixed broken single word name test
This expected the name duplication that the previous commit fixed to be the behavior, changed to expect the single word to be the last name.
2025-05-06 09:58:09 -05:00
Steven Wallace
08ca2a2db3 Fixed doubled first name in templates
v12.3.0 caused a regression with contributors with a single word name, causing the name to be doubled. This was caused by using that name as both the first and last name, so swap the first name with the (blank) last name rather than duplicate them.
2025-05-05 10:37:28 -05:00
rmcrackan
64a85b6aab Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-02 22:15:36 -04:00
rmcrackan
1a38273d5f incr ver 2025-05-02 22:15:32 -04:00
rmcrackan
303dd7c471 Merge pull request #1233 from Mbucari/master
Bugfixes and Feature Requests
2025-05-02 22:14:33 -04:00
MBucari
313e3846c3 Remove AudioFormat from library book exporter (5f455182) 2025-05-02 15:39:47 -06:00
Michael Bucari-Tovo
422c86345e Add logging 2025-05-02 14:50:33 -06:00
Michael Bucari-Tovo
ce952417fb Don't replace library properties in queued item with null/empty 2025-05-02 13:07:53 -06:00
Michael Bucari-Tovo
5f4551822b Remove Book.AudioFormat property
This property was set to the highest quality returned by the library scan. Since adding quality option settings, it is no longer guaranteed to reflect the file that is downloaded. Also, the library scan qualities don't contain spatial audio or widevine-specific qualities., only ADRM.
2025-05-02 12:39:12 -06:00
Michael Bucari-Tovo
3aebc7c885 Improve download performance. 2025-05-02 12:19:32 -06:00
Michael Bucari-Tovo
3982edd0f1 Add codec tag and use real bitrate/samplerate (#1227) 2025-05-02 11:20:58 -06:00
Michael Bucari-Tovo
f4dafac28f Try to solve #1226 2025-05-01 13:19:03 -06:00
Michael Bucari-Tovo
1090d29f74 Add fine-grained options for downloading widevine content 2025-05-01 13:03:03 -06:00
63 changed files with 657 additions and 552 deletions

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.1" />
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -103,8 +103,8 @@ namespace AaxDecrypter
OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
return !IsCanceled;

View File

@@ -204,8 +204,6 @@ namespace AaxDecrypter
else
throw new InvalidOperationException($"Unknown file type: {fileType}");
FileUtility.SaferMove(tempFilePath, aaxPath);
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}

View File

@@ -61,9 +61,6 @@ namespace AaxDecrypter
#region Constants
//Size of each range request. Android app uses 64MB chunks.
private const int RANGE_REQUEST_SZ = 64 * 1024 * 1024;
//Download memory buffer size
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
@@ -161,7 +158,7 @@ namespace AaxDecrypter
//Initiate connection with the first request block and
//get the total content length before returning.
using var client = new HttpClient();
var client = new HttpClient();
var response = await RequestNextByteRangeAsync(client);
if (ContentLength != 0 && ContentLength != response.FileSize)
@@ -170,38 +167,59 @@ namespace AaxDecrypter
ContentLength = response.FileSize;
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Hand off the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(response), _cancellationSource.Token);
//Hand off the client and the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
}
private async Task DownloadLoopInternal(BlockResponse initialResponse)
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
{
await DownloadToFile(initialResponse);
initialResponse.Dispose();
try
{
using var client = new HttpClient();
long startPosition = WritePosition;
while (WritePosition < ContentLength && !IsCancelled)
{
using var response = await RequestNextByteRangeAsync(client);
await DownloadToFile(response);
try
{
await DownloadToFile(blockResponse);
}
catch (HttpIOException e)
when (e.HttpRequestError is HttpRequestError.ResponseEnded
&& WritePosition != startPosition
&& WritePosition < ContentLength && !IsCancelled)
{
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
//the download made *some* progress since the last attempt.
//Try again to complete the download from where it left off.
//Make sure to rewind file to last flush position.
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
finally
{
_writeFile.Close();
_writeFile.Dispose();
blockResponse.Dispose();
client.Dispose();
}
}
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
{
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
//Just in case it snuck in the saved json (Issue #1232)
RequestHeaders.Remove("Range");
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
request.Headers.Add("Range", $"bytes={WritePosition}-{WritePosition + RANGE_REQUEST_SZ - 1}");
request.Headers.Add("Range", $"bytes={WritePosition}-");
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
@@ -226,7 +244,7 @@ namespace AaxDecrypter
private async Task DownloadToFile(BlockResponse block)
{
var endPosition = WritePosition + block.BlockSize;
var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
@@ -259,11 +277,11 @@ namespace AaxDecrypter
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token);
startTime = DateTime.Now;
startTime = DateTime.UtcNow;
bytesReadSinceThrottle = 0;
}
@@ -286,7 +304,6 @@ namespace AaxDecrypter
}
finally
{
networkStream.Close();
_downloadedPiece.Set();
OnUpdate();
}

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.3.1.1</Version>
<Version>12.4.1.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -104,9 +104,6 @@ namespace ApplicationServices
[Name("Content Type")]
public string ContentType { get; set; }
[Name("Audio Format")]
public string AudioFormat { get; set; }
[Name("Language")]
public string Language { get; set; }
@@ -152,7 +149,6 @@ namespace ApplicationServices
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
@@ -228,7 +224,6 @@ namespace ApplicationServices
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
@@ -299,7 +294,6 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue)

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.0.1" />
<PackageReference Include="AudibleApi" Version="9.4.1.1" />
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
</ItemGroup>

View File

@@ -19,7 +19,6 @@ namespace DataLayer.Configurations
//
entity.Ignore(nameof(Book.Authors));
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
entity.Ignore(nameof(Book.TitleWithSubtitle));
entity.Ignore(b => b.Categories);

View File

@@ -1,65 +0,0 @@
using System;
namespace DataLayer
{
internal enum AudioFormatEnum : long
{
//Defining the enum this way ensures that when comparing:
//LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo
//This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality
//I've never seen mono formats.
Unknown = 0,
LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2,
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
AAX_22_32 = LC_32_22050_stereo,
AAX_22_64 = LC_64_22050_stereo,
AAX_44_64 = LC_64_44100_stereo,
AAX_44_128 = LC_128_44100_stereo
}
public class AudioFormat : IComparable<AudioFormat>, IComparable
{
internal int AudioFormatID { get; private set; }
public int Bitrate { get; private init; }
public int SampleRate { get; private init; }
public int Channels { get; private init; }
public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0;
public static AudioFormat FromString(string formatStr)
{
if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal))
return FromEnum(enumVal);
return FromEnum(AudioFormatEnum.Unknown);
}
internal static AudioFormat FromEnum(AudioFormatEnum enumVal)
{
var val = (long)enumVal;
return new()
{
Bitrate = (int)(val >> 18),
SampleRate = (int)(val >> 2) & ushort.MaxValue,
Channels = (int)(val & 3)
};
}
internal AudioFormatEnum ToEnum()
{
var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels);
return Enum.IsDefined(val) ?
val : AudioFormatEnum.Unknown;
}
public override string ToString()
=> IsValid ?
$"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" :
"Unknown";
public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum());
public int CompareTo(object obj) => CompareTo(obj as AudioFormat);
}
}

View File

@@ -43,9 +43,11 @@ namespace DataLayer
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
internal AudioFormatEnum _audioFormat;
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
//This field is now unused, however, there is little sense in adding a
//database migration to remove an unused field. Leave it for compatibility.
#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0
internal long _audioFormat;
#pragma warning restore CS0649
// mutable
public string PictureId { get; set; }

View File

@@ -154,9 +154,6 @@ namespace DtoImporterService
// Update the book titles, since formatting can change
book.UpdateTitle(item.Title, item.Subtitle);
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
book.AudioFormat = codec;
// set/update book-specific info which may have changed
if (item.PictureId is not null)
book.PictureId = item.PictureId;

View File

@@ -19,21 +19,24 @@ namespace FileLiberator
protected void OnTitleDiscovered(object _, string title)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
TitleDiscovered?.Invoke(this, title);
if (title != null)
TitleDiscovered?.Invoke(this, title);
}
protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors);
protected void OnAuthorsDiscovered(object _, string authors)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
AuthorsDiscovered?.Invoke(this, authors);
if (authors != null)
AuthorsDiscovered?.Invoke(this, authors);
}
protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators);
protected void OnNarratorsDiscovered(object _, string narrators)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
NarratorsDiscovered?.Invoke(this, narrators);
if (narrators != null)
NarratorsDiscovered?.Invoke(this, narrators);
}
protected byte[] OnRequestCoverArt()

View File

@@ -39,14 +39,20 @@ namespace FileLiberator
/// 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);
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook, 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 does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file already exists

View File

@@ -47,13 +47,18 @@ namespace FileLiberator
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var config = Configuration.Instance;
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook);
bool success = false;
try
{
FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook);
success = await downloadAudiobookAsync(api, config, downloadOptions);
}
finally
{
@@ -78,12 +83,12 @@ namespace FileLiberator
var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
Task[] finalTasks = new[]
{
Task.Run(() => downloadCoverArt(libraryBook)),
Task[] finalTasks =
[
Task.Run(() => downloadCoverArt(downloadOptions)),
moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
};
];
try
{
@@ -116,16 +121,9 @@ namespace FileLiberator
}
}
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
{
var config = Configuration.Instance;
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
using var dlOptions = await DownloadOptions.InitiateDownloadAsync(api, libraryBook, config);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
@@ -149,7 +147,7 @@ namespace FileLiberator
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
@@ -158,12 +156,12 @@ namespace FileLiberator
{
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile);
OnFileCreated(dlOptions.LibraryBook, metadataFile);
}
return success;
}
@@ -173,7 +171,7 @@ namespace FileLiberator
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
return;
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
tags.Album ??= tags.Title;
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
tags.AlbumArtists ??= tags.Artist;
@@ -280,7 +278,7 @@ namespace FileLiberator
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(LibraryBook libraryBook)
private static void downloadCoverArt(DownloadOptions options)
{
if (!Configuration.Instance.DownloadCoverArt) return;
@@ -288,24 +286,24 @@ namespace FileLiberator
try
{
var destinationDir = getDestinationDirectory(libraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
var destinationDir = getDestinationDirectory(options.LibraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath);
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native));
if (picBytes.Length > 0)
{
File.WriteAllBytes(coverPath, picBytes);
SetFileTime(libraryBook, coverPath);
SetFileTime(options.LibraryBook, coverPath);
}
}
catch (Exception ex)
{
//Failure to download cover art should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
}
}
}

View File

@@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
#nullable enable
@@ -23,9 +24,18 @@ public partial class DownloadOptions
/// <summary>
/// Initiate an audiobook download from the audible api.
/// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, LibraryBook libraryBook, Configuration config)
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook)
{
var license = await ChooseContent(api, libraryBook, config);
//Come audiobooks will have incorrect chapters in the metadata returned from the license request,
//but the metadata returned by the content metadata endpoint will be correct. Call the content
//metadata endpoint and use its chapters. Only replace the license request chapters if the total
//lengths match (defensive against different audio formats having slightly different lengths).
var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId);
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
var options = BuildDownloadOptions(libraryBook, config, license);
return options;
@@ -33,81 +43,78 @@ public partial class DownloadOptions
private static async Task<ContentLicense> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
{
var cdm = await Cdm.GetCdmAsync();
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
ContentLicense? contentLic = null;
ContentLicense? fallback = null;
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
if (cdm is null)
ContentLicense? contentLic = null, fallback = null;
try
{
//Doesn't matter what the user chose. We can't get a CDM so we must fall back to AAX(C)
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
//try to request a widevine content license using the user's spatial audio settings
var codecChoice = config.SpatialAudioCodec switch
{
Configuration.SpatialCodec.EC_3 => Ec3Codec,
Configuration.SpatialCodec.AC_4 => Ac4Codec,
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
};
contentLic
= await api.GetDownloadLicenseAsync(
libraryBook.Book.AudibleProductId,
dlQuality,
ChapterTitlesType.Tree,
DrmType.Widevine,
config.RequestSpatial,
codecChoice);
}
else
catch (Exception ex)
{
var spatial = config.FileDownloadQuality is Configuration.DownloadQuality.Spatial;
try
{
var codecChoice = config.SpatialAudioCodec switch
{
Configuration.SpatialCodec.EC_3 => Ec3Codec,
Configuration.SpatialCodec.AC_4 => Ac4Codec,
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
};
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, spatial, codecChoice);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
}
if (contentLic is null)
{
//We failed to get a widevine license, so fall back to AAX(C)
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
}
else if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm)
{
/*
We got a widevine license and we have a Cdm, but we still need to decide if we WANT the file
being delivered with widevine. This file is not "spatial", so it may be no better than the
audio in the Adrm files. All else being equal, we prefer Adrm files because they have more
build-in metadata and always AAC-LC, which is a codec playable by pretty much every device
in existence.
Unfortunately, there appears to be no way to determine which codec/quality combination we'll
get until we make the request and see what content gets delivered. For some books,
Widevine/High delivers 44.1 kHz / 128 kbps audio and Adrm/High delivers 22.05 kHz / 64 kbps.
In those cases, the Widevine content size is much larger. Other books will deliver the same
sample rate / bitrate for both Widevine and Adrm, the only difference being codec. Widevine
is usually xHE-AAC, but is sometimes AAC-LC. Adrm is always AAC-LC.
To decide which file we want, use this simple rule: if files are different codecs and
Widevine is significantly larger, use Widevine. Otherwise use ADRM.
*/
fallback = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
var wvCr = contentLic.ContentMetadata.ContentReference;
var adrmCr = fallback.ContentMetadata.ContentReference;
if (wvCr.Codec == adrmCr.Codec ||
adrmCr.ContentSizeInBytes > wvCr.ContentSizeInBytes ||
RelativePercentDifference(adrmCr.ContentSizeInBytes, wvCr.ContentSizeInBytes) < 0.05)
{
contentLic = fallback;
}
}
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
//We failed to get a widevine license, so fall back to AAX(C)
return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
}
if (contentLic.DrmType == DrmType.Widevine && cdm is not null)
if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm)
{
/*
We got a widevine license and we have a Cdm, but we still need to decide if we WANT the file
being delivered with widevine. This file is not "spatial", so it may be no better than the
audio in the Adrm files. All else being equal, we prefer Adrm files because they have more
build-in metadata and always AAC-LC, which is a codec playable by pretty much every device
in existence.
Unfortunately, there appears to be no way to determine which codec/quality combination we'll
get until we make the request and see what content gets delivered. For some books,
Widevine/High delivers 44.1 kHz / 128 kbps audio and Adrm/High delivers 22.05 kHz / 64 kbps.
In those cases, the Widevine content size is much larger. Other books will deliver the same
sample rate / bitrate for both Widevine and Adrm, the only difference being codec. Widevine
is usually xHE-AAC, but is sometimes AAC-LC. Adrm is always AAC-LC.
To decide which file we want, use this simple rule: if files are different codecs and
Widevine is significantly larger, use Widevine. Otherwise use ADRM.
*/
fallback = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
var wvCr = contentLic.ContentMetadata.ContentReference;
var adrmCr = fallback.ContentMetadata.ContentReference;
if (wvCr.Codec == adrmCr.Codec ||
adrmCr.ContentSizeInBytes > wvCr.ContentSizeInBytes ||
RelativePercentDifference(adrmCr.ContentSizeInBytes, wvCr.ContentSizeInBytes) < 0.05)
{
contentLic = fallback;
}
}
if (contentLic.DrmType == DrmType.Widevine)
{
try
{
using var client = new HttpClient();
var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
@@ -124,7 +131,6 @@ public partial class DownloadOptions
Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()),
Iv = Convert.ToHexStringLower(keys[0].Key)
};
}
catch
{
@@ -160,9 +166,6 @@ public partial class DownloadOptions
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 32 && contentLic.Voucher?.Iv.Length == 32 ? AAXClean.FileType.Aaxc
: null;
//Set the requested AudioFormat for use in file naming templates
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
var dlOptions = new DownloadOptions(config, libraryBook, contentLic.ContentMetadata.ContentUrl?.OfflineUrl)
{
AudibleKey = contentLic.Voucher?.Key,
@@ -176,6 +179,14 @@ public partial class DownloadOptions
RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
dlOptions.LibraryBookDto.Codec = contentLic.ContentMetadata.ContentReference.Codec;
if (TryGetAudioInfo(contentLic.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
{
dlOptions.LibraryBookDto.BitRate = bitrate;
dlOptions.LibraryBookDto.SampleRate = sampleRate;
dlOptions.LibraryBookDto.Channels = channels;
}
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
@@ -202,6 +213,43 @@ public partial class DownloadOptions
return dlOptions;
}
/// <summary>
/// The most reliable way to get these audio file properties is from the filename itself.
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
/// </summary>
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
{
bitrate = sampleRate = channels = null;
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
return false;
var file = Path.GetFileName(uri.LocalPath);
var match = AdrmAudioProperties().Match(file);
if (match.Success)
{
bitrate = int.Parse(match.Groups[1].Value);
sampleRate = int.Parse(match.Groups[2].Value);
channels = int.Parse(match.Groups[3].Value);
return true;
}
else if ((match = WidevineAudioProperties().Match(file)).Success)
{
bitrate = int.Parse(match.Groups[2].Value);
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
channels = match.Groups[3].Value switch
{
"ec3" => 6,
"ac4" => 3,
_ => null
};
return true;
}
return false;
}
public static LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new()
@@ -317,7 +365,7 @@ public partial class DownloadOptions
else if (titleConcat is null)
{
chaps.Add(c);
chaps.AddRange(flattenChapters(c.Chapters));
chaps.AddRange(flattenChapters(c.Chapters, titleConcat));
}
else
{
@@ -330,7 +378,7 @@ public partial class DownloadOptions
else
chaps.Add(c);
var children = flattenChapters(c.Chapters);
var children = flattenChapters(c.Chapters, titleConcat);
foreach (var child in children)
child.Title = $"{c.Title}{titleConcat}{child.Title}";
@@ -359,4 +407,9 @@ public partial class DownloadOptions
static double RelativePercentDifference(long num1, long num2)
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
[GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex WidevineAudioProperties();
[GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex AdrmAudioProperties();
}

View File

@@ -55,9 +55,6 @@ namespace FileLiberator
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
Channels = libraryBook.Book.AudioFormat.Channels,
Language = libraryBook.Book.Language
};
}

View File

@@ -6,7 +6,30 @@
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme/>
</Application.Styles>
<Application.Styles>
<FluentTheme>
<FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" />
<ColorPaletteResources x:Key="Dark" />
</FluentTheme.Palettes>
</FluentTheme>
<Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
<Style Selector="^ /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
</Style>
</Style>
<Style Selector="Button">
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Style Selector="^">
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style>
<Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
</Style>
</Application.Styles>
</Application>

View File

@@ -4,27 +4,16 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="HangoverAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
ItemsSource="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>

View File

@@ -2,103 +2,18 @@ using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using HangoverAvalonia.ViewModels;
using ReactiveUI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace HangoverAvalonia.Controls
namespace HangoverAvalonia.Controls;
public partial class CheckedListBox : UserControl
{
public partial class CheckedListBox : UserControl
public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
public CheckedListBox()
{
public event EventHandler<ItemCheckEventArgs> ItemCheck;
public static readonly StyledProperty<IEnumerable> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, IEnumerable>(nameof(Items));
public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public IEnumerable<object> CheckedItems =>
_viewModel
.CheckboxItems
.Where(i => i.IsChecked)
.Select(i => i.Item);
public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked;
public void SetItemChecked(object item, bool isChecked)
{
var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item);
if (obj is not null)
obj.IsChecked = isChecked;
}
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
_viewModel.CheckedChanged += _viewModel_CheckedChanged;
}
private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e)
{
var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked };
ItemCheck?.Invoke(this, args);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.SetItems(Items);
base.OnPropertyChanged(change);
}
public class CheckedListBoxViewModel : ViewModelBase
{
public event EventHandler<CheckBoxViewModel> CheckedChanged;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get; private set; }
public void SetItems(IEnumerable items)
{
UnsubscribeFromItems(CheckboxItems);
CheckboxItems = new(items.OfType<object>().Select(o => new CheckBoxViewModel { Item = o }));
SubscribeToItems(CheckboxItems);
this.RaisePropertyChanged(nameof(CheckboxItems));
}
private void SubscribeToItems(IEnumerable objects)
{
foreach (var i in objects.OfType<INotifyPropertyChanged>())
i.PropertyChanged += I_PropertyChanged;
}
private void UnsubscribeFromItems(AvaloniaList<CheckBoxViewModel> objects)
{
if (objects is null) return;
foreach (var i in objects)
i.PropertyChanged -= I_PropertyChanged;
}
private void I_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender);
}
}
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}
}
public class ItemCheckEventArgs : EventArgs
{
public int ItemIndex { get; init; }
public bool IsChecked { get; init; }
public object Item { get; init; }
InitializeComponent();
}
}

View File

@@ -71,13 +71,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.8" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.8" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.0" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@@ -0,0 +1,11 @@
using ReactiveUI;
namespace HangoverAvalonia.ViewModels;
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}

View File

@@ -1,41 +1,8 @@
using ApplicationServices;
using DataLayer;
using ReactiveUI;
using System.Collections.Generic;
namespace HangoverAvalonia.ViewModels;
namespace HangoverAvalonia.ViewModels
public partial class MainVM
{
public partial class MainVM
{
private List<LibraryBook> _deletedBooks;
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
public TrashBinViewModel TrashBinViewModel { get; } = new();
private int _totalBooksCount = 0;
private int _checkedBooksCount = 0;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
}
private void Load_deletedVM()
{
reload();
}
public void reload()
{
DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
_checkedBooksCount = 0;
_totalBooksCount = DeletedBooks.Count;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
private void Load_deletedVM() { }
}

View File

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

View File

@@ -1,40 +1,12 @@
using ApplicationServices;
using DataLayer;
using HangoverAvalonia.Controls;
using System.Linq;
namespace HangoverAvalonia.Views;
namespace HangoverAvalonia.Views
public partial class MainWindow
{
public partial class MainWindow
private void deletedTab_VisibleChanged(bool isVisible)
{
private void deletedTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
if (!isVisible)
return;
if (_viewModel.DeletedBooks.Count == 0)
_viewModel.reload();
}
public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args)
{
_viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count();
}
public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, true);
}
public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, false);
}
public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
var qtyChanges = libraryBooksToRestore.RestoreBooks();
if (qtyChanges > 0)
_viewModel.reload();
}
_viewModel.TrashBinViewModel.Reload();
}
}

View File

@@ -15,13 +15,11 @@
<TabControl Name="tabControl1" Grid.Row="0">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="23"/>
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem">
<Setter Property="MinHeight" Value="40"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="8,2,8,5"/>
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
@@ -51,6 +49,7 @@
<TextBox
Margin="0,5,0,5"
AcceptsReturn="True"
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
<Button
@@ -73,33 +72,58 @@
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
</TabItem.Header>
<Grid
DataContext="{Binding TrashBinViewModel}"
RowDefinitions="Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="To restore deleted book, check box and save" />
Text="Check books you want to permanently delete from or restore to Libation" />
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
Name="deletedCbl"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,Auto,*">
ColumnDefinitions="Auto,Auto,*,Auto">
<Button Grid.Column="0" Margin="0,0,20,0" Content="Check All" Click="Deleted_CheckAll_Click" />
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" />
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" />
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" />
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Restore"
Command="{Binding RestoreCheckedAsync}"/>
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Command="{Binding PermanentlyDeleteCheckedAsync}" >
<TextBlock
TextAlignment="Center"
Text="Permanently Delete&#xa;from Libation" />
</Button>
</Grid>
</Grid>
</TabItem>

View File

@@ -18,7 +18,6 @@ namespace HangoverAvalonia.Views
public void OnLoad()
{
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -5,26 +5,15 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LibationAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
ItemsSource="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>

View File

@@ -12,24 +12,10 @@ namespace LibationAvalonia.Controls
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.CheckboxItems = Items;
base.OnPropertyChanged(change);
}
private class CheckedListBoxViewModel : ViewModelBase
{
private AvaloniaList<CheckBoxViewModel> _checkboxItems;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get => _checkboxItems; set => this.RaiseAndSetIfChanged(ref _checkboxItems, value); }
}
}

View File

@@ -43,14 +43,40 @@
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
SelectionChanged="Quality_SelectionChanged"
ItemsSource="{CompiledBinding DownloadQualities}"
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,5,0,0"
IsEnabled="{CompiledBinding SpatialSelected}"
<Grid ColumnDefinitions="*,*">
<CheckBox
IsChecked="{CompiledBinding UseWidevine, Mode=TwoWay}"
IsCheckedChanged="UseWidevine_IsCheckedChanged"
ToolTip.Tip="{CompiledBinding UseWidevineTip}">
<TextBlock Text="{CompiledBinding UseWidevineText}" />
</CheckBox>
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
ToolTip.Tip="{CompiledBinding RequestSpatialTip}"
IsEnabled="{CompiledBinding UseWidevine}"
IsChecked="{CompiledBinding RequestSpatial, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding RequestSpatialText}" />
</CheckBox>
</Grid>
<Grid ColumnDefinitions="*,Auto"
ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}">
<Grid.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<MultiBinding.Bindings>
<CompiledBinding Path="UseWidevine"/>
<CompiledBinding Path="RequestSpatial"/>
</MultiBinding.Bindings>
</MultiBinding>
</Grid.IsEnabled>
<TextBlock
VerticalAlignment="Center"
@@ -62,6 +88,11 @@
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
</Grid>
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />

View File

@@ -22,21 +22,21 @@ namespace LibationAvalonia.Controls.Settings
}
}
public async void Quality_SelectionChanged(object sender, SelectionChangedEventArgs e)
private async void UseWidevine_IsCheckedChanged(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_viewModel.SpatialSelected)
if (sender is CheckBox cbox && cbox.IsChecked is true)
{
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
{
await MessageBox.Show(VisualRoot as Window,
"Your must remove account(s) from Libation and then re-add them to enable spatial audiobook downloads.",
"Spatial Audio Unavailable",
if (VisualRoot is Window parent)
await MessageBox.Show(parent,
"Your must remove account(s) from Libation and then re-add them to enable widwvine content.",
"Widevine Content Unavailable",
MessageBoxButtons.OK);
_viewModel.FileDownloadQuality = _viewModel.DownloadQualities[1];
_viewModel.UseWidevine = false;
}
}
}

View File

@@ -114,7 +114,6 @@ Title: {title}
Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language}

View File

@@ -14,11 +14,11 @@
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
VerticalAlignment="Top">
<Panel Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
<Image IsVisible="{Binding IsAsterisk}" Stretch="None" Source="/Assets/MBIcons/Asterisk.png"/>
<Image IsVisible="{Binding IsError}" Stretch="None" Source="/Assets/MBIcons/error.png"/>
<Image IsVisible="{Binding IsQuestion}" Stretch="None" Source="/Assets/MBIcons/Question.png"/>
<Image IsVisible="{Binding IsExclamation}" Stretch="None" Source="/Assets/MBIcons/Exclamation.png"/>
<Panel Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
<Image IsVisible="{Binding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/>
<Image IsVisible="{Binding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/>
<Image IsVisible="{Binding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
<Image IsVisible="{Binding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/>
</Panel>
<TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{Binding Message}" />

View File

@@ -41,10 +41,10 @@
<None Remove="Assets\img-coverart-prod-unavailable_500x500.jpg" />
<None Remove="Assets\img-coverart-prod-unavailable_80x80.jpg" />
<None Remove="Assets\1x1.png" />
<None Remove="Assets\MBIcons\Asterisk.png" />
<None Remove="Assets\MBIcons\error.png" />
<None Remove="Assets\MBIcons\Exclamation.png" />
<None Remove="Assets\MBIcons\Question.png" />
<None Remove="Assets\MBIcons\Asterisk_64.png" />
<None Remove="Assets\MBIcons\Error_64.png" />
<None Remove="Assets\MBIcons\Exclamation_64.png" />
<None Remove="Assets\MBIcons\Question_64.png" />
</ItemGroup>
<ItemGroup>
@@ -73,14 +73,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.2.8" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.8" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.0" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -40,6 +40,12 @@ namespace LibationAvalonia.ViewModels
if (libraryBooks.Length == 1)
{
var item = libraryBooks[0];
//Remove this item from the queue if it's already present and completed.
//Only do this when adding a single book at a time to prevent accidental
//extra downloads when queueing in batches.
ProcessQueue.RemoveCompleted(item);
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
{
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);

View File

@@ -130,6 +130,11 @@ namespace LibationAvalonia.ViewModels
return true;
}
public bool RemoveCompleted(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry
&& entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry);
public void AddDownloadPdf(LibraryBook libraryBook)
=> AddDownloadPdf(new List<LibraryBook>() { libraryBook });

View File

@@ -67,6 +67,8 @@ namespace LibationAvalonia.ViewModels.Settings
FileDownloadQuality = DownloadQualities.SingleOrDefault(s => s.Value == config.FileDownloadQuality) ?? DownloadQualities[0];
SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate) ?? SampleRates[0];
SelectedEncoderQuality = config.LameEncoderQuality;
UseWidevine = config.UseWidevine;
RequestSpatial = config.RequestSpatial;
}
public void SaveSettings(Configuration config)
@@ -96,12 +98,13 @@ namespace LibationAvalonia.ViewModels.Settings
config.MaxSampleRate = SelectedSampleRate?.Value ?? config.MaxSampleRate;
config.FileDownloadQuality = FileDownloadQuality?.Value ?? config.FileDownloadQuality;
config.SpatialAudioCodec = SpatialAudioCodec?.Value ?? config.SpatialAudioCodec;
config.UseWidevine = UseWidevine;
config.RequestSpatial = RequestSpatial;
}
public AvaloniaList<EnumDisplay<Configuration.DownloadQuality>> DownloadQualities { get; } = new([
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal),
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High),
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Spatial, "Spatial (if available)"),
]);
public AvaloniaList<EnumDisplay<Configuration.SpatialCodec>> SpatialAudioCodecs { get; } = new([
new EnumDisplay<Configuration.SpatialCodec>(Configuration.SpatialCodec.EC_3, "Dolby Digital Plus (E-AC-3)"),
@@ -109,6 +112,10 @@ namespace LibationAvalonia.ViewModels.Settings
]);
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality));
public string UseWidevineText { get; } = Configuration.GetDescription(nameof(Configuration.UseWidevine));
public string UseWidevineTip { get; } = Configuration.GetHelpText(nameof(Configuration.UseWidevine));
public string RequestSpatialText { get; } = Configuration.GetDescription(nameof(Configuration.RequestSpatial));
public string RequestSpatialTip { get; } = Configuration.GetHelpText(nameof(Configuration.RequestSpatial));
public string SpatialAudioCodecText { get; } = Configuration.GetDescription(nameof(Configuration.SpatialAudioCodec));
public string SpatialAudioCodecTip { get; } = Configuration.GetHelpText(nameof(Configuration.SpatialAudioCodec));
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
@@ -133,19 +140,13 @@ namespace LibationAvalonia.ViewModels.Settings
public string RetainAaxFileTip => Configuration.GetHelpText(nameof(RetainAaxFile));
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
public bool SpatialSelected { get; private set; }
private EnumDisplay<Configuration.DownloadQuality>? _fileDownloadQuality;
public EnumDisplay<Configuration.DownloadQuality> FileDownloadQuality
{
get => _fileDownloadQuality ?? DownloadQualities[0];
set
{
SpatialSelected = value?.Value == Configuration.DownloadQuality.Spatial;
this.RaiseAndSetIfChanged(ref _fileDownloadQuality, value);
this.RaisePropertyChanged(nameof(SpatialSelected));
}
}
private bool _useWidevine;
private bool _requestSpatial;
public bool UseWidevine { get => _useWidevine; set => this.RaiseAndSetIfChanged(ref _useWidevine, value); }
public bool RequestSpatial { get => _requestSpatial; set => this.RaiseAndSetIfChanged(ref _requestSpatial, value); }
public EnumDisplay<Configuration.DownloadQuality> FileDownloadQuality { get; set; }
public EnumDisplay<Configuration.SpatialCodec> SpatialAudioCodec { get; set; }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }

View File

@@ -9,24 +9,16 @@
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="650"
Background="{DynamicResource SystemRegionColor}"
x:Class="LibationAvalonia.Views.ProcessQueueControl">
<UserControl.Resources>
<views:DecimalConverter x:Key="myConverter" />
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<views:ProcessBookControl />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<Grid RowDefinitions="*,Auto">
<TabControl Grid.Row="0">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
<Setter Property="Height" Value="33"/>
</Style>
</TabControl.Styles>
@@ -42,14 +34,13 @@
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
AllowAutoHide="False">
<ItemsRepeater IsVisible="True"
Grid.Column="0"
Name="repeater"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
Background="Transparent"
ItemsSource="{Binding Items}"
ItemTemplate="{StaticResource elementFactory}" />
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<views:ProcessBookControl DataContext="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<Grid Grid.Column="0" Grid.Row="1" ColumnDefinitions="*,Auto,Auto">

View File

@@ -89,6 +89,24 @@ namespace LibationFileManager
AC-4 cannot be converted to MP3.
""" },
{nameof(UseWidevine), """
Some audiobooks are only delivered in the highest
available quality with special, third-party content
protection. Enabling this option will make Libation
request audiobooks with Widevine DRM, which may
yield higher quality audiobook files. If they are
higher quality, however, they will also be encoded
with a somewhat uncommon codec (xHE-AAC USAC)
which you may have difficulty playing.
This must be enable to download spatial audiobooks.
""" },
{nameof(RequestSpatial), """
If selected, Libation will request audiobooks in the
Dolby Atmos 'Spatial Audio' format. Audiobooks which
don't have a spatial audio version will be download
as usual based on your other file quality settings.
""" },
}
.AsReadOnly();

View File

@@ -246,8 +246,7 @@ namespace LibationFileManager
public enum DownloadQuality
{
High,
Normal,
Spatial
Normal
}
[JsonConverter(typeof(StringEnumConverter))]
@@ -257,6 +256,12 @@ namespace LibationFileManager
AC_4
}
[Description("Use widevine DRM")]
public bool UseWidevine { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Request Spatial Audio")]
public bool RequestSpatial { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Spatial audio codec:")]
public SpatialCodec SpatialAudioCodec { get => GetNonString(defaultValue: SpatialCodec.EC_3); set => SetNonString(value); }

View File

@@ -24,10 +24,12 @@ public class ContributorDto : IFormattable
//Single-word names parse as first names. Use it as last name.
var lastName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.First : HumanName.Last;
//Because of the above, if the have only a first name, then we'd double the name as "FirstName FirstName", so clear the first name in that situation.
var firstName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.Last : HumanName.First;
return format
.Replace("{T}", HumanName.Title)
.Replace("{F}", HumanName.First)
.Replace("{F}", firstName)
.Replace("{M}", HumanName.Middle)
.Replace("{L}", lastName)
.Replace("{S}", HumanName.Suffix)

View File

@@ -27,9 +27,10 @@ public class BookDto
public bool IsPodcastParent { get; set; }
public bool IsPodcast { get; set; }
public int BitRate { get; set; }
public int SampleRate { get; set; }
public int Channels { get; set; }
public int? BitRate { get; set; }
public int? SampleRate { get; set; }
public int? Channels { get; set; }
public string? Codec { get; set; }
public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; }
public string? Language { get; set; }

View File

@@ -36,9 +36,10 @@ namespace LibationFileManager.Templates
public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)");
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "File's orig. bitrate");
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Audiobook's source bitrate");
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Audiobook's source sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Audiobook's source audio channel count");
public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audiobook's source codec");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
public static TemplateTags Locale { get; } = new("locale", "Region/country");

View File

@@ -271,9 +271,6 @@ namespace LibationFileManager.Templates
{ TemplateTags.Language, lb => lb.Language },
//Don't allow formatting of LanguageShort
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
{ TemplateTags.Bitrate, lb => (int?)(lb.IsPodcastParent ? null : lb.BitRate) },
{ TemplateTags.SampleRate, lb => (int?)(lb.IsPodcastParent ? null : lb.SampleRate) },
{ TemplateTags.Channels, lb => (int?)(lb.IsPodcastParent ? null : lb.Channels) },
{ TemplateTags.Account, lb => lb.Account },
{ TemplateTags.AccountNickname, lb => lb.AccountNickname },
{ TemplateTags.Locale, lb => lb.Locale },
@@ -281,7 +278,16 @@ namespace LibationFileManager.Templates
{ TemplateTags.DatePublished, lb => lb.DatePublished },
{ TemplateTags.DateAdded, lb => lb.DateAdded },
{ TemplateTags.FileDate, lb => lb.FileDate },
};
};
private static readonly PropertyTagCollection<LibraryBookDto> audioFilePropertyTags =
new(caseSensative: true, StringFormatter, IntegerFormatter)
{
{ TemplateTags.Bitrate, lb => lb.BitRate },
{ TemplateTags.SampleRate, lb => lb.SampleRate },
{ TemplateTags.Channels, lb => lb.Channels },
{ TemplateTags.Codec, lb => lb.Codec },
};
private static readonly List<TagCollection> chapterPropertyTags = new()
{
@@ -376,8 +382,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections
=> new TagCollection[] { filePropertyTags, conditionalTags, folderConditionalTags };
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, conditionalTags, folderConditionalTags];
public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
@@ -396,7 +401,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = new TagCollection[] { filePropertyTags, conditionalTags };
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags];
}
public class ChapterFileTemplate : Templates, ITemplate
@@ -404,7 +409,8 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Chapter File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags);
public static IEnumerable<TagCollection> TagCollections { get; }
= chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags);
public override IEnumerable<string> Warnings
=> NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))

View File

@@ -49,7 +49,6 @@ Title: {title}
Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {_libraryBook.DateAdded:d}
Language: {Book.Language}

View File

@@ -30,7 +30,8 @@ namespace LibationWinForms.Dialogs
return;
System.Media.SystemSounds.Hand.Play();
pictureBox1.Image = SystemIcons.Error.ToBitmap();
//This is a different (and newer) icon from SystemIcons.Error
pictureBox1.Image = SystemIcons.GetStockIcon(StockIconId.Error).ToBitmap();
}
private void githubLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)

View File

@@ -23,6 +23,8 @@ namespace LibationWinForms.Dialogs
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
this.moveMoovAtomCbox.Text = desc(nameof(config.MoveMoovToBeginning));
this.useWidevineCbox.Text = desc(nameof(config.UseWidevine));
this.requestSpatialCbox.Text = desc(nameof(config.RequestSpatial));
this.spatialCodecLbl.Text = desc(nameof(config.SpatialAudioCodec));
toolTip.SetToolTip(combineNestedChapterTitlesCbox, Configuration.GetHelpText(nameof(config.CombineNestedChapterTitles)));
@@ -34,6 +36,8 @@ namespace LibationWinForms.Dialogs
toolTip.SetToolTip(mergeOpeningEndCreditsCbox, Configuration.GetHelpText(nameof(config.MergeOpeningAndEndCredits)));
toolTip.SetToolTip(retainAaxFileCbox, Configuration.GetHelpText(nameof(config.RetainAaxFile)));
toolTip.SetToolTip(stripAudibleBrandingCbox, Configuration.GetHelpText(nameof(config.StripAudibleBrandAudio)));
toolTip.SetToolTip(useWidevineCbox, Configuration.GetHelpText(nameof(config.UseWidevine)));
toolTip.SetToolTip(requestSpatialCbox, Configuration.GetHelpText(nameof(config.RequestSpatial)));
toolTip.SetToolTip(spatialCodecLbl, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
toolTip.SetToolTip(spatialAudioCodecCb, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
@@ -41,7 +45,6 @@ namespace LibationWinForms.Dialogs
[
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal),
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High),
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Spatial, "Spatial (if available)"),
]);
spatialAudioCodecCb.Items.AddRange(
@@ -76,6 +79,8 @@ namespace LibationWinForms.Dialogs
downloadClipsBookmarksCbox.Checked = config.DownloadClipsBookmarks;
fileDownloadQualityCb.SelectedItem = config.FileDownloadQuality;
spatialAudioCodecCb.SelectedItem = config.SpatialAudioCodec;
useWidevineCbox.Checked = config.UseWidevine;
requestSpatialCbox.Checked = config.RequestSpatial;
clipsBookmarksFormatCb.SelectedItem = config.ClipsBookmarksFileFormat;
retainAaxFileCbox.Checked = config.RetainAaxFile;
@@ -118,6 +123,8 @@ namespace LibationWinForms.Dialogs
config.DownloadCoverArt = downloadCoverArtCbox.Checked;
config.DownloadClipsBookmarks = downloadClipsBookmarksCbox.Checked;
config.FileDownloadQuality = ((EnumDisplay<Configuration.DownloadQuality>)fileDownloadQualityCb.SelectedItem).Value;
config.UseWidevine = useWidevineCbox.Checked;
config.RequestSpatial = requestSpatialCbox.Checked;
config.SpatialAudioCodec = ((EnumDisplay<Configuration.SpatialCodec>)spatialAudioCodecCb.SelectedItem).Value;
config.ClipsBookmarksFileFormat = (Configuration.ClipBookmarkFormat)clipsBookmarksFormatCb.SelectedItem;
config.RetainAaxFile = retainAaxFileCbox.Checked;
@@ -140,7 +147,6 @@ namespace LibationWinForms.Dialogs
config.ChapterTitleTemplate = chapterTitleTemplateTb.Text;
}
private void downloadClipsBookmarksCbox_CheckedChanged(object sender, EventArgs e)
{
clipsBookmarksFormatCb.Enabled = downloadClipsBookmarksCbox.Checked;
@@ -190,27 +196,28 @@ namespace LibationWinForms.Dialogs
}
}
private void fileDownloadQualityCb_SelectedIndexChanged(object sender, EventArgs e)
{
var selectedSpatial = fileDownloadQualityCb.SelectedItem.Equals(Configuration.DownloadQuality.Spatial);
if (selectedSpatial)
private void useWidevineCbox_CheckedChanged(object sender, EventArgs e)
{
if (useWidevineCbox.Checked)
{
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
{
MessageBox.Show(this,
"Your must remove account(s) from Libation and then re-add them to enable spatial audiobook downloads.",
"Spatial Audio Unavailable",
"Your must remove account(s) from Libation and then re-add them to enable widwvine content.",
"Widevine Content Unavailable",
MessageBoxButtons.OK);
fileDownloadQualityCb.SelectedItem = Configuration.DownloadQuality.High;
useWidevineCbox.Checked = false;
return;
}
}
spatialCodecLbl.Enabled = spatialAudioCodecCb.Enabled = selectedSpatial;
requestSpatialCbox.Enabled = useWidevineCbox.Checked;
spatialCodecLbl.Enabled = spatialAudioCodecCb.Enabled = useWidevineCbox.Checked && requestSpatialCbox.Checked;
}
}
}

View File

@@ -84,6 +84,8 @@
folderTemplateTb = new System.Windows.Forms.TextBox();
folderTemplateLbl = new System.Windows.Forms.Label();
tab4AudioFileOptions = new System.Windows.Forms.TabPage();
requestSpatialCbox = new System.Windows.Forms.CheckBox();
useWidevineCbox = new System.Windows.Forms.CheckBox();
spatialAudioCodecCb = new System.Windows.Forms.ComboBox();
spatialCodecLbl = new System.Windows.Forms.Label();
moveMoovAtomCbox = new System.Windows.Forms.CheckBox();
@@ -306,7 +308,7 @@
allowLibationFixupCbox.AutoSize = true;
allowLibationFixupCbox.Checked = true;
allowLibationFixupCbox.CheckState = System.Windows.Forms.CheckState.Checked;
allowLibationFixupCbox.Location = new System.Drawing.Point(19, 205);
allowLibationFixupCbox.Location = new System.Drawing.Point(19, 230);
allowLibationFixupCbox.Name = "allowLibationFixupCbox";
allowLibationFixupCbox.Size = new System.Drawing.Size(162, 19);
allowLibationFixupCbox.TabIndex = 11;
@@ -772,6 +774,8 @@
// tab4AudioFileOptions
//
tab4AudioFileOptions.AutoScroll = true;
tab4AudioFileOptions.Controls.Add(requestSpatialCbox);
tab4AudioFileOptions.Controls.Add(useWidevineCbox);
tab4AudioFileOptions.Controls.Add(spatialAudioCodecCb);
tab4AudioFileOptions.Controls.Add(spatialCodecLbl);
tab4AudioFileOptions.Controls.Add(moveMoovAtomCbox);
@@ -798,11 +802,38 @@
tab4AudioFileOptions.Text = "Audio File Options";
tab4AudioFileOptions.UseVisualStyleBackColor = true;
//
// requestSpatialCbox
//
requestSpatialCbox.AutoSize = true;
requestSpatialCbox.CheckAlign = System.Drawing.ContentAlignment.MiddleRight;
requestSpatialCbox.Checked = true;
requestSpatialCbox.CheckState = System.Windows.Forms.CheckState.Checked;
requestSpatialCbox.Location = new System.Drawing.Point(284, 35);
requestSpatialCbox.Name = "requestSpatialCbox";
requestSpatialCbox.Size = new System.Drawing.Size(138, 19);
requestSpatialCbox.TabIndex = 29;
requestSpatialCbox.Text = "[RequestSpatial desc]";
requestSpatialCbox.UseVisualStyleBackColor = true;
requestSpatialCbox.CheckedChanged += useWidevineCbox_CheckedChanged;
//
// useWidevineCbox
//
useWidevineCbox.AutoSize = true;
useWidevineCbox.Checked = true;
useWidevineCbox.CheckState = System.Windows.Forms.CheckState.Checked;
useWidevineCbox.Location = new System.Drawing.Point(19, 35);
useWidevineCbox.Name = "useWidevineCbox";
useWidevineCbox.Size = new System.Drawing.Size(129, 19);
useWidevineCbox.TabIndex = 28;
useWidevineCbox.Text = "[UseWidevine desc]";
useWidevineCbox.UseVisualStyleBackColor = true;
useWidevineCbox.CheckedChanged += useWidevineCbox_CheckedChanged;
//
// spatialAudioCodecCb
//
spatialAudioCodecCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
spatialAudioCodecCb.FormattingEnabled = true;
spatialAudioCodecCb.Location = new System.Drawing.Point(249, 35);
spatialAudioCodecCb.Location = new System.Drawing.Point(249, 60);
spatialAudioCodecCb.Margin = new System.Windows.Forms.Padding(3, 3, 5, 3);
spatialAudioCodecCb.Name = "spatialAudioCodecCb";
spatialAudioCodecCb.Size = new System.Drawing.Size(173, 23);
@@ -811,7 +842,7 @@
// spatialCodecLbl
//
spatialCodecLbl.AutoSize = true;
spatialCodecLbl.Location = new System.Drawing.Point(19, 37);
spatialCodecLbl.Location = new System.Drawing.Point(19, 62);
spatialCodecLbl.Name = "spatialCodecLbl";
spatialCodecLbl.Size = new System.Drawing.Size(143, 15);
spatialCodecLbl.TabIndex = 24;
@@ -836,7 +867,6 @@
fileDownloadQualityCb.Name = "fileDownloadQualityCb";
fileDownloadQualityCb.Size = new System.Drawing.Size(130, 23);
fileDownloadQualityCb.TabIndex = 1;
fileDownloadQualityCb.SelectedIndexChanged += fileDownloadQualityCb_SelectedIndexChanged;
//
// fileDownloadQualityLbl
//
@@ -851,7 +881,7 @@
// combineNestedChapterTitlesCbox
//
combineNestedChapterTitlesCbox.AutoSize = true;
combineNestedChapterTitlesCbox.Location = new System.Drawing.Point(19, 181);
combineNestedChapterTitlesCbox.Location = new System.Drawing.Point(19, 206);
combineNestedChapterTitlesCbox.Name = "combineNestedChapterTitlesCbox";
combineNestedChapterTitlesCbox.Size = new System.Drawing.Size(217, 19);
combineNestedChapterTitlesCbox.TabIndex = 10;
@@ -862,7 +892,7 @@
//
clipsBookmarksFormatCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
clipsBookmarksFormatCb.FormattingEnabled = true;
clipsBookmarksFormatCb.Location = new System.Drawing.Point(285, 107);
clipsBookmarksFormatCb.Location = new System.Drawing.Point(285, 132);
clipsBookmarksFormatCb.Name = "clipsBookmarksFormatCb";
clipsBookmarksFormatCb.Size = new System.Drawing.Size(67, 23);
clipsBookmarksFormatCb.TabIndex = 6;
@@ -870,7 +900,7 @@
// downloadClipsBookmarksCbox
//
downloadClipsBookmarksCbox.AutoSize = true;
downloadClipsBookmarksCbox.Location = new System.Drawing.Point(19, 109);
downloadClipsBookmarksCbox.Location = new System.Drawing.Point(19, 134);
downloadClipsBookmarksCbox.Name = "downloadClipsBookmarksCbox";
downloadClipsBookmarksCbox.Size = new System.Drawing.Size(248, 19);
downloadClipsBookmarksCbox.TabIndex = 5;
@@ -883,7 +913,7 @@
audiobookFixupsGb.Controls.Add(splitFilesByChapterCbox);
audiobookFixupsGb.Controls.Add(stripUnabridgedCbox);
audiobookFixupsGb.Controls.Add(stripAudibleBrandingCbox);
audiobookFixupsGb.Location = new System.Drawing.Point(6, 229);
audiobookFixupsGb.Location = new System.Drawing.Point(6, 254);
audiobookFixupsGb.Name = "audiobookFixupsGb";
audiobookFixupsGb.Size = new System.Drawing.Size(416, 114);
audiobookFixupsGb.TabIndex = 19;
@@ -1324,7 +1354,7 @@
// mergeOpeningEndCreditsCbox
//
mergeOpeningEndCreditsCbox.AutoSize = true;
mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 157);
mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 182);
mergeOpeningEndCreditsCbox.Name = "mergeOpeningEndCreditsCbox";
mergeOpeningEndCreditsCbox.Size = new System.Drawing.Size(198, 19);
mergeOpeningEndCreditsCbox.TabIndex = 9;
@@ -1334,7 +1364,7 @@
// retainAaxFileCbox
//
retainAaxFileCbox.AutoSize = true;
retainAaxFileCbox.Location = new System.Drawing.Point(19, 133);
retainAaxFileCbox.Location = new System.Drawing.Point(19, 158);
retainAaxFileCbox.Name = "retainAaxFileCbox";
retainAaxFileCbox.Size = new System.Drawing.Size(131, 19);
retainAaxFileCbox.TabIndex = 8;
@@ -1347,7 +1377,7 @@
downloadCoverArtCbox.AutoSize = true;
downloadCoverArtCbox.Checked = true;
downloadCoverArtCbox.CheckState = System.Windows.Forms.CheckState.Checked;
downloadCoverArtCbox.Location = new System.Drawing.Point(19, 85);
downloadCoverArtCbox.Location = new System.Drawing.Point(19, 110);
downloadCoverArtCbox.Name = "downloadCoverArtCbox";
downloadCoverArtCbox.Size = new System.Drawing.Size(162, 19);
downloadCoverArtCbox.TabIndex = 4;
@@ -1360,7 +1390,7 @@
createCueSheetCbox.AutoSize = true;
createCueSheetCbox.Checked = true;
createCueSheetCbox.CheckState = System.Windows.Forms.CheckState.Checked;
createCueSheetCbox.Location = new System.Drawing.Point(19, 61);
createCueSheetCbox.Location = new System.Drawing.Point(19, 86);
createCueSheetCbox.Name = "createCueSheetCbox";
createCueSheetCbox.Size = new System.Drawing.Size(145, 19);
createCueSheetCbox.TabIndex = 3;
@@ -1531,5 +1561,7 @@
private System.Windows.Forms.Button applyDisplaySettingsBtn;
private System.Windows.Forms.ComboBox spatialAudioCodecCb;
private System.Windows.Forms.Label spatialCodecLbl;
private System.Windows.Forms.CheckBox useWidevineCbox;
private System.Windows.Forms.CheckBox requestSpatialCbox;
}
}

View File

@@ -30,6 +30,12 @@ namespace LibationWinForms
if (libraryBooks.Length == 1)
{
var item = libraryBooks[0];
//Remove this item from the queue if it's already present and completed.
//Only do this when adding a single book at a time to prevent accidental
//extra downloads when queueing in batches.
processBookQueue1.RemoveCompleted(item);
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
{
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);

View File

@@ -92,6 +92,11 @@ namespace LibationWinForms.ProcessQueue
return true;
}
public bool RemoveCompleted(DataLayer.LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBook entry
&& entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry);
public void AddDownloadPdf(DataLayer.LibraryBook libraryBook)
=> AddDownloadPdf(new List<DataLayer.LibraryBook>() { libraryBook });

View File

@@ -26,7 +26,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3240.44" />
</ItemGroup>
<ItemGroup>

View File

@@ -284,7 +284,7 @@ namespace TemplatesTests
[DataRow("Carla Naumburg PhD", "Title=, First=Carla, Middle= Last=Naumburg, Suffix=PhD")]
[DataRow("Doug Stanhope and Friends", "Title=, First=Doug, Middle= Last=Stanhope and Friends, Suffix=")]
[DataRow("Tamara Lovatt-Smith", "Title=, First=Tamara, Middle= Last=Lovatt-Smith, Suffix=")]
[DataRow("Common", "Title=, First=Common, Middle= Last=Common, Suffix=")]
[DataRow("Common", "Title=, First=, Middle= Last=Common, Suffix=")]
[DataRow("Doug Tisdale Jr.", "Title=, First=Doug, Middle= Last=Tisdale, Suffix=Jr")]
[DataRow("Robert S. Mueller III", "Title=, First=Robert, Middle=S. Last=Mueller, Suffix=III")]
[DataRow("Frank T Vertosick Jr. MD", "Title=, First=Frank, Middle=T Last=Vertosick, Suffix=Jr. MD")]