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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user