- Fix type assertion panics in library.go (lines 58, 109, 237) Changed from *database.Users to database.Users to match JWT middleware - Fix ISBN type mismatch in ebook.go (lines 249, 308) Changed from pgtype.Text to string to match database schema - Fix ISBN type mismatch in ebook_scanner.go (line 421) Changed from pgtype.Text to string to match database schema These changes fix 500 errors in library creation and ebook operations.
508 lines
15 KiB
Go
508 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
|
|
adminID pgtype.UUID
|
|
}
|
|
|
|
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) SetAdminID(adminID pgtype.UUID) {
|
|
s.adminID = adminID
|
|
}
|
|
|
|
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 {
|
|
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 != ""},
|
|
AddedByAdminID: s.adminID,
|
|
})
|
|
|
|
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: 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
|
|
}
|