feat(scanner): Add fixed-layout EPUB detection for manga support

- Add DetectFixedLayoutEPUB method to identify manga-style EPUBs
- Check for rendition:layout pre-paginated metadata
- Check for RTL page-progression-direction (manga indicator)
- Check image count threshold (>50 images suggests manga/comic)
- Check subject tags for manga/comic keywords
- Enable proper format detection for manga EPUBs in libraries
This commit is contained in:
2026-04-12 20:43:49 -04:00
parent 059955be72
commit f7610c6063
+168 -1
View File
@@ -1068,7 +1068,15 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
case ".epub":
metadata, err := s.extractEPUBMetadata(path)
if err != nil {
return metadata, err
// 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,
}}
}
// Try to extract embedded cover
coverPath, err := s.extractEPUBCover(path)
@@ -1181,6 +1189,165 @@ func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error)
return metadata, nil
}
// 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
}
// 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
}
// 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
}
// parseCalibreMetadataOPF parses a Calibre metadata.opf file and extracts metadata
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
// Open file