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":
|
case ".epub":
|
||||||
metadata, err := s.extractEPUBMetadata(path)
|
metadata, err := s.extractEPUBMetadata(path)
|
||||||
if err != nil {
|
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
|
// Try to extract embedded cover
|
||||||
coverPath, err := s.extractEPUBCover(path)
|
coverPath, err := s.extractEPUBCover(path)
|
||||||
@@ -1181,6 +1189,165 @@ func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error)
|
|||||||
return metadata, nil
|
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
|
// parseCalibreMetadataOPF parses a Calibre metadata.opf file and extracts metadata
|
||||||
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
|
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
|
||||||
// Open file
|
// Open file
|
||||||
|
|||||||
Reference in New Issue
Block a user