# 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 1. [Phase 1: Database Schema Changes](#phase-1-database-schema-changes) 2. [Phase 2: Database Query Additions](#phase-2-database-query-additions) 3. [Phase 3: Scanner Service Enhancements](#phase-3-scanner-service-enhancements) 4. [Phase 4: New Processing Issues Handler](#phase-4-new-processing-issues-handler) 5. [Phase 5: Reader Handler Updates](#phase-5-reader-handler-updates) 6. [Phase 6: Route Registration](#phase-6-route-registration) 7. [Phase 7: Frontend Templates](#phase-7-frontend-templates) 8. [Phase 8: Update Reader Template](#phase-8-update-reader-template) 9. [Phase 9: Update Reader JavaScript](#phase-9-update-reader-javascript) 10. [Implementation Order](#implementation-order) --- ## Phase 1: Database Schema Changes ### File: `database/schema/schema.sql` **Location:** Add at end of file (after line 1253) **Changes to add:** ```sql -- ============================================================================ -- 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 tags) -- Threshold of 50 images suggests manga/comic vs novel IF (SELECT COUNT(*) FROM regexp_matches(opf_content, ']+>', '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:** ```sql -- ============================================================================ -- 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:** ```go // 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 tags) // Threshold of 50 images suggests manga/comic vs novel imgCount := strings.Count(opfString, ` 50 { return true, nil } // Check 4: Manga subject tag lowerOPF := strings.ToLower(opfString) if strings.Contains(lowerOPF, ` Processing Issues - Bookhoard @Header(user, "/admin/libraries/"+libraryID)

Processing Issues

Items that couldn't be processed in this library

← Back to Library
if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 {
if stats.ErrorCount > 0 {

Errors

{ stats.ErrorCount }

} if stats.WarningCount > 0 {

Warnings

{ stats.WarningCount }

} if stats.InfoCount > 0 {

Info

{ stats.InfoCount }

}
} if len(issues) == 0 {

No processing issues found for this library.

} else {
for _, issue := range issues {

{ issue.Title }

{ issue.IssueDescription }

Type: { issue.IssueType }

Format: { issue.FormatGroup }

File: { issue.FilePath }

Library: { issue.LibraryTypeName }

{ issue.Severity }
if issue.Severity == "warning" || issue.Severity == "info" { }
}
}
} ``` --- ## Phase 8: Update Reader Template ### File: `templates/reader.templ` **Location:** Update the `x-init` attribute to include panel detection config **Find this code:** ```templ ``` **Replace with:** ```templ ``` --- ## Phase 9: Update Reader JavaScript ### File: `web/src/reader/reader.ts` **Location:** Replace entire file content **Replace with:** ```typescript 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 ```bash # 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 ```bash # 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 ```bash # 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 ```bash # 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 ```bash # 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 `.epub` files - [ ] 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: ```bash # 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