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 }