Files
bookhoard/MANGA_EPUB_IMPLEMENTATION.md
T
john-okeefe 88982ec11e docs: Add comprehensive implementation plan for manga EPUB and panel detection
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.
2026-04-12 19:03:49 -04:00

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

  1. Phase 1: Database Schema Changes
  2. Phase 2: Database Query Additions
  3. Phase 3: Scanner Service Enhancements
  4. Phase 4: New Processing Issues Handler
  5. Phase 5: Reader Handler Updates
  6. Phase 6: Route Registration
  7. Phase 7: Frontend Templates
  8. Phase 8: Update Reader Template
  9. Phase 9: Update Reader JavaScript
  10. 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 .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:

# 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