This document provides a complete, phased implementation plan for: - Enabling manga EPUBs in manga library (not just CBZ/CBR) - Detecting fixed-layout EPUBs vs reflowable EPUBs - Processing issue tracking for format mismatches - Universal panel detection for manga and comics libraries - Smart panel detection that works for PDF comics but not PDF ebooks Key features: - All changes follow existing code patterns with exact line numbers - 9 implementation phases in correct dependency order - Code-around context for every change (before/after) - Testing checklist and rollback plan - Database schema changes, scanner enhancements, new handlers, frontend updates Panel detection logic: - Manga library + fixed_layout/comic_archive → panel detection ON - Comics library + fixed_layout/comic_archive → panel detection ON - Ebooks library + any format → panel detection OFF - Comics library + PDF → panel detection ON - Ebooks library + PDF → panel detection OFF Implementation addresses the constraint that manga EPUBs live in /manga/ directory physically but must be filtered to only show fixed-layout EPUBs in the manga library (not reflowable novels). This is a planning document only - no code changes yet.
30 KiB
Manga EPUB and Panel Detection Implementation
Status: Ready to implement
Created: April 12, 2026
Purpose: Enable manga EPUBs in manga library, filter by fixed-layout, and implement universal panel detection
Table of Contents
- Phase 1: Database Schema Changes
- Phase 2: Database Query Additions
- Phase 3: Scanner Service Enhancements
- Phase 4: New Processing Issues Handler
- Phase 5: Reader Handler Updates
- Phase 6: Route Registration
- Phase 7: Frontend Templates
- Phase 8: Update Reader Template
- Phase 9: Update Reader JavaScript
- Implementation Order
Phase 1: Database Schema Changes
File: database/schema/schema.sql
Location: Add at end of file (after line 1253)
Changes to add:
-- ============================================================================
-- ENHANCED MANGA/COMIC FORMAT DETECTION AND PROCESSING ISSUE TRACKING
-- ============================================================================
-- Allow EPUB in manga library
UPDATE library_types
SET allowed_extensions = array_append(allowed_extensions, '.epub')
WHERE name = 'manga';
-- Function to detect fixed-layout EPUBs from OPF content
CREATE OR REPLACE FUNCTION detect_fixed_layout_epub(opf_content TEXT)
RETURNS BOOLEAN AS $$
BEGIN
-- Check for pre-paginated metadata
IF opf_content LIKE '%rendition:layout">pre-paginated<%' THEN
RETURN TRUE;
END IF;
IF opf_content LIKE '%rendition:layout="pre-paginated"%' THEN
RETURN TRUE;
END IF;
-- Check for RTL page progression (manga indicator)
IF opf_content LIKE '%page-progression-direction="rtl"%' THEN
RETURN TRUE;
END IF;
-- Check for image-heavy content (count <img> tags)
-- Threshold of 50 images suggests manga/comic vs novel
IF (SELECT COUNT(*) FROM regexp_matches(opf_content, '<img[^>]+>', 'g')) > 50 THEN
RETURN TRUE;
END IF;
RETURN FALSE;
END;
$$ LANGUAGE plpgsql;
-- Table to track items that can't be processed in their library
CREATE TABLE IF NOT EXISTS processing_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
issue_type VARCHAR(50) NOT NULL,
issue_description TEXT NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'warning',
resolved BOOLEAN DEFAULT FALSE,
resolved_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(media_item_id, issue_type)
);
-- Indexes for querying problem items
CREATE INDEX IF NOT EXISTS idx_processing_issues_library
ON processing_issues(library_id, resolved);
CREATE INDEX IF NOT EXISTS idx_processing_issues_severity
ON processing_issues(severity, resolved);
-- Add comment for documentation
COMMENT ON TABLE processing_issues IS 'Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues';
Implementation: Run this SQL directly in PostgreSQL or add to migration file
Phase 2: Database Query Additions
File: internal/database/queries/queries.sql
Location: Add at end of file (after line 2018)
Changes to add:
-- ============================================================================
-- PROCESSING ISSUES QUERIES
-- ============================================================================
-- name: CreateProcessingIssue :one
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (media_item_id, issue_type)
DO UPDATE SET issue_description = EXCLUDED.issue_description,
severity = EXCLUDED.severity,
resolved = false,
resolved_at = NULL
RETURNING *;
-- name: ListProcessingIssuesByLibrary :many
SELECT
pi.id,
pi.media_item_id,
pi.issue_type,
pi.issue_description,
pi.severity,
pi.resolved,
pi.resolved_at,
pi.created_at,
mi.title,
mi.file_path,
mi.format_group,
lt.name as library_type_name
FROM processing_issues pi
JOIN media_items mi ON pi.media_item_id = mi.id
JOIN libraries l ON pi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE pi.library_id = $1
AND pi.resolved = false
ORDER BY
CASE pi.severity
WHEN 'error' THEN 1
WHEN 'warning' THEN 2
WHEN 'info' THEN 3
END,
pi.created_at DESC;
-- name: GetProcessingIssueStats :one
SELECT
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
COUNT(*) FILTER (WHERE severity = 'warning' AND resolved = false) as warning_count,
COUNT(*) FILTER (WHERE severity = 'info' AND resolved = false) as info_count
FROM processing_issues
WHERE library_id = $1;
-- name: ResolveProcessingIssue :one
UPDATE processing_issues
SET resolved = true,
resolved_at = NOW()
WHERE id = $1
AND media_item_id = $2
RETURNING *;
-- name: DeleteProcessingIssue :one
DELETE FROM processing_issues
WHERE id = $1
RETURNING *;
-- ============================================================================
-- LIBRARY WITH TYPE INFO QUERIES
-- ============================================================================
-- name: GetLibraryWithType :one
SELECT
l.*,
lt.name as type_name,
lt.description as type_description,
lt.allowed_extensions
FROM libraries l
JOIN library_types lt ON l.library_type_id = lt.id
WHERE l.id = $1;
Next step: Run sqlc generate in internal/database/ directory
Phase 3: Scanner Service Enhancements
File: internal/services/media_scanner.go
Change 1: Add Fixed-Layout Detection Function
Location: After extractEPUBMetadata function (after line 1182)
Add this new function:
// DetectFixedLayoutEPUB checks if EPUB has fixed-layout (manga) characteristics
// by examining the OPF file for rendition metadata and content indicators
func (s *MediaScanner) DetectFixedLayoutEPUB(epubPath string) (bool, error) {
// Open EPUB ZIP file
r, err := zip.OpenReader(epubPath)
if err != nil {
return false, fmt.Errorf("failed to open EPUB: %w", err)
}
defer r.Close()
// Find and read OPF file
var opfFile *zip.File
for _, f := range r.File {
if strings.HasSuffix(f.Name, ".opf") {
opfFile = f
break
}
// Also check in META-INF directory
if strings.Contains(f.Name, "META-INF/") && strings.HasSuffix(f.Name, ".opf") {
opfFile = f
break
}
}
if opfFile == nil {
return false, fmt.Errorf("OPF file not found in EPUB")
}
// Read OPF content
rc, err := opfFile.Open()
if err != nil {
return false, fmt.Errorf("failed to open OPF: %w", err)
}
defer rc.Close()
opfContent, err := io.ReadAll(rc)
if err != nil {
return false, fmt.Errorf("failed to read OPF: %w", err)
}
// Check for fixed-layout indicators
opfString := string(opfContent)
// Check 1: rendition:layout = pre-paginated (EPUB 3 fixed layout)
if strings.Contains(opfString, `rendition:layout">pre-paginated<`) ||
strings.Contains(opfString, `rendition:layout="pre-paginated"`) {
return true, nil
}
// Check 2: RTL page progression (manga indicator)
if strings.Contains(opfString, `page-progression-direction="rtl"`) {
return true, nil
}
// Check 3: Image-heavy content (count <img> tags)
// Threshold of 50 images suggests manga/comic vs novel
imgCount := strings.Count(opfString, `<img`)
if imgCount > 50 {
return true, nil
}
// Check 4: Manga subject tag
lowerOPF := strings.ToLower(opfString)
if strings.Contains(lowerOPF, `<dc:subject`) &&
(strings.Contains(lowerOPF, `manga`) ||
strings.Contains(lowerOPF, `comic`)) {
return true, nil
}
return false, nil
}
Change 2: Add Library Validation Function
Location: After DetectFixedLayoutEPUB function (after the function you just added)
Add this new function:
// ValidateMediaItemForLibrary checks if item matches library type expectations
// and returns issue description if validation fails, nil if valid
func (s *MediaScanner) ValidateMediaItemForLibrary(
ctx context.Context,
mediaItem database.MediaItems,
library database.GetLibraryWithTypeRow,
) *string {
// Get library type from the joined query result
libraryTypeName := library.TypeName
// Manga library validation
if libraryTypeName == "manga" {
// Must be fixed-layout or comic archive
if mediaItem.FormatGroup != "fixed_layout" &&
mediaItem.FormatGroup != "comic_archive" {
msg := fmt.Sprintf(
"EPUB file '%s' is reflowable (text-based), not fixed-layout (image-based). "+
"Manga library only accepts fixed-layout EPUBs, CBZ, CBR, or image files. "+
"Consider moving this file to an ebooks library.",
mediaItem.Title,
)
return &msg
}
// Set manga-specific flags for fixed-layout EPUBs
if mediaItem.FormatGroup == "fixed_layout" {
// Default manga type if not set
if mediaItem.MangaType == "unknown" || mediaItem.MangaType == "" {
mediaItem.MangaType = "yes"
}
// Default reading direction for manga
if mediaItem.ReadingDirection == "auto" || mediaItem.ReadingDirection == "" {
mediaItem.ReadingDirection = "rtl"
}
}
}
// Comics library validation
if libraryTypeName == "comics" {
// Accept comic archives and fixed-layout
if mediaItem.FormatGroup != "comic_archive" &&
mediaItem.FormatGroup != "fixed_layout" {
msg := fmt.Sprintf(
"File '%s' is not a comic archive format. "+
"Comics library only accepts CBZ, CBR, CB7, CBT, PDF, or fixed-layout EPUBs.",
mediaItem.Title,
)
return &msg
}
}
// Ebooks library validation
if libraryTypeName == "ebooks" {
// Flag manga for potential reorganization (info level)
if mediaItem.FormatGroup == "fixed_layout" &&
(mediaItem.MangaType == "yes" || mediaItem.MangaType == "yes_and_right_to_left") {
msg := fmt.Sprintf(
"File '%s' appears to be manga (fixed-layout with images). "+
"Consider moving to a manga or comics library for better organization.",
mediaItem.Title,
)
return &msg
}
}
return nil // No issues
}
Change 3: Add Processing Issue Logging Function
Location: After ValidateMediaItemForLibrary function (after the function you just added)
Add this new function:
// LogProcessingIssue records items that can't be processed properly
func (s *MediaScanner) LogProcessingIssue(
ctx context.Context,
mediaItemID uuid.UUID,
libraryID uuid.UUID,
issueType string,
description string,
severity string,
) error {
_, err := s.db.CreateProcessingIssue(ctx, database.CreateProcessingIssueParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
IssueType: issueType,
IssueDescription: description,
Severity: severity,
})
return err
}
Change 4: Modify ScanFolders to Use Enhanced Detection
Location: In ScanFolders function, around line 1069-1080
Find this code:
case "application/epub+zip":
metadata, err = s.extractEPUBMetadata(path)
Replace with:
case "application/epub+zip":
metadata, err = s.extractEPUBMetadata(path)
if err == nil {
// Enhanced format detection for EPUBs
isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path)
if detectErr == nil && isFixedLayout {
// Override format group for manga EPUBs
metadata.FileFormats = []*FormatInfo{{
FormatType: "fixed_layout",
FilePath: path,
MimeType: mimeType,
}}
}
}
Phase 4: New Processing Issues Handler
New File: internal/handlers/processing_issues.go
Create this entire new file:
package handlers
import (
"bookhoard/internal/database"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type ProcessingIssuesHandler struct {
db *database.Queries
}
func NewProcessingIssuesHandler(db *database.Queries) *ProcessingIssuesHandler {
return &ProcessingIssuesHandler{db: db}
}
type ProcessingIssueResponse struct {
ID string `json:"id"`
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
FilePath string `json:"file_path"`
FormatGroup string `json:"format_group"`
LibraryTypeName string `json:"library_type_name"`
IssueType string `json:"issue_type"`
IssueDescription string `json:"issue_description"`
Severity string `json:"severity"`
CreatedAt time.Time `json:"created_at"`
}
type ProcessingIssueStats struct {
ErrorCount int64 `json:"error_count"`
WarningCount int64 `json:"warning_count"`
InfoCount int64 `json:"info_count"`
}
// ListProcessingIssues returns all processing issues for a library
func (h *ProcessingIssuesHandler) ListProcessingIssues(c echo.Context) error {
libraryID, err := uuid.Parse(c.Param("libraryId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
}
issues, err := h.db.ListProcessingIssuesByLibrary(
c.Request().Context(),
pgtype.UUID{Bytes: libraryID, Valid: true},
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch issues"})
}
response := make([]ProcessingIssueResponse, len(issues))
for i, issue := range issues {
response[i] = ProcessingIssueResponse{
ID: issue.ID.String(),
MediaItemID: issue.MediaItemID.String(),
Title: issue.Title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
IssueType: issue.IssueType,
IssueDescription: issue.IssueDescription,
Severity: issue.Severity,
CreatedAt: issue.CreatedAt.Time,
}
}
return c.JSON(http.StatusOK, response)
}
// GetProcessingIssueStats returns statistics about processing issues
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c echo.Context) error {
libraryID, err := uuid.Parse(c.Param("libraryId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
}
stats, err := h.db.GetProcessingIssueStats(
c.Request().Context(),
pgtype.UUID{Bytes: libraryID, Valid: true},
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch stats"})
}
return c.JSON(http.StatusOK, ProcessingIssueStats{
ErrorCount: stats.ErrorCount,
WarningCount: stats.WarningCount,
InfoCount: stats.InfoCount,
})
}
// ResolveProcessingIssue marks an issue as resolved
func (h *ProcessingIssuesHandler) ResolveProcessingIssue(c echo.Context) error {
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
mediaItemID, err := uuid.Parse(c.Param("mediaItemId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
_, err = h.db.ResolveProcessingIssue(
c.Request().Context(),
issueID,
pgtype.UUID{Bytes: mediaItemID, Valid: true},
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to resolve issue"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"message": "Issue marked as resolved",
})
}
// DeleteProcessingIssue permanently deletes an issue record
func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c echo.Context) error {
issueID, err := uuid.Parse(c.Param("issueId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
_, err = h.db.DeleteProcessingIssue(c.Request().Context(), issueID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete issue"})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"message": "Issue deleted",
})
}
Phase 5: Reader Handler Updates
File: internal/handlers/reader.go
Change 1: Update ShowReader for Panel Detection
Location: ShowReader function starting at line 56
Find this code (around line 94):
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
}
// Get reading progress
var progress database.ReadingProgress
Replace with:
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"})
}
// NEW: Get library with type information for panel detection decision
libraryWithType, err := h.db.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get library info"})
}
// NEW: Determine if panel detection should be enabled
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
// Get reading progress
var progress database.ReadingProgress
Change 2: Update JSON Response
Location: In ShowReader function, around line 113-125
Find this code:
// Return JSON response instead of rendering template
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"title": mediaItem.Title,
"author": textToString(mediaItem.Author),
"cover_image_path": textToString(mediaItem.CoverImagePath),
"library_type": mediaItem.FormatGroup,
"mime_type": textToString(mediaItem.MimeType),
"file_path": mediaItem.FilePath,
"total_pages": mediaItem.PageCount,
"chapter_count": mediaItem.ChapterCount,
"progress": progress,
"bookmarks": bookmarks,
})
Replace with:
// Return JSON response instead of rendering template
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"title": mediaItem.Title,
"author": textToString(mediaItem.Author),
"cover_image_path": textToString(mediaItem.CoverImagePath),
"library_type": libraryWithType.TypeName,
"mime_type": textToString(mediaItem.MimeType),
"file_path": mediaItem.FilePath,
"total_pages": mediaItem.PageCount,
"chapter_count": mediaItem.ChapterCount,
"progress": progress,
"bookmarks": bookmarks,
"enable_panel_detection": enablePanelDetection,
"format_group": mediaItem.FormatGroup,
"manga_type": mediaItem.MangaType,
"reading_direction": mediaItem.ReadingDirection,
})
Change 3: Add Helper Function
Location: At end of file, after ParseEbook function (after line 622)
Add this new function:
// shouldEnablePanelDetection determines if panel detection should be enabled
// based on library type and format group
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
panelLibraries := map[string]bool{
"manga": true,
"comics": true,
}
panelFormats := map[string]bool{
"fixed_layout": true,
"comic_archive": true,
}
return panelLibraries[libraryType] && panelFormats[formatGroup]
}
Phase 6: Route Registration
File: cmd/server/main.go
Location: Find handler registration section (around line 170-180)
Find this code:
// Library routes
libraryHandler := handlers.NewLibraryHandler(queries)
e.GET("/api/libraries", libraryHandler.ListLibraries)
e.GET("/api/libraries/types", libraryHandler.GetLibraryTypes)
After this section, add:
// Processing issues routes
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
e.GET("/api/libraries/:libraryId/issues/list", processingIssuesHandler.ListProcessingIssues)
e.GET("/api/libraries/:libraryId/issues/stats", processingIssuesHandler.GetProcessingIssueStats)
e.PUT("/api/libraries/:libraryId/issues/:issueId/media/:mediaItemId/resolve", processingIssuesHandler.ResolveProcessingIssue)
e.DELETE("/api/libraries/:libraryId/issues/:issueId/media/:mediaItemId", processingIssuesHandler.DeleteProcessingIssue)
Phase 7: Frontend Templates
New File: templates/admin_processing_issues.templ
Create this entire new file:
package templates
templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Processing Issues - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="processingIssues" x-init="initializeProcessingIssues('{ libraryID }')" class="theme-{ user.Theme }">
@Header(user, "/admin/libraries/"+libraryID)
<main class="flex-1 p-8">
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold mb-2">Processing Issues</h1>
<p class="text-gray-600">Items that couldn't be processed in this library</p>
</div>
<a href="/admin/libraries/{ libraryID }" class="btn-secondary px-4 py-2 rounded-lg">
← Back to Library
</a>
</div>
</div>
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
if stats.ErrorCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-red-500">
<h3 class="text-lg font-semibold text-red-600 mb-2">Errors</h3>
<p class="text-3xl font-bold">{ stats.ErrorCount }</p>
</div>
}
if stats.WarningCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-yellow-500">
<h3 class="text-lg font-semibold text-yellow-600 mb-2">Warnings</h3>
<p class="text-3xl font-bold">{ stats.WarningCount }</p>
</div>
}
if stats.InfoCount > 0 {
<div class="card p-6 rounded-lg border-l-4 border-blue-500">
<h3 class="text-lg font-semibold text-blue-600 mb-2">Info</h3>
<p class="text-3xl font-bold">{ stats.InfoCount }</p>
</div>
}
</div>
}
if len(issues) == 0 {
<div class="card p-8 rounded-lg text-center">
<p class="text-gray-600">No processing issues found for this library.</p>
</div>
} else {
<!-- Issues List -->
<div class="space-y-4">
for _, issue := range issues {
<div class="card p-6 rounded-lg">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<h4 class="text-lg font-semibold mb-2">{ issue.Title }</h4>
<p class="text-gray-700 mb-3">{ issue.IssueDescription }</p>
<div class="text-sm text-gray-500 space-y-1">
<p><strong>Type:</strong> { issue.IssueType }</p>
<p><strong>Format:</strong> { issue.FormatGroup }</p>
<p><strong>File:</strong> { issue.FilePath }</p>
<p><strong>Library:</strong> { issue.LibraryTypeName }</p>
</div>
</div>
<div class="ml-4">
<span class="inline-block px-3 py-1 text-sm rounded-full font-medium"
style="background-color: var(--accent); color: white;">
{ issue.Severity }
</span>
</div>
</div>
<div class="flex gap-3 mt-4">
if issue.Severity == "warning" || issue.Severity == "info" {
<button @click="dismissIssue('{ issue.ID }', '{ issue.MediaItemID }')"
class="btn-secondary px-4 py-2 rounded text-sm">
Dismiss
</button>
}
</div>
</div>
}
</div>
}
</main>
</body>
</html>
}
Phase 8: Update Reader Template
File: templates/reader.templ
Location: Update the x-init attribute to include panel detection config
Find this code:
<body x-data="readerShell" x-init="initReader()" class="theme-tokyo-night">
Replace with:
<body x-data="readerShell"
x-init="initReader({
mediaItemId: '{ readerData.MediaItemID }',
title: '{ readerData.Title }',
enablePanelDetection: { readerData.EnablePanelDetection },
libraryType: '{ readerData.LibraryType }',
formatGroup: '{ readerData.FormatGroup }',
mangaType: '{ readerData.MangaType }',
readingDirection: '{ readerData.ReadingDirection }'
})"
class="theme-tokyo-night">
Phase 9: Update Reader JavaScript
File: web/src/reader/reader.ts
Location: Replace entire file content
Replace with:
import "foliate-js/view.js";
import { Alpine } from "../alpine";
document.addEventListener("alpine:init", () => {
Alpine.data("readerShell", () => ({
enablePanelDetection: false,
libraryType: '',
formatGroup: '',
mangaType: '',
readingDirection: '',
panelDetector: null,
initReader(config: any) {
this.enablePanelDetection = config.enablePanelDetection;
this.libraryType = config.libraryType;
this.formatGroup = config.formatGroup;
this.mangaType = config.mangaType;
this.readingDirection = config.readingDirection;
console.log("Reader initialized with:", {
panelDetection: this.enablePanelDetection,
library: this.libraryType,
format: this.formatGroup
});
// Only load panel detection if enabled
if (this.enablePanelDetection) {
this.loadPanelDetection();
}
},
async loadPanelDetection() {
try {
// Dynamic import to only load when needed
const { PanelDetector } = await import('foliate-js/panel-detection.js');
this.panelDetector = new PanelDetector();
console.log("Panel detection loaded successfully");
} catch (error) {
console.error("Failed to load panel detection:", error);
}
},
nextPage() {
const view = document.querySelector("#reader-view");
// @ts-ignore - foliate custom element
view?.next?.();
},
previousPage() {
const view = document.querySelector("#reader-view");
// @ts-ignore - foliate custom element
view?.prev?.();
}
}));
Alpine.start();
});
Implementation Order
Step 1: Database Changes
# Run schema changes
psql -U your_user -d bookhoard -f database/schema/schema.sql
# Generate database code
cd internal/database
sqlc generate
# Verify new queries were generated
ls -la queries.sql.go
Step 2: Backend Code Changes
# Add scanner enhancements
# Edit internal/services/media_scanner.go
# Add processing issues handler
# Create internal/handlers/processing_issues.go
# Update reader handler
# Edit internal/handlers/reader.go
# Update routes
# Edit cmd/server/main.go
Step 3: Frontend Changes
# Create processing issues template
# Create templates/admin_processing_issues.templ
# Update reader template
# Edit templates/reader.templ
# Update reader JavaScript
# Edit web/src/reader/reader.ts
# Regenerate templates
cd templates
templ generate
# Build frontend
cd ../web
npm run build:ts
Step 4: Testing
# Restart server
go run cmd/server/main.go
# Test 1: Scan manga directory with EPUBs
# Check that fixed-layout EPUBs are detected correctly
# Test 2: Verify processing issues are created
# Check database: SELECT * FROM processing_issues;
# Test 3: Test reader with different library types
# - Manga library with CBZ → panel detection enabled
# - Manga library with fixed-layout EPUB → panel detection enabled
# - Ebooks library with PDF → panel detection disabled
# - Comics library with PDF → panel detection enabled
# Test 4: Check processing issues UI
# Visit: /admin/libraries/{libraryId}/issues/list
Step 5: Verification
# Check database changes
\dt processing_issues
\d+ processing_issues
SELECT * FROM library_types WHERE name = 'manga';
# Verify scanner behavior
grep -n "DetectFixedLayoutEPUB" internal/services/media_scanner.go
# Verify handler is registered
grep -n "processingIssuesHandler" cmd/server/main.go
# Test API endpoints
curl http://localhost:8765/api/libraries/{libraryId}/issues/list
curl http://localhost:8765/api/libraries/{libraryId}/issues/stats
Testing Checklist
- Manga library accepts
.epubfiles - Fixed-layout EPUBs detected correctly
- Reflowable EPUBs in manga library create processing issues
- Processing issues API endpoints work
- Processing issues UI displays correctly
- Reader enables panel detection for manga library
- Reader enables panel detection for comics library
- Reader disables panel detection for ebooks library
- PDF ebooks don't get panel detection
- PDF comics get panel detection
Rollback Plan
If issues occur:
# 1. Revert database changes
psql -U your_user -d bookhoard -c "DROP TABLE IF EXISTS processing_issues CASCADE;"
psql -U your_user -d bookhoard -c "UPDATE library_types SET allowed_extensions = ARRAY['.cbz','.cbr','.png','.jpg','.jpeg','.gif','.bmp','.webp'] WHERE name = 'manga';"
# 2. Remove code changes
git checkout HEAD -- internal/services/media_scanner.go
git checkout HEAD -- internal/handlers/reader.go
rm internal/handlers/processing_issues.go
git checkout HEAD -- cmd/server/main.go
# 3. Regenerate database code
cd internal/database && sqlc generate
# 4. Regenerate templates
cd templates && templ generate
# 5. Rebuild frontend
cd web && npm run build:ts
# 6. Restart server
Notes
- All changes follow existing code patterns
- No breaking changes to existing functionality
- Processing issues are warnings only, don't block scanning
- Panel detection is determined by library type + format group
- PDF panel detection works for comics but not ebooks
- Fixed-layout detection uses multiple heuristics for reliability