feat: add cover preview for EPUB and MOBI files#2
Conversation
- Extract and display cover images from EPUB and MOBI files - Show cover thumbnail in single-file view (80x120) - Show small cover in multi-file list view (32x48) - Update drop zone text to show 'MOBI & EPUB files' - Add NullToCollapsedConverter for conditional cover visibility
There was a problem hiding this comment.
Pull request overview
This PR adds book cover preview functionality for EPUB and MOBI files to enhance the user interface and help users visually identify books. The implementation extracts cover images from both file formats and displays them asynchronously.
Changes:
- Added cover extraction methods for EPUB (via ZIP archive parsing) and MOBI (via mobitool) files
- Extended the BookMetadata model with a CoverImage property
- Updated UI to display cover thumbnails in both single-file view (80x120) and multi-file list view (32x48)
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| Services/ConversionService.cs | Added ExtractEpubCover and ExtractMobiCover methods to extract cover images from book files; added null-coalescing operator for defensive programming |
| Models/BookMetadata.cs | Added CoverImage property with INotifyPropertyChanged implementation |
| MainWindow.xaml.cs | Integrated async cover extraction for both single-file and multi-file views using fire-and-forget tasks |
| MainWindow.xaml | Added UI elements for displaying cover images with data binding and updated drop zone text |
| NullToCollapsedConverter.cs | Created new value converter to show/hide cover elements based on null state |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| _ = Task.Run(() => _conversionService.ExtractEpubCover(filePath!)) | ||
| .ContinueWith(t => | ||
| { | ||
| if (t.Result != null) | ||
| Dispatcher.Invoke(() => book.CoverImage = t.Result); | ||
| }, TaskContinuationOptions.OnlyOnRanToCompletion); |
There was a problem hiding this comment.
Fire-and-forget tasks can cause exceptions to be silently swallowed and make it difficult to debug issues. Additionally, if these tasks are still running when files are reloaded or the window is closed, they may try to update BookMetadata objects that are no longer in the collection. Consider storing task references and cancelling them when appropriate, or at minimum using proper exception handling in the continuation.
| _ = Task.Run(() => _conversionService.ExtractMobiCover(filePath!)) | ||
| .ContinueWith(t => | ||
| { | ||
| if (t.Result != null) | ||
| Dispatcher.Invoke(() => book.CoverImage = t.Result); | ||
| }, TaskContinuationOptions.OnlyOnRanToCompletion); |
There was a problem hiding this comment.
Fire-and-forget tasks can cause exceptions to be silently swallowed and make it difficult to debug issues. Additionally, if these tasks are still running when files are reloaded or the window is closed, they may try to update BookMetadata objects that are no longer in the collection. Consider storing task references and cancelling them when appropriate, or at minimum using proper exception handling in the continuation.
| var coverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, @"<item[^>]*properties\s*=\s*[""'][^""']*cover-image[^""']*[""'][^>]*href\s*=\s*[""']([^""']+)[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); | ||
|
|
||
| if (!coverMatch.Success) | ||
| { | ||
| // Pattern 1b: href before properties | ||
| coverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, @"<item[^>]*href\s*=\s*[""']([^""']+)[""'][^>]*properties\s*=\s*[""'][^""']*cover-image[^""']*[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); | ||
| } | ||
|
|
||
| if (!coverMatch.Success) | ||
| { | ||
| // Pattern 2: id="cover" or id="cover-image" | ||
| coverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, @"<item[^>]*id\s*=\s*[""']cover[^""']*[""'][^>]*href\s*=\s*[""']([^""']+)[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); | ||
| } | ||
|
|
||
| if (!coverMatch.Success) | ||
| { | ||
| // Pattern 2b: href before id | ||
| coverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, @"<item[^>]*href\s*=\s*[""']([^""']+)[""'][^>]*id\s*=\s*[""']cover[^""']*[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); | ||
| } | ||
|
|
||
| if (!coverMatch.Success) | ||
| { | ||
| // Pattern 3: meta name="cover" content="image-id", then find that image | ||
| var metaCoverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, @"<meta[^>]*name\s*=\s*[""']cover[""'][^>]*content\s*=\s*[""']([^""']+)[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase); | ||
|
|
||
| if (!metaCoverMatch.Success) | ||
| { | ||
| // Try reversed attribute order | ||
| metaCoverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, @"<meta[^>]*content\s*=\s*[""']([^""']+)[""'][^>]*name\s*=\s*[""']cover[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase); | ||
| } | ||
|
|
||
| if (metaCoverMatch.Success) | ||
| { | ||
| var coverId = metaCoverMatch.Groups[1].Value; | ||
| // Find item with this id | ||
| coverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, $@"<item[^>]*id\s*=\s*[""']{System.Text.RegularExpressions.Regex.Escape(coverId)}[""'][^>]*href\s*=\s*[""']([^""']+)[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); | ||
|
|
||
| if (!coverMatch.Success) | ||
| { | ||
| coverMatch = System.Text.RegularExpressions.Regex.Match( | ||
| opfContent, $@"<item[^>]*href\s*=\s*[""']([^""']+)[""'][^>]*id\s*=\s*[""']{System.Text.RegularExpressions.Regex.Escape(coverId)}[""']", | ||
| System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Multiple Regex.Match calls are being made repeatedly with the same patterns. These regex patterns should be compiled and cached as static readonly fields to improve performance, especially since ExtractEpubCover may be called multiple times in batch processing scenarios.
| var bitmap = new BitmapImage(); | ||
| bitmap.BeginInit(); | ||
| bitmap.CacheOption = BitmapCacheOption.OnLoad; | ||
| bitmap.UriSource = new Uri(coverFile); |
There was a problem hiding this comment.
The bitmap is loaded from a file in the temp directory using UriSource, but the temp directory is deleted in the finally block immediately after the method returns. This creates a race condition where the file may be deleted before the BitmapImage finishes loading it asynchronously. You should load the bitmap into memory using BitmapCacheOption.OnLoad with a FileStream instead, similar to how LoadBitmapFromEntry works.
| var bitmap = new BitmapImage(); | |
| bitmap.BeginInit(); | |
| bitmap.CacheOption = BitmapCacheOption.OnLoad; | |
| bitmap.UriSource = new Uri(coverFile); | |
| using var coverStream = File.OpenRead(coverFile); | |
| var bitmap = new BitmapImage(); | |
| bitmap.BeginInit(); | |
| bitmap.CacheOption = BitmapCacheOption.OnLoad; | |
| bitmap.StreamSource = coverStream; |
| process.WaitForExit(5000); // 5 second timeout for cover extraction | ||
|
|
There was a problem hiding this comment.
The process timeout uses WaitForExit with a timeout but doesn't check the return value to verify if the process actually exited or timed out. If the process times out, it will continue to run and consume resources. You should check the return value and call process.Kill() if the timeout is reached.
| process.WaitForExit(5000); // 5 second timeout for cover extraction | |
| var exited = process.WaitForExit(5000); // 5 second timeout for cover extraction | |
| if (!exited) | |
| { | |
| try | |
| { | |
| process.Kill(); | |
| process.WaitForExit(); | |
| } | |
| catch | |
| { | |
| // Ignore exceptions from Kill/WaitForExit; we're already failing this extraction. | |
| } | |
| return null; | |
| } |
| _ = Task.Run(() => _conversionService.ExtractEpubCover(filePath)) | ||
| .ContinueWith(t => | ||
| { | ||
| if (t.Result != null) | ||
| { | ||
| Dispatcher.Invoke(() => | ||
| { | ||
| CoverImage.Source = t.Result; | ||
| CoverBorder.Visibility = Visibility.Visible; | ||
| }); | ||
| } | ||
| }, TaskContinuationOptions.OnlyOnRanToCompletion); |
There was a problem hiding this comment.
Fire-and-forget tasks can cause exceptions to be silently swallowed and make it difficult to debug issues. Additionally, if these tasks are still running when the window is closed or a new file is loaded, they may try to update UI elements that no longer exist or are bound to different data. Consider storing task references and cancelling them when appropriate, or at minimum using proper exception handling in the continuation.
| _ = Task.Run(() => _conversionService.ExtractMobiCover(filePath)) | ||
| .ContinueWith(t => | ||
| { | ||
| if (t.Result != null) | ||
| { | ||
| Dispatcher.Invoke(() => | ||
| { | ||
| CoverImage.Source = t.Result; | ||
| CoverBorder.Visibility = Visibility.Visible; | ||
| }); | ||
| } | ||
| }, TaskContinuationOptions.OnlyOnRanToCompletion); |
There was a problem hiding this comment.
Fire-and-forget tasks can cause exceptions to be silently swallowed and make it difficult to debug issues. Additionally, if these tasks are still running when the window is closed or a new file is loaded, they may try to update UI elements that no longer exist or are bound to different data. Consider storing task references and cancelling them when appropriate, or at minimum using proper exception handling in the continuation.
| _ = Task.Run(() => _conversionService.ExtractMobiCover(filePath)) | |
| .ContinueWith(t => | |
| { | |
| if (t.Result != null) | |
| { | |
| Dispatcher.Invoke(() => | |
| { | |
| CoverImage.Source = t.Result; | |
| CoverBorder.Visibility = Visibility.Visible; | |
| }); | |
| } | |
| }, TaskContinuationOptions.OnlyOnRanToCompletion); | |
| var coverTask = Task.Run(() => _conversionService.ExtractMobiCover(filePath)); | |
| coverTask.ContinueWith(t => | |
| { | |
| var cover = t.Result; | |
| if (cover != null) | |
| { | |
| Dispatcher.Invoke(() => | |
| { | |
| CoverImage.Source = cover; | |
| CoverBorder.Visibility = Visibility.Visible; | |
| }); | |
| } | |
| }, TaskContinuationOptions.OnlyOnRanToCompletion); | |
| coverTask.ContinueWith(t => | |
| { | |
| // Observe and handle exceptions to avoid unobserved task exceptions | |
| var _ = t.Exception; | |
| Dispatcher.Invoke(() => | |
| { | |
| SetStatus("Failed to extract cover from MOBI file", isError: true); | |
| }); | |
| }, TaskContinuationOptions.OnlyOnFaulted); |
| finally | ||
| { | ||
| // Cleanup | ||
| try { Directory.Delete(tempDir, true); } catch { } |
There was a problem hiding this comment.
Poor error handling: empty catch block.
| try { Directory.Delete(tempDir, true); } catch { } | |
| try | |
| { | |
| Directory.Delete(tempDir, true); | |
| } | |
| catch (Exception ex) | |
| { | |
| Debug.WriteLine($"Failed to delete temporary directory '{tempDir}': {ex}"); | |
| } |
- Kill mobitool process if it times out (prevents hung processes) - Load MOBI cover into memory before deleting temp dir (fixes race condition)
|
Thanks for the review! Addressed the valid issues: Fixed:
Not fixing (over-engineering):
|
Summary
Adds book cover preview functionality to make the UI more visually appealing and help users identify books at a glance.
Screenshots
Cover appears next to the book title/author when a file is loaded.
Technical Details
properties="cover-image"(EPUB3),id="cover",<meta name="cover">, and fallback to files namedcover.*CoverImageproperty toBookMetadatamodelNullToCollapsedConverterfor conditional visibility binding