- Add markdownlint disable directives for linting - Update SQL examples for consistency - Update templ examples for panel detection integration - Update TypeScript examples for reader shell configuration
1052 lines
30 KiB
Markdown
1052 lines
30 KiB
Markdown
<!-- markdownlint-disable MD010 -->
|
|
<!-- markdownlint-disable MD013 -->
|
|
|
|
# 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 <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:**
|
|
|
|
```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 <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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
case "application/epub+zip":
|
|
metadata, err = s.extractEPUBMetadata(path)
|
|
```
|
|
|
|
**Replace with:**
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
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):**
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```go
|
|
// Library routes
|
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
|
e.GET("/api/libraries", libraryHandler.ListLibraries)
|
|
e.GET("/api/libraries/types", libraryHandler.GetLibraryTypes)
|
|
```
|
|
|
|
**After this section, add:**
|
|
|
|
```go
|
|
// 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:**
|
|
|
|
```templ
|
|
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:**
|
|
|
|
```templ
|
|
<body x-data="readerShell" x-init="initReader()" class="theme-tokyo-night">
|
|
```
|
|
|
|
**Replace with:**
|
|
|
|
```templ
|
|
<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:**
|
|
|
|
```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
|