Files
bookhoard/internal/services/ebook_scanner.go
T
john-okeefe 08e80ae84b refactor: reorganize project structure and update configurations
- Move migrations/ to database/schema/ for clarity on database schema definitions
- Move sqlc.yaml to internal/database/ to group with database code
- Move static/ to cmd/server/static/ to co-locate with server
- Update all configuration files and documentation
- Follow Go project conventions for better organization
2026-01-24 23:40:31 -05:00

502 lines
15 KiB
Go

package services
import (
"bookmann/internal/database"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type EbookMetadata struct {
Title string
Author string
Description string
Series string
SeriesNumber int32
Publisher string
PublishDate time.Time
Contributors string
CoverPath string
ISBN string
Tags string
}
type EbookScanner struct {
db *database.Queries
watcher *fsnotify.Watcher
folders []string
}
func NewEbookScanner(db *database.Queries) *EbookScanner {
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
}
return &EbookScanner{
db: db,
watcher: watcher,
}
}
func (s *EbookScanner) SetFolders(folders []string) error {
s.folders = folders
// Remove old watch if exists
if s.watcher != nil {
s.watcher.Close()
}
// Create new watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create watcher: %v", err)
}
s.watcher = watcher
// Add all folders to watch
for _, folder := range folders {
if err := s.watcher.Add(folder); err != nil {
fmt.Printf("Warning: failed to watch folder %s: %v\n", folder, err)
}
}
return nil
}
func (s *EbookScanner) ScanFolders(ctx context.Context) error {
if len(s.folders) == 0 {
return fmt.Errorf("no folders set")
}
fmt.Printf("Starting scan of %d folders: %v\n", len(s.folders), s.folders)
totalFiles := 0
ebookFiles := 0
for _, folder := range s.folders {
fmt.Printf("Scanning folder: %s\n", folder)
// Check if folder exists
if _, err := os.Stat(folder); os.IsNotExist(err) {
fmt.Printf("Folder does not exist: %s\n", folder)
continue
}
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if err != nil {
fmt.Printf("Error accessing path %s: %v\n", path, err)
return err
}
totalFiles++
if d.IsDir() {
// Also watch subdirectories
if err := s.watcher.Add(path); err != nil {
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
}
return nil
}
// Check if it's an ebook file
if s.isEbookFile(path) {
ebookFiles++
fmt.Printf("Found ebook file: %s\n", path)
if err := s.processEbookFile(ctx, path); err != nil {
fmt.Printf("Error processing ebook %s: %v\n", path, err)
} else {
fmt.Printf("Successfully processed ebook: %s\n", path)
}
}
return nil
})
if err != nil {
return fmt.Errorf("failed to scan folder %s: %v", folder, err)
}
}
fmt.Printf("Scan completed: %d total files scanned, %d ebook files found\n", totalFiles, ebookFiles)
return nil
}
func (s *EbookScanner) isEbookFile(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub", ".pdf", ".mobi", ".azw3", ".fb2", ".txt":
return true
default:
return false
}
}
// extractFolderStructureMetadata extracts metadata from folder paths, prioritizing Calibre structure
func (s *EbookScanner) extractFolderStructureMetadata(path, rootFolder string) *EbookMetadata {
metadata := &EbookMetadata{}
// Get the relative path from root folder
relPath, err := filepath.Rel(rootFolder, path)
if err != nil {
return metadata
}
// Split into directory components
dir := filepath.Dir(relPath)
components := strings.Split(dir, string(filepath.Separator))
if len(components) < 2 {
return metadata // Not enough structure to extract
}
// Calibre structure detection
// Pattern 1: Author Name/Book Title/
// Pattern 2: Author Name/Series Name/Book Title/
// Pattern 3: Author Name/Series Name, Book #1 - Book Title/
author := strings.TrimSuffix(components[0], "_") // Remove trailing underscore if present
metadata.Author = strings.ReplaceAll(author, "_", " ")
if len(components) >= 3 {
// This might be a series structure
possibleSeries := components[1]
possibleTitle := components[2]
// Check for Calibre series format: "Series Name, Book #1 - Title"
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
if len(seriesMatch) == 4 {
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
if seriesNum, err := strconv.ParseInt(seriesMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
}
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
} else {
// Simple series structure: Author/Series/Title
metadata.Series = strings.ReplaceAll(possibleSeries, "_", " ")
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
// Try to extract series number from title
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+)$`).FindStringSubmatch(metadata.Title)
if len(titleNumMatch) == 3 {
metadata.Title = titleNumMatch[1]
if seriesNum, err := strconv.ParseInt(titleNumMatch[2], 10, 32); err == nil {
metadata.SeriesNumber = int32(seriesNum)
}
}
}
} else {
// Simple structure: Author/Title
metadata.Title = strings.ReplaceAll(components[1], "_", " ")
}
return metadata
}
func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error {
fmt.Printf("Processing ebook file: %s\n", path)
// Get file info
info, err := os.Stat(path)
if err != nil {
fmt.Printf("Failed to get file info for %s: %v\n", path, err)
return fmt.Errorf("failed to get file info: %v", err)
}
fmt.Printf("File info for %s: size=%d\n", path, info.Size())
// Check if ebook already exists in database
existingEbook, err := s.getEbookByFilePath(ctx, path)
if err == nil {
fmt.Printf("Ebook already exists in database: %s (size: %d vs %d)\n", path, existingEbook.FileSize.Int64, info.Size())
// Ebook exists, check if file has changed (by size)
if existingEbook.FileSize.Int64 != info.Size() {
fmt.Printf("File size changed, updating ebook: %s\n", path)
return s.updateEbook(ctx, existingEbook.ID, path, info)
}
fmt.Printf("Ebook already exists with same size, skipping: %s\n", path)
return nil // Skip if already exists and size matches
} else if err != pgx.ErrNoRows && !strings.Contains(err.Error(), "no rows") {
fmt.Printf("Database error checking ebook existence: %v\n", err)
// Some other error occurred
return fmt.Errorf("failed to check if ebook exists: %v", err)
}
fmt.Printf("Ebook does not exist in database, creating new entry: %s\n", path)
// Extract metadata from file first
metadata, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
metadata = &EbookMetadata{}
}
// Try to get metadata from folder structure as fallback/enhancement
// Use the root folder that contains this file
var rootFolder string
for _, folder := range s.folders {
if strings.HasPrefix(path, folder) {
rootFolder = folder
break
}
}
if rootFolder != "" {
folderMetadata := s.extractFolderStructureMetadata(path, rootFolder)
// Use folder metadata as fallback for missing information
if metadata.Title == "" && folderMetadata.Title != "" {
metadata.Title = folderMetadata.Title
}
if metadata.Author == "" && folderMetadata.Author != "" {
metadata.Author = folderMetadata.Author
}
if metadata.Series == "" && folderMetadata.Series != "" {
metadata.Series = folderMetadata.Series
}
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
metadata.SeriesNumber = folderMetadata.SeriesNumber
}
}
// Final fallback if still missing essential metadata
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if metadata.Author == "" {
metadata.Author = "Unknown"
}
// Create ebook in database
_, err = s.db.CreateEbook(ctx, database.CreateEbookParams{
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
FilePath: path,
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
})
return err
}
func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
return s.extractEPUBMetadata(path)
case ".pdf":
return s.extractPDFMetadata(path)
default:
// For other formats, return basic metadata
return &EbookMetadata{
Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil
}
}
func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error) {
book, err := epub.ReadBook(path)
if err != nil {
return nil, fmt.Errorf("failed to open EPUB: %v", err)
}
metadata := &EbookMetadata{}
// Title
if title, err := book.Title(); err == nil && title != "" {
metadata.Title = title
}
// Author
if authors, err := book.MetadataByKey("creator"); err == nil && len(authors) > 0 {
metadata.Author = authors[0]
}
// Description
if descriptions, err := book.MetadataByKey("description"); err == nil && len(descriptions) > 0 {
metadata.Description = descriptions[0]
}
// Publisher
if publishers, err := book.MetadataByKey("publisher"); err == nil && len(publishers) > 0 {
metadata.Publisher = publishers[0]
}
// Series and series number (Calibre specific metadata)
if series, err := book.MetadataByKey("calibre:series"); err == nil && len(series) > 0 {
metadata.Series = series[0]
}
if seriesIndex, err := book.MetadataByKey("calibre:series_index"); err == nil && len(seriesIndex) > 0 {
if index, err := strconv.ParseFloat(seriesIndex[0], 32); err == nil {
metadata.SeriesNumber = int32(index)
}
}
// Publish date
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
metadata.PublishDate = date
} else {
// Try alternative date formats
if date, err := time.Parse("2006", dates[0]); err == nil {
metadata.PublishDate = date
}
}
}
// Contributors
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
metadata.Contributors = strings.Join(contributors, ", ")
}
// ISBN
if isbns, err := book.MetadataByKey("identifier"); err == nil && len(isbns) > 0 {
for _, isbn := range isbns {
if strings.Contains(strings.ToLower(isbn), "isbn") {
// Extract ISBN number from identifier like "isbn:978-3-16-148410-0"
isbnParts := strings.SplitN(isbn, ":", 2)
if len(isbnParts) == 2 {
metadata.ISBN = isbnParts[1]
break
}
}
}
}
// Tags
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
metadata.Tags = strings.Join(tags, ", ")
}
return metadata, nil
}
func (s *EbookScanner) extractPDFMetadata(path string) (*EbookMetadata, error) {
// For now, return basic metadata since PDF extraction requires additional libraries
// In a future enhancement, you could use libraries like github.com/ledongthuc/pdf
filename := strings.TrimSuffix(filepath.Base(path), ".pdf")
return &EbookMetadata{
Title: filename,
}, nil
}
func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, filePath string, info os.FileInfo) error {
metadata, err := s.extractMetadata(filePath)
if err != nil {
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", filePath, err)
metadata = &EbookMetadata{
Title: filepath.Base(filePath),
}
}
_, err = s.db.UpdateEbook(ctx, database.UpdateEbookParams{
ID: ebookID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: metadata.ISBN, Valid: metadata.ISBN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Tags: pgtype.Text{String: metadata.Tags, Valid: metadata.Tags != ""},
Asin: pgtype.Text{}, // Keep existing ASIN
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
Contributors: pgtype.Text{String: metadata.Contributors, Valid: metadata.Contributors != ""},
})
return err
}
func (s *EbookScanner) getEbookByFilePath(ctx context.Context, filePath string) (database.Ebooks, error) {
return s.db.GetEbookByFilePath(ctx, filePath)
}
func (s *EbookScanner) getMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
return "application/epub+zip"
case ".pdf":
return "application/pdf"
case ".mobi":
return "application/x-mobipocket-ebook"
case ".azw3":
return "application/vnd.amazon.ebook"
case ".fb2":
return "application/x-fictionbook+xml"
case ".txt":
return "text/plain"
default:
return "application/octet-stream"
}
}
func (s *EbookScanner) WatchChanges(ctx context.Context) {
go func() {
for {
select {
case event, ok := <-s.watcher.Events:
if !ok {
return
}
// Handle new directories - add them to the watcher
if event.Has(fsnotify.Create) {
info, err := os.Stat(event.Name)
if err == nil && info.IsDir() {
// Add the new directory to the watcher
if err := s.watcher.Add(event.Name); err != nil {
fmt.Printf("Warning: failed to watch new directory %s: %v\n", event.Name, err)
} else {
fmt.Printf("Now watching new directory: %s\n", event.Name)
}
}
}
// Handle file modifications and creations
if (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) && s.isEbookFile(event.Name) {
fmt.Printf("New/modified ebook detected: %s\n", event.Name)
if err := s.processEbookFile(ctx, event.Name); err != nil {
fmt.Printf("Error processing modified ebook %s: %v\n", event.Name, err)
}
}
case err, ok := <-s.watcher.Errors:
if !ok {
return
}
fmt.Printf("Watcher error: %v\n", err)
case <-ctx.Done():
return
}
}
}()
}
func (s *EbookScanner) Close() error {
if s.watcher != nil {
return s.watcher.Close()
}
return nil
}