Skip to content

feat: add cover preview for EPUB and MOBI files#2

Merged
christianromeni merged 2 commits into
mainfrom
feat/cover-preview
Jan 17, 2026
Merged

feat: add cover preview for EPUB and MOBI files#2
christianromeni merged 2 commits into
mainfrom
feat/cover-preview

Conversation

@christianromeni

Copy link
Copy Markdown
Contributor

Summary

Adds book cover preview functionality to make the UI more visually appealing and help users identify books at a glance.

  • Extract and display cover images from EPUB files (supports EPUB2 and EPUB3 formats)
  • Extract and display cover images from MOBI files via mobitool
  • Show cover thumbnail (80x120) next to file info in single-file view
  • Show small cover thumbnail (32x48) in multi-file list view
  • Covers load asynchronously to keep UI responsive
  • Update drop zone text to "MOBI & EPUB files"

Screenshots

Cover appears next to the book title/author when a file is loaded.

Technical Details

  • EPUB cover extraction tries multiple patterns: properties="cover-image" (EPUB3), id="cover", <meta name="cover">, and fallback to files named cover.*
  • MOBI cover extraction uses mobitool to dump the markup folder and finds the cover image
  • Added CoverImage property to BookMetadata model
  • Added NullToCollapsedConverter for conditional visibility binding

- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread MainWindow.xaml.cs
Comment on lines +375 to +380
_ = Task.Run(() => _conversionService.ExtractEpubCover(filePath!))
.ContinueWith(t =>
{
if (t.Result != null)
Dispatcher.Invoke(() => book.CoverImage = t.Result);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread MainWindow.xaml.cs
Comment on lines +392 to +397
_ = Task.Run(() => _conversionService.ExtractMobiCover(filePath!))
.ContinueWith(t =>
{
if (t.Result != null)
Dispatcher.Invoke(() => book.CoverImage = t.Result);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +142
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);
}
}
}

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread Services/ConversionService.cs Outdated
Comment on lines +294 to +297
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.UriSource = new Uri(coverFile);

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment thread Services/ConversionService.cs Outdated
Comment on lines +267 to +268
process.WaitForExit(5000); // 5 second timeout for cover extraction

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment thread MainWindow.xaml.cs
Comment on lines +250 to +261
_ = Task.Run(() => _conversionService.ExtractEpubCover(filePath))
.ContinueWith(t =>
{
if (t.Result != null)
{
Dispatcher.Invoke(() =>
{
CoverImage.Source = t.Result;
CoverBorder.Visibility = Visibility.Visible;
});
}
}, TaskContinuationOptions.OnlyOnRanToCompletion);

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread MainWindow.xaml.cs
Comment on lines +296 to +307
_ = Task.Run(() => _conversionService.ExtractMobiCover(filePath))
.ContinueWith(t =>
{
if (t.Result != null)
{
Dispatcher.Invoke(() =>
{
CoverImage.Source = t.Result;
CoverBorder.Visibility = Visibility.Visible;
});
}
}, TaskContinuationOptions.OnlyOnRanToCompletion);

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
_ = 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);

Copilot uses AI. Check for mistakes.
finally
{
// Cleanup
try { Directory.Delete(tempDir, true); } catch { }

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poor error handling: empty catch block.

Suggested change
try { Directory.Delete(tempDir, true); } catch { }
try
{
Directory.Delete(tempDir, true);
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to delete temporary directory '{tempDir}': {ex}");
}

Copilot uses AI. Check for mistakes.
- Kill mobitool process if it times out (prevents hung processes)
- Load MOBI cover into memory before deleting temp dir (fixes race condition)
@christianromeni

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Addressed the valid issues:

Fixed:

  • ✅ Process timeout - now kills mobitool if it hangs past 5 seconds
  • ✅ Bitmap race condition - MOBI cover now loaded into memory via StreamSource before temp dir is deleted

Not fixing (over-engineering):

  • Fire-and-forget tasks (4 comments) - Cover extraction is non-critical UI polish. If it fails silently, user just doesn't see a cover. Not worth adding CancellationTokens and complex exception handling.
  • Regex compilation - Premature optimization. Loading a few books, not thousands.
  • Empty catch on temp dir delete - Cleanup code. If it fails, we don't care.

@christianromeni christianromeni merged commit 483afe0 into main Jan 17, 2026
2 checks passed
@christianromeni christianromeni deleted the feat/cover-preview branch January 17, 2026 02:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants