refactor: restructure project from bookmann to shelf
- Rename project from 'bookmann' to 'shelf' - Move all backend/ contents to root level (flatten structure) - Update Go module name from 'bookmann' to 'shelf' - Update all import paths to use new 'shelf' module - Update Dockerfile to work without backend/ subdirectory - Update docker-compose.yml to use new structure and rename containers - Update .gitignore for new file paths - Update README.md with new project name and structure - Regenerate database code with new module imports
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"shelf/internal/database"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
epub "github.com/ArcadiaLin/go-epub"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
for _, folder := range s.folders {
|
||||
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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) {
|
||||
if err := s.processEbookFile(ctx, path); err != nil {
|
||||
fmt.Printf("Error processing ebook %s: %v\n", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan folder %s: %v", folder, err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error {
|
||||
// Get file info
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file info: %v", err)
|
||||
}
|
||||
|
||||
// Check if ebook already exists in database
|
||||
existingEbook, err := s.getEbookByFilePath(ctx, path)
|
||||
if err == nil {
|
||||
// Ebook exists, check if file has changed (by size)
|
||||
if existingEbook.FileSize.Int64 != info.Size() {
|
||||
return s.updateEbook(ctx, existingEbook.ID, path, info)
|
||||
}
|
||||
return nil // Skip if already exists and size matches
|
||||
} else if err.Error() != "sql: no rows in result set" {
|
||||
// Some other error occurred
|
||||
return fmt.Errorf("failed to check if ebook exists: %v", err)
|
||||
}
|
||||
// Ebook doesn't exist, continue with creation
|
||||
|
||||
// Extract metadata
|
||||
metadata, err := s.extractMetadata(path)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, err)
|
||||
// Continue with basic metadata
|
||||
metadata = &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
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 != ""},
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return s.extractEPUBMetadata(path)
|
||||
default:
|
||||
// For other formats, return basic metadata
|
||||
return &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
}, 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]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors
|
||||
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
|
||||
metadata.Contributors = strings.Join(contributors, ", ")
|
||||
}
|
||||
|
||||
return metadata, 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{}, // Keep existing 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{}, // Keep existing 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
|
||||
}
|
||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
|
||||
if 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
|
||||
}
|
||||
Reference in New Issue
Block a user