Add feature to scan library for higher quality books

This commit is contained in:
Mbucari
2025-12-28 20:56:29 -07:00
committed by MBucari
parent c91d359017
commit 31087c0855
7 changed files with 375 additions and 4 deletions

View File

@@ -86,7 +86,11 @@ public class MockLibraryBook : LibraryBook
string localeName = "us",
bool isAbridged = false,
bool isSpatial = false,
string language = "English")
string language = "English",
LiberatedStatus bookStatus = LiberatedStatus.Liberated,
LiberatedStatus? pdfStatus = null,
AudioFormat? lastDlFormat = null,
Version? lastDlVersion = null)
{
var book = new Book(
new AudibleProductId(CalculateAsin(title + subtitle)),
@@ -99,6 +103,12 @@ public class MockLibraryBook : LibraryBook
[new Contributor(firstNarrator, CalculateAsin(firstNarrator))],
localeName);
lastDlFormat ??= new AudioFormat(Codec.AAC_LC, 128, 44100, 2);
lastDlVersion ??= new Version(13, 0);
book.UserDefinedItem.SetLastDownloaded(lastDlVersion, lastDlFormat, "1");
book.UserDefinedItem.PdfStatus = pdfStatus;
book.UserDefinedItem.BookStatus = bookStatus;
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
return new MockLibraryBook(

View File

@@ -0,0 +1,85 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
Width="800" Height="450"
x:Class="LibationAvalonia.Dialogs.FindBetterQualityBooksDialog"
x:DataType="dialogs:FindBetterQualityBooksDialog+ScanVM"
Title="FindBetterQualityBooksDialog">
<Grid Margin="5" RowDefinitions="*,Auto">
<DataGrid
Name="booksDataGrid"
GridLinesVisibility="All"
CanUserReorderColumns="False"
CanUserResizeColumns="False"
CanUserSortColumns="False"
AutoGenerateColumns="False"
IsReadOnly="True"
ItemsSource="{CompiledBinding Books}">
<DataGrid.Styles>
<Style x:DataType="dialogs:FindBetterQualityBooksDialog+BookData" Selector="DataGridRow">
<Setter Property="Background" Value="{CompiledBinding ScanStatus, Converter={x:Static dialogs:FindBetterQualityBooksDialog.RowConverter }}" />
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTextColumn
Width="120"
IsReadOnly="False"
Binding="{CompiledBinding Asin}"
Header="ASIN"/>
<DataGridTextColumn
Width="*"
IsReadOnly="True"
Binding="{CompiledBinding Title}"
Header="Title"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding Codec}"
Header="Existing&#xa;Codec"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding BitrateString}"
Header="Existing&#xa;Bitrate"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding AvailableCodec}"
Header="Available&#xa;Codec"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding AvailableBitrateString}"
Header="Available&#xa;Bitrate"/>
<DataGridCheckBoxColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding IsSignificant}"
Header="Significantly&#xa;Greater?"/>
</DataGrid.Columns>
</DataGrid>
<Grid Margin="0,5,0,0" Grid.Row="1"
ColumnDefinitions="Auto,Auto,*,Auto">
<CheckBox IsChecked="{Binding ScanWidevine, Mode=TwoWay}" Content="Use Widevine?" Margin="0,0,5,0" />
<Button Grid.Column="1" Classes="SaveButton" Content="Scan Audible for Higher Quality Audio" IsVisible="{Binding !IsScanning}" Command="{Binding ScanAsync}" />
<Button Grid.Column="1" Classes="SaveButton" Content="Stop Scan" IsVisible="{Binding IsScanning}" Command="{Binding StopScan}" />
<Button Grid.Column="3" Classes="SaveButton" Content="{Binding MarkBooksButtonText}"
IsVisible="{Binding SignificantCount}" Command="{Binding MarkBooksAsync}" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,253 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using Avalonia.Media;
using DataLayer;
using Dinah.Core.Net.Http;
using DynamicData;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.Dialogs;
public partial class FindBetterQualityBooksDialog : DialogWindow
{
public enum ScanStatus
{
None,
Error,
Cancelled,
Completed,
}
private ScanVM VM { get; }
private Dictionary<BookData, IDisposable> Observers { get; } = new();
public FindBetterQualityBooksDialog()
{
InitializeComponent();
if (Design.IsDesignMode)
{
var library = Enumerable.Repeat(MockLibraryBook.CreateBook(), 3);
DataContext = VM = new ScanVM(library);
VM.Books[0].AvailableCodec = "xHE-AAC";
VM.Books[0].AvailableBitrate = 256;
VM.Books[0].ScanStatus = ScanStatus.Completed;
VM.Books[1].ScanStatus = ScanStatus.Error;
VM.Books[2].ScanStatus = ScanStatus.Cancelled;
VM.SignificantCount = 1;
}
else
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
DataContext = VM = new ScanVM(library.Where(ShouldScan));
}
VM.Books.ForEachItem(OnBookDataAdded, OnBookDataRemoved, OnBooksReset);
}
private static bool ShouldScan(LibraryBook lb)
=> lb.Book.ContentType is ContentType.Product //only scan books, not podcasts
&& !lb.Book.IsSpatial //skip spatial audio books. When querying the /metadata endpoint, it will only show ac-4 data for spatial audiobooks.
&& lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated //only check if the book is liberated
&& lb.Book.UserDefinedItem.LastDownloadedFormat.Codec is not Codec.Mp3 //If they downloaded as mp3, no way to tell what source material was. Skip.
&& lb.Book.AudioExists; //only check if audio files exist
private void OnBookDataAdded(BookData bookData)
{
var subscriber = bookData.ObservableForProperty(x => x.ScanStatus)
.Subscribe(v => booksDataGrid.ScrollIntoView(v.Sender, booksDataGrid.Columns[0]));
Observers[bookData] = subscriber;
}
private void OnBookDataRemoved(BookData bookData)
{
if (Observers.TryGetValue(bookData, out var subscriber))
{
subscriber.Dispose();
Observers.Remove(bookData);
}
}
private void OnBooksReset()
{
foreach (var subscriber in Observers.Values)
{
subscriber.Dispose();
}
Observers.Clear();
}
public static FuncValueConverter<ScanStatus, IBrush?> RowConverter { get; } = new(status =>
{
var brush = status switch
{
ScanStatus.Completed => "ProcessQueueBookCompletedBrush",
ScanStatus.Cancelled => "ProcessQueueBookCancelledBrush",
ScanStatus.Error => "ProcessQueueBookFailedBrush",
_ => null,
};
return brush is not null && App.Current.TryGetResource(brush, App.Current.ActualThemeVariant, out var res) ? res as Brush : null;
});
public class ScanVM : ViewModelBase
{
public AvaloniaList<BookData> Books { get; }
public bool ScanWidevine { get; set; }
public int SignificantCount
{
get => field;
set
{
this.RaiseAndSetIfChanged(ref field, value);
MarkBooksButtonText = value == 0 ? string.Empty
: value == 1 ? "Mark 1 book as 'Not Liberated'"
: $"Mark {value} books as 'Not Liberated'";
}
}
public bool IsScanning { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? MarkBooksButtonText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
private CancellationTokenSource? cts;
public ScanVM(IEnumerable<LibraryBook> books)
{
Books = new(books.Select(lb => new BookData(lb)));
ScanWidevine = Configuration.Instance.UseWidevine;
}
public void StopScan()
{
cts?.Cancel();
}
public async Task MarkBooksAsync()
{
var significant = Books.Where(b => b.IsSignificant).ToArray();
await significant.Select(b => b.LibraryBook).UpdateBookStatusAsync(LiberatedStatus.NotLiberated);
Books.RemoveMany(significant);
SignificantCount = Books.Count(b => b.IsSignificant);
}
public async Task ScanAsync()
{
if (cts?.IsCancellationRequested is true || Design.IsDesignMode)
return;
IsScanning = true;
try
{
using var cli = new HttpClient();
cts = new CancellationTokenSource();
foreach (var b in Books.Where(b => b.ScanStatus is not ScanStatus.Completed))
{
var url = GetUrl(b.LibraryBook);
try
{
var resp = await cli.GetAsync(url, cts.Token);
var (codecString, bitrate) = await ReadAudioInfoAsync(resp.EnsureSuccessStatusCode());
b.AvailableBitrate = bitrate;
b.AvailableCodec = codecString;
b.ScanStatus = ScanStatus.Completed;
}
catch (OperationCanceledException)
{
b.ScanStatus = ScanStatus.Cancelled;
break;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error checking for better quality for {@Asin}", b.Asin);
b.ScanStatus = ScanStatus.Error;
}
finally
{
SignificantCount = Books.Count(b => b.IsSignificant);
}
}
}
finally
{
cts?.Dispose();
cts = null;
IsScanning = false;
}
}
static async Task<(string codec, int bitrate)> ReadAudioInfoAsync(HttpResponseMessage response)
{
var data = await response.Content.ReadAsJObjectAsync();
var totalLengthMs = data["content_metadata"]?["chapter_info"]?.Value<long>("runtime_length_ms") ?? throw new InvalidDataException("Missing runtime length");
var contentReference = data["content_metadata"]?["content_reference"];
var totalSize = contentReference?.Value<long>("content_size_in_bytes") ?? throw new InvalidDataException("Missing content size");
var codec = contentReference?.Value<string>("codec") ?? throw new InvalidDataException("Missing codec name");
var codecString = codec switch
{
AudibleApi.Codecs.AAC_LC => "AAC-LC",
AudibleApi.Codecs.xHE_AAC => "xHE-AAC",
AudibleApi.Codecs.EC_3 => "EC-3",
AudibleApi.Codecs.AC_4 => "AC-4",
_ => codec,
};
var bitrate = (int)(totalSize / 1024d * 1000 / totalLengthMs * 8); // in kbps
return (codecString, bitrate);
}
string GetUrl(LibraryBook libraryBook)
{
var drm_type = ScanWidevine ? "Widevine" : "Adrm";
var locale = AudibleApi.Localization.Get(libraryBook.Book.Locale);
return string.Format(BaseUrl, locale.TopDomain, libraryBook.Book.AudibleProductId, drm_type);
}
const string BaseUrl = "ht" + "tps://api.audible.{0}/1.0/content/{1}/metadata?response_groups=chapter_info,content_reference&quality=High&drm_type={2}";
}
public class BookData : ViewModelBase
{
public LibraryBook LibraryBook { get; }
public BookData(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Asin = libraryBook.Book.AudibleProductId;
Title = libraryBook.Book.Title;
Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat.CodecString;
Bitrate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat.BitRate;
BitrateString = GetBitrateString(Bitrate);
}
public string Asin { get; }
public string Title { get; }
public string Codec { get; }
public int Bitrate { get; }
public string BitrateString { get; }
public string? AvailableCodec { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public int AvailableBitrate
{
get => field;
set
{
this.RaiseAndSetIfChanged(ref field, value);
AvailableBitrateString = GetBitrateString(value);
var diff = (double)AvailableBitrate / Bitrate;
IsSignificant = diff >= 1.15;
}
}
public string? AvailableBitrateString { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
public bool IsSignificant { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
public ScanStatus ScanStatus { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
private static string GetBitrateString(int bitrate) => $"{bitrate} kbps";
}
}

View File

@@ -9,6 +9,7 @@ namespace LibationAvalonia.ViewModels
{
partial class MainVM
{
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
private void Configure_Settings()
{
@@ -21,6 +22,7 @@ namespace LibationAvalonia.ViewModels
public Task ShowAccountsAsync() => new LibationAvalonia.Dialogs.AccountsDialog().ShowDialog(MainWindow);
public Task ShowSettingsAsync() => new LibationAvalonia.Dialogs.SettingsDialog().ShowDialog(MainWindow);
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
public void LaunchHangover()
{

View File

@@ -154,6 +154,8 @@
<MenuItem Command="{CompiledBinding ShowTrashBinAsync}" Header="Trash Bin" />
<MenuItem Command="{CompiledBinding LaunchHangover}" Header="Launch _Hangover" />
<Separator />
<MenuItem Command="{CompiledBinding ShowFindBetterQualityBooksAsync}" Header="Scan for Better Quality Audiobooks" ToolTip.Tip="{CompiledBinding FindBetterQualityBooksTip}"/>
<Separator />
<MenuItem Command="{CompiledBinding StartWalkthroughAsync}" Header="Take a Guided _Tour of Libation" />
<MenuItem Command="{CompiledBinding ShowAboutAsync}" Header="A_bout..." />
</MenuItem>

View File

@@ -11,17 +11,27 @@ namespace LibationCli.Options;
[Verb("search", HelpText = "Search for books in your library")]
internal class SearchOptions : OptionsBase
{
[Option('n', Default = 10, HelpText = "Number of search results per page")]
[Option('n', Default = 10, HelpText = "Number of search results per page (0 shows all results)")]
public int NumResultsPerPage { get; set; }
[Value(0, MetaName = "query", Required = true, HelpText = "Lucene search string")]
public IEnumerable<string> Query { get; set; }
[Option('b', "bare", HelpText = "Print bare list of ASINs without titles")]
public bool Bare { get; set; }
protected override Task ProcessAsync()
{
var query = string.Join(" ", Query).Trim('\"');
var results = SearchEngineCommands.Search(query).Docs.ToList();
if (NumResultsPerPage == 0)
{
Console.WriteLine(string.Join(Environment.NewLine, results.Select(r => getDocDisplay(r.Doc))));
return Task.CompletedTask;
}
Console.WriteLine($"Found {results.Count} matching results.");
string nextPrompt = "Press any key for the next " + NumResultsPerPage + " results or Esc for all results";
@@ -47,10 +57,12 @@ internal class SearchOptions : OptionsBase
return Task.CompletedTask;
}
private static string getDocDisplay(Lucene.Net.Documents.Document doc)
private string getDocDisplay(Lucene.Net.Documents.Document doc)
{
var title = doc.GetField("title");
var id = doc.GetField("_ID_");
if (Bare)
return id.StringValue;
var title = doc.GetField("title");
return $"[{id.StringValue}] - {title.StringValue}";
}
}

View File

@@ -119,6 +119,13 @@ namespace LibationFileManager
Libation's database (through an Audible account
scan) for a matching audio file to be found.
""" },
{"FindBetterQualityBooks","""
For all liberated audiobooks in your library, scan
Audible's servers to see if a higher-quality audio
format exists. If a better quality format is found,
you can choose to mark those books for re
-download.
""" },
{"LocateAudiobooksDialog","""
Libation will search all .m4b and .mp3 files in a folder, looking for audio files belonging to library books in Libation's database.