Merge pull request #1869 from rmcrackan/rmcrackan/1867-duplicate-asin

#1867 - Fix duplicate-ASIN crashes. New Hangover recovery
This commit is contained in:
rmcrackan
2026-06-12 11:52:42 -04:00
committed by GitHub
31 changed files with 1513 additions and 266 deletions

View File

@@ -132,9 +132,9 @@ public static class DbContexts
return context.GetDeletedLibraryBooks();
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
using var context = GetContext();
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
}
}

View File

@@ -12,6 +12,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using static DtoImporterService.PerfLogger;
@@ -23,7 +24,12 @@ public static class LibraryCommands
public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
/// <summary>
/// Serializes library scan and import operations so only one path reads/writes
/// <see cref="Book"/> / <see cref="LibraryBook"/> rows at a time (prevents duplicate ASIN inserts).
/// </summary>
private static readonly SemaphoreSlim ImportGate = new(1, 1);
static LibraryCommands()
{
@@ -35,67 +41,69 @@ public static class LibraryCommands
{
logRestart();
lock (_lock)
{
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (accounts is null || accounts.Length == 0)
return new List<LibraryBook>();
await ImportGate.WaitAsync();
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
ScanBegin?.Invoke(null, accounts.Length);
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
ImportGate.Release();
}
}
@@ -107,79 +115,93 @@ public static class LibraryCommands
if (accounts is null || accounts.Length == 0)
return (0, 0);
await ImportGate.WaitAsync();
int newCount = 0;
try
{
lock (_lock)
{
if (Scanning)
return (0, 0);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
try
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
| LibraryOptions.ResponseGroupOptions.IsFinished,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
| LibraryOptions.ResponseGroupOptions.IsFinished,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
if (totalCount == 0)
return default;
if (totalCount == 0)
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
ImportGate.Release();
}
}
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
{
await ImportGate.WaitAsync();
try
{
return importSingleToDb(item, accountId, localeName);
}
finally
{
ImportGate.Release();
}
}
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
{
ArgumentValidator.EnsureNotNull(item, nameof(item));

View File

@@ -33,7 +33,7 @@ public static class LibraryBookQueries
.AsEnumerable()
.ToList();
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
var libraryQuery
= context
@@ -41,8 +41,14 @@ public static class LibraryBookQueries
.AsNoTrackingWithIdentityResolution()
.GetLibrary();
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
var matches = caseSensative
? libraryQuery.Where(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.Where(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
if (account is not null)
matches = matches.Where(lb => lb.Account == account);
return matches.FirstOrDefault();
}
public List<LibraryBook> GetUnLiberated_Flat_NoTracking()

View File

@@ -26,7 +26,9 @@ public static class AudioFileStorageExt
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(
series.Series.AudibleSeriesId,
account: libraryBook.Account);
if (seriesParent is not null)
{
return Templates.Folder.GetFilename(seriesParent.ToDto(), books, "");

View File

@@ -0,0 +1,61 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using HangoverBase;
using System.Threading.Tasks;
namespace HangoverAvalonia;
internal static class HangoverMutationConfirm
{
public static async Task<bool> ConfirmAsync(Window owner, string actionDescription)
{
bool? result = null;
var yesButton = new Button { Content = "Yes", MinWidth = 75 };
var noButton = new Button { Content = "No", MinWidth = 75 };
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 10,
Margin = new Thickness(0, 15, 0, 0),
Children = { noButton, yesButton },
};
var dialog = new Window
{
Title = HangoverDbMutation.ConfirmTitle,
Width = 420,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
Content = new Grid
{
RowDefinitions = new RowDefinitions("*,Auto"),
Margin = new Thickness(10),
Children =
{
new TextBlock
{
[Grid.RowProperty] = 0,
Text = HangoverDbMutation.BuildConfirmMessage(actionDescription),
TextWrapping = TextWrapping.Wrap,
},
buttonPanel,
},
},
};
Grid.SetRow(buttonPanel, 1);
yesButton.Click += (_, _) => { result = true; dialog.Close(); };
noButton.Click += (_, _) => { result = false; dialog.Close(); };
dialog.Closed += (_, _) => result ??= false;
await dialog.ShowDialog(owner);
return result == true;
}
}

View File

@@ -1,5 +1,7 @@
using HangoverBase;
using ReactiveUI;
using System;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
@@ -10,26 +12,78 @@ public partial class MainVM
private string _databaseFileText;
private bool _databaseFound;
private string _sqlResults;
private string _duplicateResults;
private string _duplicateAsinStatusText;
private bool _canRemoveDuplicateAsins;
private bool _confirmRemoveDuplicateAsins;
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
public string SqlQuery { get; set; }
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
public string DuplicateResults { get => _duplicateResults; set => this.RaiseAndSetIfChanged(ref _duplicateResults, value); }
public string DuplicateAsinStatusText { get => _duplicateAsinStatusText; set => this.RaiseAndSetIfChanged(ref _duplicateAsinStatusText, value); }
public bool CanRemoveDuplicateAsins { get => _canRemoveDuplicateAsins; set => this.RaiseAndSetIfChanged(ref _canRemoveDuplicateAsins, value); }
public bool ConfirmRemoveDuplicateAsins
{
get => _confirmRemoveDuplicateAsins;
set => this.RaiseAndSetIfChanged(ref _confirmRemoveDuplicateAsins, value);
}
private void Load_databaseVM()
{
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s));
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s, s => DuplicateResults = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)
{
DatabaseFileText = $"Database file not found";
DuplicateAsinStatusText = "Duplicate ASIN cleanup unavailable (database not found).";
DatabaseFound = false;
CanRemoveDuplicateAsins = false;
return;
}
DatabaseFileText = $"Database file: {_tab.DbFile}";
DatabaseFound = true;
RefreshDuplicateAsinStatus();
}
public void ExecuteQuery() => _tab.ExecuteQuery();
public async Task ExecuteQueryAsync()
{
if (HangoverDbMutation.IsMutatingSql(SqlQuery)
&& ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverDbMutation.SqlMutatingDescription))
return;
_tab.ExecuteQuery();
}
public void RefreshDuplicateAsinStatus()
{
DuplicateAsinStatusText = _tab.GetDuplicateAsinStatusText();
CanRemoveDuplicateAsins = _tab.CanRemoveDuplicateAsins();
if (!CanRemoveDuplicateAsins)
ConfirmRemoveDuplicateAsins = false;
}
public void ScanDuplicateAsins()
{
_tab.ScanDuplicateAsins();
RefreshDuplicateAsinStatus();
}
public async Task RemoveDuplicateAsinsAsync()
{
if (!ConfirmRemoveDuplicateAsins)
return;
if (ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverDbMutation.RemoveDuplicateAsinsDescription))
return;
_tab.RemoveDuplicateAsins();
ConfirmRemoveDuplicateAsins = false;
RefreshDuplicateAsinStatus();
}
}

View File

@@ -1,8 +1,16 @@
namespace HangoverAvalonia.ViewModels;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
public partial class MainVM
{
public TrashBinViewModel TrashBinViewModel { get; } = new();
private void Load_deletedVM() { }
private void Load_deletedVM()
{
TrashBinViewModel.ConfirmDbMutationAsync = action
=> ConfirmDbMutationAsync is { } confirm
? confirm(action)
: Task.FromResult(true);
}
}

View File

@@ -1,7 +1,12 @@
using System;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
public partial class MainVM : ViewModelBase
{
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
public MainVM()
{
Load_databaseVM();

View File

@@ -77,8 +77,17 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
item.IsChecked = false;
}
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
public async Task RestoreCheckedAsync()
{
if (!CheckedBooks.Any())
return;
if (ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
return;
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
if (qtyChanges > 0)
@@ -88,6 +97,13 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public async Task PermanentlyDeleteCheckedAsync()
{
if (!CheckedBooks.Any())
return;
if (ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.PermanentlyDeleteBooksDescription))
return;
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)

View File

@@ -1,10 +0,0 @@
namespace HangoverAvalonia.Views;
public partial class MainWindow
{
private void cliTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
}
}

View File

@@ -2,14 +2,6 @@
public partial class MainWindow
{
private void databaseTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
}
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_viewModel.ExecuteQuery();
}
public async void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.ExecuteQueryAsync();
}

View File

@@ -0,0 +1,18 @@
namespace HangoverAvalonia.Views;
public partial class MainWindow
{
private void fixDuplicatesTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
_viewModel.RefreshDuplicateAsinStatus();
}
public void ScanDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> _viewModel.ScanDuplicateAsins();
public async void RemoveDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.RemoveDuplicateAsinsAsync();
}

View File

@@ -1,140 +1,362 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:HangoverAvalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
xmlns:vms="clr-namespace:HangoverAvalonia.ViewModels"
x:DataType="vms:MainVM"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
Width="800" Height="500"
x:Class="HangoverAvalonia.Views.MainWindow"
Icon="/Assets/hangover.ico "
Title="Hangover: Libation debug and recovery tool">
<Design.DataContext>
<vm:MainVM/>
</Design.DataContext>
<TabControl Name="tabControl1" Grid.Row="0">
<TabControl.Styles>
<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>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
</Style>
<Style Selector="Button">
<Setter Property="Padding" Value="20,5,20,5"/>
</Style>
</TabControl.Styles>
<!-- Database Tab -->
<TabItem Name="databaseTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
</TabItem.Header>
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
<TextBlock
Margin="0,10,0,5"
Grid.Row="0"
Text="{Binding DatabaseFileText}" />
<TextBlock
Margin="0,5,0,5"
Grid.Row="1"
Text="SQL (database command)" />
<TextBox
Margin="0,5,0,5"
AcceptsReturn="True"
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
<Button
Grid.Row="3"
Content="Execute"
IsEnabled="{Binding DatabaseFound}"
Click="Execute_Click" />
<TextBox
Margin="0,5,0,10"
IsReadOnly="True"
Grid.Row="4"
Text="{Binding SqlResults}" />
</Grid>
</TabItem>
<!-- Deleted Books Tab -->
<TabItem Name="deletedTab">
<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="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"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,*,Auto">
<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>
<!-- Command Line Interface Tab -->
<TabItem Name="cliTab">
<!-- Fix Duplicates Tab -->
<TabItem Name="fixDuplicatesTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
</TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Fix Duplicates</TextBlock>
</TabItem.Header>
<Grid RowDefinitions="Auto,Auto,*">
<TextBlock
Margin="0,10,0,10"
Grid.Row="0"
Text="{Binding DuplicateAsinStatusText}"
TextWrapping="Wrap" />
<Grid
Grid.Row="1"
ColumnDefinitions="Auto,Auto,*">
<Button
Grid.Column="0"
Margin="0,0,10,0"
Content="Scan Duplicate ASINs"
IsEnabled="{Binding DatabaseFound}"
Click="ScanDuplicateAsins_Click" />
<CheckBox
Grid.Column="1"
Margin="0,0,10,0"
VerticalAlignment="Center"
IsEnabled="{Binding CanRemoveDuplicateAsins}"
IsChecked="{Binding ConfirmRemoveDuplicateAsins}"
Content="Confirm removal" />
<Button
Grid.Column="2"
HorizontalAlignment="Left"
Content="Remove Duplicates"
IsEnabled="{Binding ConfirmRemoveDuplicateAsins}"
Click="RemoveDuplicateAsins_Click" />
</Grid>
<TextBox
Margin="0,10,0,10"
AcceptsReturn="True"
IsReadOnly="True"
Grid.Row="2"
Text="{Binding DuplicateResults}"
TextWrapping="Wrap" />
</Grid>
</TabItem>
</TabControl>
</Window>

View File

@@ -18,8 +18,9 @@ public partial class MainWindow : Window
public void OnLoad()
{
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
_viewModel.ConfirmDbMutationAsync = action => HangoverMutationConfirm.ConfirmAsync(this, action);
fixDuplicatesTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) fixDuplicatesTab_VisibleChanged(fixDuplicatesTab.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

@@ -11,16 +11,19 @@ public class DatabaseTabCommands
public Func<string> SqlInput { get; }
public Action<string> SqlOutputAppend { get; }
public Action<string> SqlOutputOverwrite { get; }
public Action<string> DuplicateOutputOverwrite { get; }
public DatabaseTabCommands() { }
public DatabaseTabCommands(
Func<string> sqlInput,
Action<string> sqlDisplayAppend,
Action<string> sqlDisplayOverwrite)
Action<string> sqlDisplayOverwrite,
Action<string> duplicateOutputOverwrite)
{
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
DuplicateOutputOverwrite = ArgumentValidator.EnsureNotNull(duplicateOutputOverwrite, nameof(duplicateOutputOverwrite));
}
}
@@ -36,6 +39,7 @@ public class DatabaseTab
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.DuplicateOutputOverwrite));
}
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
@@ -151,4 +155,40 @@ public class DatabaseTab
if (results != 1) _commands.SqlOutputAppend("s");
_commands.SqlOutputAppend(" affected");
}
public string GetDuplicateAsinStatusText()
{
if (DuplicateAsinCleanup.IsCompleted())
return "Duplicate ASIN cleanup: completed (one-time migration already run).";
var scan = DuplicateAsinCleanup.Scan();
return scan.DuplicateGroupCount == 0
? "Duplicate ASIN cleanup: no duplicates found."
: $"Duplicate ASIN cleanup: {scan.DuplicateGroupCount} duplicate ASIN group(s) found. Scan for details, then remove.";
}
public bool CanRemoveDuplicateAsins()
=> !DuplicateAsinCleanup.IsCompleted() && DuplicateAsinCleanup.Scan().DuplicateGroupCount > 0;
public void ScanDuplicateAsins()
{
var scan = DuplicateAsinCleanup.Scan();
_commands.DuplicateOutputOverwrite(scan.Report);
}
public string RemoveDuplicateAsins()
{
EnsureBackup();
try
{
var result = DuplicateAsinCleanup.Execute();
_commands.DuplicateOutputOverwrite(result.Report);
return result.Report;
}
finally
{
DeleteUnneededBackups();
}
}
}

View File

@@ -0,0 +1,214 @@
using ApplicationServices;
using AppScaffolding;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace HangoverBase;
/// <summary>
/// One-time utility to find and remove duplicate <see cref="Book"/> + <see cref="LibraryBook"/> rows
/// that share the same <see cref="Book.AudibleProductId"/>.
/// </summary>
public static class DuplicateAsinCleanup
{
public const string SettingsKey = "HangoverDuplicateAsinCleanupCompleted";
public static bool IsCompleted()
=> UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out var value) && value == "true";
public static DuplicateAsinScanResult Scan()
{
using var context = DbContexts.GetContext();
var groups = GetDuplicateGroups(context);
return new DuplicateAsinScanResult(groups.Count, BuildReport(groups, previewOnly: true));
}
public static DuplicateAsinCleanupResult Execute()
{
if (IsCompleted())
return new DuplicateAsinCleanupResult(0, 0, "Duplicate ASIN cleanup has already been completed for this library.");
using var context = DbContexts.GetContext();
var groups = GetDuplicateGroups(context);
if (groups.Count == 0)
{
MarkCompleted();
return new DuplicateAsinCleanupResult(0, 0, "No duplicate ASIN rows found. Nothing to do.");
}
var removedBooks = 0;
var removedLibraryBooks = 0;
foreach (var group in groups)
{
var keeper = SelectKeeper(group);
var duplicates = group.Where(lb => !ReferenceEquals(lb, keeper)).ToList();
foreach (var duplicate in duplicates)
{
MergeIntoKeeper(context, keeper, duplicate);
context.LibraryBooks.Remove(duplicate);
context.Books.Remove(duplicate.Book);
removedLibraryBooks++;
removedBooks++;
}
}
var changes = context.SaveChanges();
MarkCompleted();
var summary = new StringBuilder();
summary.AppendLine($"Removed {removedBooks} duplicate Book row(s) and {removedLibraryBooks} LibraryBook row(s).");
summary.AppendLine($"SaveChanges reported {changes} database change(s).");
summary.AppendLine();
summary.Append(BuildReport(groups, previewOnly: false));
return new DuplicateAsinCleanupResult(groups.Count, removedBooks, summary.ToString());
}
private static List<List<LibraryBook>> GetDuplicateGroups(LibationContext context)
{
var libraryBooks = context.LibraryBooks
.Include(lb => lb.Book).ThenInclude(b => b.UserDefinedItem)
.Include(lb => lb.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.ToList();
return libraryBooks
.GroupBy(lb => lb.Book.AudibleProductId, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).ToList())
.ToList();
}
private static LibraryBook SelectKeeper(List<LibraryBook> group)
=> group.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).First();
private static int Score(LibraryBook libraryBook)
{
var book = libraryBook.Book;
var udi = book.UserDefinedItem;
var score = 0;
if (!libraryBook.IsDeleted)
score += 1000;
score += bookStatusRank(udi.BookStatus) * 10;
score += bookStatusRank(udi.PdfStatus ?? LiberatedStatus.NotLiberated);
score += book.ContentType switch
{
ContentType.Parent => 15,
ContentType.Episode => 15,
ContentType.Product => 10,
_ => 0,
};
if (book.SeriesLink.Any())
score += 5;
if (!string.IsNullOrWhiteSpace(book.PictureId))
score += 2;
return score;
}
private static int bookStatusRank(LiberatedStatus status) => status switch
{
LiberatedStatus.Liberated => 10,
LiberatedStatus.PartialDownload => 8,
LiberatedStatus.NotLiberated => 3,
LiberatedStatus.Error => 1,
_ => 0,
};
private static void MergeIntoKeeper(LibationContext context, LibraryBook keeper, LibraryBook duplicate)
{
var keeperBook = keeper.Book;
var duplicateBook = duplicate.Book;
var keeperUdi = keeperBook.UserDefinedItem;
var duplicateUdi = duplicateBook.UserDefinedItem;
if (bookStatusRank(duplicateUdi.BookStatus) > bookStatusRank(keeperUdi.BookStatus))
keeperUdi.BookStatus = duplicateUdi.BookStatus;
if (duplicateUdi.PdfStatus is LiberatedStatus duplicatePdf
&& bookStatusRank(duplicatePdf) > bookStatusRank(keeperUdi.PdfStatus ?? LiberatedStatus.NotLiberated))
keeperUdi.SetPdfStatus(duplicatePdf);
if (!string.IsNullOrWhiteSpace(duplicateUdi.Tags))
{
var mergedTags = keeperUdi.TagsEnumerated
.Concat(duplicateUdi.TagsEnumerated)
.Distinct(StringComparer.OrdinalIgnoreCase);
keeperUdi.Tags = string.Join(" ", mergedTags);
}
if (duplicateUdi.LastDownloaded is DateTime duplicateLast
&& (keeperUdi.LastDownloaded is null || duplicateLast > keeperUdi.LastDownloaded))
keeperUdi.SetLastDownloaded(duplicateUdi.LastDownloadedVersion, duplicateUdi.LastDownloadedFormat, duplicateUdi.LastDownloadedFileVersion);
if (duplicateUdi.Rating.OverallRating > keeperUdi.Rating.OverallRating)
keeperUdi.UpdateRating(duplicateUdi.Rating.OverallRating, duplicateUdi.Rating.PerformanceRating, duplicateUdi.Rating.StoryRating);
foreach (var seriesBook in duplicateBook.SeriesLink)
{
if (keeperBook.SeriesLink.Any(sb => sb.Series.AudibleSeriesId == seriesBook.Series.AudibleSeriesId))
continue;
keeperBook.UpsertSeries(seriesBook.Series, seriesBook.Order, context);
}
}
private static string BuildReport(List<List<LibraryBook>> groups, bool previewOnly)
{
var builder = new StringBuilder();
if (groups.Count == 0)
{
builder.Append("No duplicate ASIN rows found.");
return builder.ToString();
}
var duplicateRowCount = groups.Sum(g => g.Count - 1);
builder.Append(previewOnly
? $"Found {groups.Count} duplicate ASIN group(s) ({duplicateRowCount} extra row(s) would be removed)."
: $"Processed {groups.Count} duplicate ASIN group(s).");
builder.AppendLine();
builder.AppendLine();
foreach (var group in groups.OrderBy(g => g[0].Book.AudibleProductId, StringComparer.OrdinalIgnoreCase))
{
var keeper = SelectKeeper(group);
var asin = keeper.Book.AudibleProductId;
var title = keeper.Book.TitleWithSubtitle;
var removing = group.Where(lb => !ReferenceEquals(lb, keeper))
.Select(lb => $"{lb.Book.AudibleProductId} ({lb.DateAdded:d}, {lb.Account})")
.ToList();
builder.Append(asin);
builder.Append(" | ");
builder.Append(title);
builder.AppendLine();
builder.Append(" Keep ");
builder.Append($"{keeper.DateAdded:d}, {keeper.Account}");
builder.Append(previewOnly ? "; would remove " : "; removed ");
builder.AppendLine(string.Join("; ", removing));
}
return builder.ToString().TrimEnd();
}
private static void MarkCompleted()
{
if (UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out _))
UNSAFE_MigrationHelper.Settings_Update(SettingsKey, "true");
else
UNSAFE_MigrationHelper.Settings_Insert(SettingsKey, "true");
}
}
public readonly record struct DuplicateAsinScanResult(int DuplicateGroupCount, string Report);
public readonly record struct DuplicateAsinCleanupResult(int DuplicateGroupCount, int RemovedBookCount, string Report);

View File

@@ -0,0 +1,82 @@
using System.Diagnostics;
using System.Text;
namespace HangoverBase;
public static class HangoverDbMutation
{
public const string ConfirmTitle = "Modify Database";
public static string RemoveDuplicateAsinsDescription { get; }
= "This one-time cleanup will back up the database, merge useful data into the kept row, and permanently delete duplicate Book + LibraryBook pairs.";
public static string SqlMutatingDescription { get; }
= "This will run a SQL command that modifies the database.";
public static string RestoreDeletedBooksDescription { get; }
= "This will restore the selected book(s) to your Libation library.";
public static string PermanentlyDeleteBooksDescription { get; }
= "This will permanently delete the selected book(s) from Libation.";
public static bool IsLibationRunning()
{
try
{
var currentPid = Environment.ProcessId;
foreach (var process in Process.GetProcessesByName("Libation"))
{
try
{
if (process.Id != currentPid && !process.HasExited)
return true;
}
finally
{
process.Dispose();
}
}
}
catch
{
// Process enumeration can fail on some platforms; treat as unknown.
}
return false;
}
public static bool IsMutatingSql(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
return false;
var trimmed = sql.Trim();
while (trimmed.StartsWith("--", StringComparison.Ordinal))
{
var lineEnd = trimmed.IndexOf('\n');
if (lineEnd < 0)
return false;
trimmed = trimmed[(lineEnd + 1)..].TrimStart();
}
var lower = trimmed.ToLowerInvariant();
return lower.StartsWith("update")
|| lower.StartsWith("insert")
|| lower.StartsWith("delete");
}
public static string BuildConfirmMessage(string actionDescription)
{
var builder = new StringBuilder();
builder.AppendLine(actionDescription);
builder.AppendLine();
if (IsLibationRunning())
builder.AppendLine("Libation is currently running.");
builder.AppendLine("Close Libation before continuing to avoid database conflicts and a stale library display.");
builder.AppendLine();
builder.Append("Proceed?");
return builder.ToString().TrimEnd();
}
}

View File

@@ -1,15 +0,0 @@
namespace HangoverWinForms;
public partial class Form1
{
private void Load_cliTab()
{
}
private void cliTab_VisibleChanged(object sender, EventArgs e)
{
if (!cliTab.Visible)
return;
}
}

View File

@@ -1,30 +1,59 @@
using HangoverBase;
namespace HangoverWinForms;
public partial class Form1
{
private DatabaseTab _tab;
private void Load_databaseTab()
{
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s, s => duplicateResultsTb.Text = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)
{
databaseFileLbl.Text = $"Database file not found";
Load_fixDuplicatesTab();
return;
}
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
Load_fixDuplicatesTab();
}
private void databaseTab_VisibleChanged(object sender, EventArgs e)
private void sqlExecuteBtn_Click(object sender, EventArgs e)
{
if (!databaseTab.Visible)
if (HangoverBase.HangoverDbMutation.IsMutatingSql(sqlTb.Text)
&& !HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.SqlMutatingDescription))
return;
_tab.ExecuteQuery();
}
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
}

View File

@@ -42,6 +42,12 @@ public partial class Form1
private async void saveBtn_Click(object sender, EventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
if (libraryBooksToRestore.Count == 0)
return;
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
return;
saveBtn.Enabled = false;
var qtyChanges = await libraryBooksToRestore.RestoreBooksAsync();
if (qtyChanges > 0)

View File

@@ -1,256 +1,628 @@
namespace HangoverWinForms
{
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
this.tabControl1 = new System.Windows.Forms.TabControl();
this.databaseTab = new System.Windows.Forms.TabPage();
this.sqlExecuteBtn = new System.Windows.Forms.Button();
this.sqlResultsTb = new System.Windows.Forms.TextBox();
this.sqlTb = new System.Windows.Forms.TextBox();
this.sqlLbl = new System.Windows.Forms.Label();
this.databaseFileLbl = new System.Windows.Forms.Label();
this.fixDuplicatesTab = new System.Windows.Forms.TabPage();
this.removeDuplicateAsinsBtn = new System.Windows.Forms.Button();
this.scanDuplicateAsinsBtn = new System.Windows.Forms.Button();
this.duplicateAsinStatusLbl = new System.Windows.Forms.Label();
this.duplicateResultsTb = new System.Windows.Forms.TextBox();
this.deletedTab = new System.Windows.Forms.TabPage();
this.deletedCheckedLbl = new System.Windows.Forms.Label();
this.label1 = new System.Windows.Forms.Label();
this.saveBtn = new System.Windows.Forms.Button();
this.uncheckAllBtn = new System.Windows.Forms.Button();
this.checkAllBtn = new System.Windows.Forms.Button();
this.deletedCbl = new System.Windows.Forms.CheckedListBox();
this.cliTab = new System.Windows.Forms.TabPage();
this.tabControl1.SuspendLayout();
this.databaseTab.SuspendLayout();
this.fixDuplicatesTab.SuspendLayout();
this.deletedTab.SuspendLayout();
this.SuspendLayout();
//
// tabControl1
//
this.tabControl1.Controls.Add(this.databaseTab);
this.tabControl1.Controls.Add(this.deletedTab);
this.tabControl1.Controls.Add(this.cliTab);
this.tabControl1.Controls.Add(this.fixDuplicatesTab);
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
this.tabControl1.Location = new System.Drawing.Point(0, 0);
this.tabControl1.Name = "tabControl1";
this.tabControl1.SelectedIndex = 0;
this.tabControl1.Size = new System.Drawing.Size(800, 450);
this.tabControl1.TabIndex = 0;
//
// databaseTab
//
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
this.databaseTab.Controls.Add(this.sqlResultsTb);
this.databaseTab.Controls.Add(this.sqlTb);
this.databaseTab.Controls.Add(this.sqlLbl);
this.databaseTab.Controls.Add(this.databaseFileLbl);
this.databaseTab.Location = new System.Drawing.Point(4, 24);
this.databaseTab.Name = "databaseTab";
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
this.databaseTab.Size = new System.Drawing.Size(792, 422);
this.databaseTab.TabIndex = 0;
this.databaseTab.Text = "Database";
this.databaseTab.UseVisualStyleBackColor = true;
//
// sqlExecuteBtn
//
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 165);
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
this.sqlExecuteBtn.TabIndex = 3;
this.sqlExecuteBtn.Text = "Execute";
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
//
// sqlResultsTb
//
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
this.sqlResultsTb.Location = new System.Drawing.Point(8, 194);
this.sqlResultsTb.Multiline = true;
this.sqlResultsTb.Name = "sqlResultsTb";
this.sqlResultsTb.ReadOnly = true;
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
this.sqlResultsTb.Size = new System.Drawing.Size(776, 222);
this.sqlResultsTb.TabIndex = 4;
//
// sqlTb
//
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.sqlTb.Location = new System.Drawing.Point(8, 48);
this.sqlTb.Location = new System.Drawing.Point(8, 40);
this.sqlTb.Multiline = true;
this.sqlTb.Name = "sqlTb";
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.sqlTb.Size = new System.Drawing.Size(778, 99);
this.sqlTb.Size = new System.Drawing.Size(778, 119);
this.sqlTb.TabIndex = 2;
//
// sqlLbl
//
this.sqlLbl.AutoSize = true;
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
this.sqlLbl.Location = new System.Drawing.Point(6, 22);
this.sqlLbl.Name = "sqlLbl";
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
this.sqlLbl.TabIndex = 1;
this.sqlLbl.Text = "SQL (database command)";
//
// databaseFileLbl
//
this.databaseFileLbl.AutoSize = true;
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
this.databaseFileLbl.Name = "databaseFileLbl";
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
this.databaseFileLbl.TabIndex = 0;
this.databaseFileLbl.Text = "Database file: ";
//
// deletedTab
// fixDuplicatesTab
//
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
this.deletedTab.Controls.Add(this.label1);
this.deletedTab.Controls.Add(this.saveBtn);
this.deletedTab.Controls.Add(this.uncheckAllBtn);
this.deletedTab.Controls.Add(this.checkAllBtn);
this.deletedTab.Controls.Add(this.deletedCbl);
this.deletedTab.Location = new System.Drawing.Point(4, 24);
this.deletedTab.Name = "deletedTab";
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
this.deletedTab.Size = new System.Drawing.Size(792, 422);
this.deletedTab.TabIndex = 2;
this.deletedTab.Text = "Deleted Books";
this.deletedTab.UseVisualStyleBackColor = true;
this.fixDuplicatesTab.Controls.Add(this.duplicateResultsTb);
this.fixDuplicatesTab.Controls.Add(this.removeDuplicateAsinsBtn);
this.fixDuplicatesTab.Controls.Add(this.scanDuplicateAsinsBtn);
this.fixDuplicatesTab.Controls.Add(this.duplicateAsinStatusLbl);
this.fixDuplicatesTab.Location = new System.Drawing.Point(4, 24);
this.fixDuplicatesTab.Name = "fixDuplicatesTab";
this.fixDuplicatesTab.Padding = new System.Windows.Forms.Padding(3);
this.fixDuplicatesTab.Size = new System.Drawing.Size(792, 422);
this.fixDuplicatesTab.TabIndex = 2;
this.fixDuplicatesTab.Text = "Fix Duplicates";
this.fixDuplicatesTab.UseVisualStyleBackColor = true;
//
// deletedCheckedLbl
// removeDuplicateAsinsBtn
//
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.deletedCheckedLbl.AutoSize = true;
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
this.deletedCheckedLbl.TabIndex = 6;
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
this.removeDuplicateAsinsBtn.Location = new System.Drawing.Point(170, 48);
this.removeDuplicateAsinsBtn.Name = "removeDuplicateAsinsBtn";
this.removeDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
this.removeDuplicateAsinsBtn.TabIndex = 2;
this.removeDuplicateAsinsBtn.Text = "Remove Duplicates";
this.removeDuplicateAsinsBtn.UseVisualStyleBackColor = true;
this.removeDuplicateAsinsBtn.Click += new System.EventHandler(this.removeDuplicateAsinsBtn_Click);
//
// label1
// scanDuplicateAsinsBtn
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(8, 3);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(239, 15);
this.label1.TabIndex = 0;
this.label1.Text = "To restore deleted book, check box and save";
this.scanDuplicateAsinsBtn.Location = new System.Drawing.Point(8, 48);
this.scanDuplicateAsinsBtn.Name = "scanDuplicateAsinsBtn";
this.scanDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
this.scanDuplicateAsinsBtn.TabIndex = 1;
this.scanDuplicateAsinsBtn.Text = "Scan Duplicate ASINs";
this.scanDuplicateAsinsBtn.UseVisualStyleBackColor = true;
this.scanDuplicateAsinsBtn.Click += new System.EventHandler(this.scanDuplicateAsinsBtn_Click);
//
// saveBtn
// duplicateAsinStatusLbl
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(709, 391);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.TabIndex = 5;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
//
// uncheckAllBtn
//
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
this.uncheckAllBtn.Name = "uncheckAllBtn";
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
this.uncheckAllBtn.TabIndex = 4;
this.uncheckAllBtn.Text = "Uncheck All";
this.uncheckAllBtn.UseVisualStyleBackColor = true;
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
//
// checkAllBtn
//
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
this.checkAllBtn.Name = "checkAllBtn";
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
this.checkAllBtn.TabIndex = 3;
this.checkAllBtn.Text = "Check All";
this.checkAllBtn.UseVisualStyleBackColor = true;
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
//
// deletedCbl
//
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
this.duplicateAsinStatusLbl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.duplicateAsinStatusLbl.Location = new System.Drawing.Point(6, 18);
this.duplicateAsinStatusLbl.Name = "duplicateAsinStatusLbl";
this.duplicateAsinStatusLbl.Size = new System.Drawing.Size(778, 27);
this.duplicateAsinStatusLbl.TabIndex = 0;
this.duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup";
//
// duplicateResultsTb
//
this.duplicateResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.duplicateResultsTb.Location = new System.Drawing.Point(8, 77);
this.duplicateResultsTb.Multiline = true;
this.duplicateResultsTb.Name = "duplicateResultsTb";
this.duplicateResultsTb.ReadOnly = true;
this.duplicateResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.duplicateResultsTb.Size = new System.Drawing.Size(776, 339);
this.duplicateResultsTb.TabIndex = 3;
//
// deletedTab
//
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
this.deletedTab.Controls.Add(this.label1);
this.deletedTab.Controls.Add(this.saveBtn);
this.deletedTab.Controls.Add(this.uncheckAllBtn);
this.deletedTab.Controls.Add(this.checkAllBtn);
this.deletedTab.Controls.Add(this.deletedCbl);
this.deletedTab.Location = new System.Drawing.Point(4, 24);
this.deletedTab.Name = "deletedTab";
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
this.deletedTab.Size = new System.Drawing.Size(792, 422);
this.deletedTab.TabIndex = 1;
this.deletedTab.Text = "Deleted Books";
this.deletedTab.UseVisualStyleBackColor = true;
//
// deletedCheckedLbl
//
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.deletedCheckedLbl.AutoSize = true;
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
this.deletedCheckedLbl.TabIndex = 6;
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(8, 3);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(239, 15);
this.label1.TabIndex = 0;
this.label1.Text = "To restore deleted book, check box and save";
//
// saveBtn
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(709, 391);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.TabIndex = 5;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
//
// uncheckAllBtn
//
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
this.uncheckAllBtn.Name = "uncheckAllBtn";
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
this.uncheckAllBtn.TabIndex = 4;
this.uncheckAllBtn.Text = "Uncheck All";
this.uncheckAllBtn.UseVisualStyleBackColor = true;
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
//
// checkAllBtn
//
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
this.checkAllBtn.Name = "checkAllBtn";
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
this.checkAllBtn.TabIndex = 3;
this.checkAllBtn.Text = "Check All";
this.checkAllBtn.UseVisualStyleBackColor = true;
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
//
// deletedCbl
//
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.deletedCbl.FormattingEnabled = true;
this.deletedCbl.Location = new System.Drawing.Point(8, 21);
this.deletedCbl.Name = "deletedCbl";
this.deletedCbl.Size = new System.Drawing.Size(776, 364);
this.deletedCbl.TabIndex = 2;
this.deletedCbl.ItemCheck += new System.Windows.Forms.ItemCheckEventHandler(this.deletedCbl_ItemCheck);
//
// cliTab
//
this.cliTab.Location = new System.Drawing.Point(4, 24);
this.cliTab.Name = "cliTab";
this.cliTab.Size = new System.Drawing.Size(792, 422);
this.cliTab.TabIndex = 1;
this.cliTab.Text = "Command Line Interface";
this.cliTab.UseVisualStyleBackColor = true;
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Controls.Add(this.tabControl1);
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
this.Name = "Form1";
this.Text = "Hangover: Libation debug and recovery tool";
this.tabControl1.ResumeLayout(false);
this.databaseTab.ResumeLayout(false);
this.databaseTab.PerformLayout();
this.fixDuplicatesTab.ResumeLayout(false);
this.deletedTab.ResumeLayout(false);
this.deletedTab.PerformLayout();
this.ResumeLayout(false);
}
#endregion
private TabControl tabControl1;
private TabPage databaseTab;
private Label databaseFileLbl;
private TextBox sqlResultsTb;
private TextBox sqlTb;
private Label sqlLbl;
private Button sqlExecuteBtn;
private TabPage cliTab;
private TabPage fixDuplicatesTab;
private Button removeDuplicateAsinsBtn;
private Button scanDuplicateAsinsBtn;
private Label duplicateAsinStatusLbl;
private TextBox duplicateResultsTb;
private TabPage deletedTab;
private CheckedListBox deletedCbl;
private Label label1;
private Button saveBtn;
private Button uncheckAllBtn;
private Button checkAllBtn;
private Label deletedCheckedLbl;
}
}
}

View File

@@ -0,0 +1,48 @@
namespace HangoverWinForms;
public partial class Form1
{
private void Load_fixDuplicatesTab()
{
if (_tab.DbFile is null)
{
duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup unavailable (database not found).";
scanDuplicateAsinsBtn.Enabled = false;
removeDuplicateAsinsBtn.Enabled = false;
return;
}
refreshDuplicateAsinStatus();
}
private void refreshDuplicateAsinStatus()
{
duplicateAsinStatusLbl.Text = _tab.GetDuplicateAsinStatusText();
scanDuplicateAsinsBtn.Enabled = true;
removeDuplicateAsinsBtn.Enabled = _tab.CanRemoveDuplicateAsins();
}
private void fixDuplicatesTab_VisibleChanged(object sender, EventArgs e)
{
if (!fixDuplicatesTab.Visible)
return;
if (_tab.DbFile is not null)
refreshDuplicateAsinStatus();
}
private void scanDuplicateAsinsBtn_Click(object sender, EventArgs e)
{
_tab.ScanDuplicateAsins();
refreshDuplicateAsinStatus();
}
private void removeDuplicateAsinsBtn_Click(object sender, EventArgs e)
{
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RemoveDuplicateAsinsDescription))
return;
_tab.RemoveDuplicateAsins();
refreshDuplicateAsinStatus();
}
}

View File

@@ -12,12 +12,10 @@ public partial class Form1 : Form
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
cliTab.VisibleChanged += cliTab_VisibleChanged;
fixDuplicatesTab.VisibleChanged += fixDuplicatesTab_VisibleChanged;
deletedTab.VisibleChanged += deletedTab_VisibleChanged;
Load_databaseTab();
Load_cliTab();
Load_deletedTab();
}
}

View File

@@ -0,0 +1,14 @@
using HangoverBase;
namespace HangoverWinForms;
internal static class HangoverMutationConfirm
{
public static bool Confirm(IWin32Window owner, string actionDescription)
=> MessageBox.Show(
owner,
HangoverDbMutation.BuildConfirmMessage(actionDescription),
HangoverDbMutation.ConfirmTitle,
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning) == DialogResult.Yes;
}

View File

@@ -28,7 +28,7 @@ static class Program
//We can do this because we're already executing inside the sandbox.
//Any process created in the sandbox executes in the same sandbox.
//Unfortunately, all sandbox files are read/execute, so no writing!
Process.Start("Hangover");
HangoverLauncher.Launch();
return;
}
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli")

View File

@@ -1,7 +1,9 @@
using Avalonia.Controls;
using LibationFileManager;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
using System.Reactive;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels;
@@ -10,8 +12,12 @@ partial class MainVM
{
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
public ReactiveCommand<Unit, Unit> LaunchHangover { get; private set; } = null!;
private void Configure_Settings()
{
LaunchHangover = ReactiveCommand.CreateFromTask(LaunchHangoverAsync);
if (App.Current is Avalonia.Application app &&
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
@@ -23,15 +29,21 @@ partial class MainVM
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
public void LaunchHangover()
private async Task LaunchHangoverAsync()
{
try
{
System.Diagnostics.Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : ""));
HangoverLauncher.Launch();
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
await MessageBox.Show(
MainWindow,
HangoverLauncher.GetLaunchFailureMessage(ex),
"Launch Hangover",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}

View File

@@ -271,7 +271,11 @@ public class ProductsDisplayViewModel : ViewModelBase
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
var seriesBook = dbBooks.FindSeriesParent(episodeBook)
?? dbBooks.FirstOrDefault(lb =>
lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId
&& lb.Account == seriesEntry.LibraryBook.Account)
?? seriesEntry.LibraryBook;
seriesEntry.UpdateLibraryBook(seriesBook);
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Diagnostics;
using System.IO;
namespace LibationFileManager;
public static class HangoverLauncher
{
public static string GetHangoverExecutablePath()
{
var fileName = Configuration.IsWindows ? "Hangover.exe" : "Hangover";
return Path.Combine(Configuration.ProcessDirectory, fileName);
}
public static void Launch()
{
var path = GetHangoverExecutablePath();
if (!File.Exists(path))
throw new FileNotFoundException(
$"Hangover was not found in Libation's folder.{Environment.NewLine}{Environment.NewLine}Expected:{Environment.NewLine}{path}",
path);
Process.Start(new ProcessStartInfo
{
FileName = path,
WorkingDirectory = Configuration.ProcessDirectory,
UseShellExecute = true,
});
}
public static string GetLaunchFailureMessage(Exception ex)
=> ex is FileNotFoundException fileNotFound
? fileNotFound.Message
: $"Could not start Hangover.{Environment.NewLine}{Environment.NewLine}{ex.Message}";
}

View File

@@ -177,7 +177,7 @@ public class GridContextMenu
Configuration.Instance.SavePodcastsToParentFolder &&
libraryBook.Book.SeriesLink.SingleOrDefault() is SeriesBook series)
{
var seriesParent = DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
var seriesParent = DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId, account: libraryBook.Account);
folderDto = seriesParent?.ToDto() ?? fileDto;
}

View File

@@ -1,4 +1,5 @@
using LibationWinForms.Dialogs;
using LibationFileManager;
using LibationWinForms.Dialogs;
using System;
using System.Windows.Forms;
@@ -42,11 +43,17 @@ public partial class Form1
{
try
{
System.Diagnostics.Process.Start("Hangover.exe");
HangoverLauncher.Launch();
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
MessageBox.Show(
this,
HangoverLauncher.GetLaunchFailureMessage(ex),
"Launch Hangover",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}
}

View File

@@ -468,7 +468,11 @@ public partial class ProductsGrid : UserControl
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
var seriesBook = dbBooks.FindSeriesParent(episodeBook)
?? dbBooks.FirstOrDefault(lb =>
lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId
&& lb.Account == seriesEntry.LibraryBook.Account)
?? seriesEntry.LibraryBook;
seriesEntry.UpdateLibraryBook(seriesBook);
}