mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-02-18 00:17:43 +01:00
Add feature to scan library for higher quality books
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
Codec"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding BitrateString}"
|
||||
Header="Existing
Bitrate"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding AvailableCodec}"
|
||||
Header="Available
Codec"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding AvailableBitrateString}"
|
||||
Header="Available
Bitrate"/>
|
||||
|
||||
<DataGridCheckBoxColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding IsSignificant}"
|
||||
Header="Significantly
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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user