fix(scanner): populate page count and total characters during media scanning

The media scanner never populated page_count or total_characters in
media_items, leaving progress display and reading position calculations
with no reliable data. This commit fixes data population for all formats:

Comics (CBZ/CBR/CB7/CBT):
- Add countArchiveImages() helper that walks archive entries and counts
  image files (.jpg, .jpeg, .png, .gif, .webp)
- Call it during comic metadata merge to set metadata.PageCount

PDFs:
- Extract pdfInfo.PageCount from the pdfcpu library (already available
  from PDFInfo call, just never used) and set metadata.PageCount

Reflowable EPUBs:
- Use book.AllChaptersText() to compute metadata.TotalCharacters
- Use book.ChapterCount() to set metadata.ChapterCount

Fixed-layout EPUBs (manga/comics in EPUB format):
- Merge .epub into the .cbz case in countArchiveImages since both are
  ZIP archives with images
- Detect fixed-layout EPUBs via DetectFixedLayoutEPUB() in both the
  Calibre sidecar path (mergeMetadata) and the no-sidecar path
  (extractMetadata), counting images when fixed-layout is detected

Format group on creation:
- Remove the guard condition on UpdateMediaItemFormatGroup so that
  format_group, is_reflowable, and has_fixed_layout are set immediately
  for every new item (not just items with text data)
- Use DetectFixedLayoutEPUB() instead of hardcoding all .epub as
  reflowable, correctly classifying fixed-layout EPUBs

Also pass PageCount to CreateMediaItem and add PageCount,
TotalCharacters, and ChapterCount fields to the MediaMetadata struct.
This commit is contained in:
2026-04-24 14:01:57 -04:00
parent 163b3162b9
commit 93197b2e31
+134 -2
View File
@@ -75,6 +75,9 @@ type MediaMetadata struct {
WebURL string // URL to info page (Goodreads, ComicVine, etc.)
MetadataNotes string // Notes from metadata files (not user notes)
CommunityRating float64 // Pre-existing community rating (0-10)
PageCount int32 // Actual page count (images for comics, pages for PDF)
TotalCharacters int64 // Total text characters (for reflowable EPUBs)
ChapterCount int32 // Number of chapters detected
// Comic-specific fields
StoryArc string // Story arc name
@@ -707,6 +710,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
})
if err != nil {
return false, fmt.Errorf("failed to create media item: %v", err)
@@ -726,6 +730,46 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
}
}
// Set format group, total characters, and chapter count
mimeType := s.getMimeType(path)
ext := strings.ToLower(filepath.Ext(path))
var formatGroup string
var isReflowable, hasFixedLayout bool
switch ext {
case ".epub":
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
if fixedErr == nil && isFixed {
formatGroup = "fixed_layout"
hasFixedLayout = true
} else {
formatGroup = "reflowable"
isReflowable = true
}
case ".mobi", ".azw", ".azw3", ".fb2", ".txt":
formatGroup = "reflowable"
isReflowable = true
case ".pdf", ".djvu":
formatGroup = "fixed_layout"
hasFixedLayout = true
case ".cbz", ".cbr", ".cb7", ".cbt":
formatGroup = "comic_archive"
hasFixedLayout = true
default:
formatGroup = "unknown"
}
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: createdItem.ID,
FormatGroup: formatGroup,
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
})
if err != nil {
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
}
// Store format information in the database
for _, format := range metadata.FileFormats {
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
@@ -783,6 +827,16 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
if err == nil {
genreTags := extractGenreTagsFromEPUB(book)
processGenresAndTags(metadata, genreTags)
if allText := book.AllChaptersText(); len(allText) > 0 {
metadata.TotalCharacters = int64(len(allText))
}
metadata.ChapterCount = int32(book.ChapterCount())
}
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
if fixedErr == nil && isFixed {
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
metadata.PageCount = int32(pageCount)
}
}
}
@@ -899,6 +953,10 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
fmt.Printf("Merged comic metadata from %s: title=%s, series=%s, issue=%d, manga=%s, direction=%s\n",
path, comicInfo.Title, comicInfo.Series, comicInfo.Number, comicInfo.Manga, metadata.ReadingDirection)
}
if pageCount, err := countArchiveImages(path); err == nil && pageCount > 0 {
metadata.PageCount = int32(pageCount)
}
}
return metadata, nil
@@ -1089,12 +1147,14 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
// 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: s.getMimeType(path),
}}
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
metadata.PageCount = int32(pageCount)
}
}
// Try to extract embedded cover
coverPath, err := s.extractEPUBCover(path)
@@ -1832,6 +1892,8 @@ func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
metadata.Publisher = pdfInfo.Producer
}
metadata.PageCount = int32(pdfInfo.PageCount)
// Try to extract cover image
coverPath, err := s.extractPDFCover(path)
if err != nil {
@@ -2276,7 +2338,77 @@ func (t *tarFileAdapter) Open() (io.ReadCloser, error) {
// isImageFile checks if a file is an image based on extension
func isImageFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif"
return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp"
}
// countArchiveImages counts image files in a comic archive
func countArchiveImages(filePath string) (int, error) {
ext := strings.ToLower(filepath.Ext(filePath))
count := 0
switch ext {
case ".cbz", ".epub":
r, err := zip.OpenReader(filePath)
if err != nil {
return 0, err
}
defer r.Close()
for _, f := range r.File {
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
count++
}
}
case ".cbr":
r, err := rardecode.OpenReader(filePath, "")
if err != nil {
return 0, err
}
defer r.Close()
for {
header, err := r.Next()
if err == io.EOF {
break
}
if err != nil {
break
}
if !header.IsDir && isImageFile(header.Name) {
count++
}
}
case ".cb7":
sz, err := sevenzip.OpenReader(filePath)
if err != nil {
return 0, err
}
defer sz.Close()
for _, f := range sz.File {
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
count++
}
}
case ".cbt":
f, err := os.Open(filePath)
if err != nil {
return 0, err
}
defer f.Close()
tr := tar.NewReader(f)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
break
}
if !header.FileInfo().IsDir() && isImageFile(header.Name) {
count++
}
}
}
return count, nil
}
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error {