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
This commit is contained in:
@@ -19,7 +19,7 @@ type Querier interface {
|
||||
DeleteEbookRating(ctx context.Context, arg DeleteEbookRatingParams) error
|
||||
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error
|
||||
DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error)
|
||||
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
|
||||
GetEbookByFilePath(ctx context.Context, filePath string) (Ebooks, error)
|
||||
GetEbookRating(ctx context.Context, arg GetEbookRatingParams) (EbookRatings, error)
|
||||
|
||||
@@ -223,8 +223,8 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :exec
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2
|
||||
const DeleteUserEbookFolder = `-- name: DeleteUserEbookFolder :one
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING id, user_id, folder_path, created_at
|
||||
`
|
||||
|
||||
type DeleteUserEbookFolderParams struct {
|
||||
@@ -232,9 +232,16 @@ type DeleteUserEbookFolderParams struct {
|
||||
FolderPath string `db:"folder_path" json:"folder_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) error {
|
||||
_, err := q.db.Exec(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
|
||||
return err
|
||||
func (q *Queries) DeleteUserEbookFolder(ctx context.Context, arg DeleteUserEbookFolderParams) (UserEbookFolders, error) {
|
||||
row := q.db.QueryRow(ctx, DeleteUserEbookFolder, arg.UserID, arg.FolderPath)
|
||||
var i UserEbookFolders
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.FolderPath,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetEbook = `-- name: GetEbook :one
|
||||
|
||||
@@ -131,8 +131,8 @@ INSERT INTO user_ebook_folders (user_id, folder_path) VALUES ($1, $2) RETURNING
|
||||
-- name: GetUserEbookFolders :many
|
||||
SELECT * FROM user_ebook_folders WHERE user_id = $1 ORDER BY created_at;
|
||||
|
||||
-- name: DeleteUserEbookFolder :exec
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2;
|
||||
-- name: DeleteUserEbookFolder :one
|
||||
DELETE FROM user_ebook_folders WHERE user_id = $1 AND folder_path = $2 RETURNING *;
|
||||
|
||||
-- name: GetEbookByFilePath :one
|
||||
SELECT * FROM ebooks WHERE file_path = $1;
|
||||
@@ -0,0 +1,17 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "database/schema"
|
||||
queries: "internal/database/queries"
|
||||
gen:
|
||||
go:
|
||||
package: "database"
|
||||
out: "internal/database"
|
||||
sql_package: "pgx/v5"
|
||||
emit_db_tags: true
|
||||
emit_prepared_queries: true
|
||||
emit_interface: true
|
||||
emit_exact_table_names: true
|
||||
emit_empty_slices: true
|
||||
emit_exported_queries: true
|
||||
emit_json_tags: true
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bookmann/internal/database"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -370,6 +373,31 @@ type AddEbookFolderRequest struct {
|
||||
FolderPath string `json:"folder_path" validate:"required"`
|
||||
}
|
||||
|
||||
// normalizePath cleans and normalizes folder paths for consistent storage and comparison
|
||||
func normalizePath(path string) string {
|
||||
fmt.Printf("normalizePath input: '%s'\n", path)
|
||||
|
||||
var cleaned string
|
||||
// Handle home directory expansion (~)
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
// Keep the original path for ~ to preserve user's formatting
|
||||
// Just normalize separators and that's it
|
||||
cleaned = strings.ReplaceAll(path, "\\", "/")
|
||||
} else {
|
||||
// Clean the path to remove redundant separators, ., .. etc.
|
||||
cleaned = filepath.Clean(path)
|
||||
// Convert to consistent path separators (use forward slashes for storage)
|
||||
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
|
||||
// Remove trailing slash unless it's root path
|
||||
if len(cleaned) > 1 && strings.HasSuffix(cleaned, "/") {
|
||||
cleaned = strings.TrimSuffix(cleaned, "/")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("normalizePath output: '%s'\n", cleaned)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
type EbookFolderResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
@@ -397,9 +425,12 @@ func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Normalize the folder path before storing
|
||||
normalizedPath := normalizePath(req.FolderPath)
|
||||
|
||||
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
FolderPath: req.FolderPath,
|
||||
FolderPath: normalizedPath,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -426,6 +457,12 @@ func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
fmt.Printf("GetEbookFolders: user_id='%s'\n", userID)
|
||||
for _, folder := range folders {
|
||||
fmt.Printf(" Folder in DB: id='%s', path='%s'\n",
|
||||
uuid.UUID(folder.ID.Bytes).String(), folder.FolderPath)
|
||||
}
|
||||
|
||||
var response []EbookFolderResponse
|
||||
for _, folder := range folders {
|
||||
response = append(response, EbookFolderResponse{
|
||||
@@ -455,14 +492,26 @@ func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
err = h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
|
||||
// Normalize the folder path before deletion
|
||||
normalizedPath := normalizePath(req.FolderPath)
|
||||
|
||||
// Debug logging - remove in production
|
||||
fmt.Printf("DeleteEbookFolder: original path='%s', normalized path='%s', user_id='%s'\n",
|
||||
req.FolderPath, normalizedPath, userID)
|
||||
|
||||
deletedFolder, err := h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
FolderPath: req.FolderPath,
|
||||
FolderPath: normalizedPath,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("DeleteEbookFolder failed: %v\n", err)
|
||||
if err == pgx.ErrNoRows {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "ebook folder not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
fmt.Printf("DeleteEbookFolder succeeded: deleted folder with path '%s'\n", deletedFolder.FolderPath)
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder deleted successfully"})
|
||||
}
|
||||
|
||||
|
||||
@@ -426,7 +426,7 @@ func (h *Handler) GetEbookRatings(c echo.Context) error {
|
||||
|
||||
// ScanEbooksRequest represents the request for scanning ebooks
|
||||
type ScanEbooksRequest struct {
|
||||
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
|
||||
FolderPaths []string `json:"folder_paths,omitempty"`
|
||||
}
|
||||
|
||||
// ScanEbooks handles POST /api/scanner/scan
|
||||
@@ -435,12 +435,40 @@ func (h *Handler) ScanEbooks(c echo.Context) error {
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
|
||||
// Get user ID from JWT token
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
var folderPaths []string
|
||||
|
||||
// If folder paths provided in request, use them
|
||||
// Otherwise, use user's saved folders
|
||||
if len(req.FolderPaths) > 0 {
|
||||
folderPaths = req.FolderPaths
|
||||
} else {
|
||||
// Get user's configured ebook folders
|
||||
folders, err := h.db.GetUserEbookFolders(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get user folders: " + err.Error()})
|
||||
}
|
||||
|
||||
if len(folders) == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no folders configured for user"})
|
||||
}
|
||||
|
||||
// Convert to folder paths
|
||||
folderPaths = make([]string, len(folders))
|
||||
for i, folder := range folders {
|
||||
folderPaths[i] = folder.FolderPath
|
||||
}
|
||||
}
|
||||
|
||||
// Set the folder paths for scanning
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
if err := h.scanner.SetFolders(folderPaths); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||
}
|
||||
|
||||
@@ -461,9 +489,6 @@ func (h *Handler) StartScanner(c echo.Context) error {
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Set the folder paths
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
|
||||
@@ -7,11 +7,14 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,8 @@ type EbookMetadata struct {
|
||||
PublishDate time.Time
|
||||
Contributors string
|
||||
CoverPath string
|
||||
ISBN string
|
||||
Tags string
|
||||
}
|
||||
|
||||
type EbookScanner struct {
|
||||
@@ -75,12 +80,28 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
|
||||
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 {
|
||||
@@ -91,8 +112,12 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +128,7 @@ func (s *EbookScanner) ScanFolders(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Scan completed: %d total files scanned, %d ebook files found\n", totalFiles, ebookFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -116,38 +142,140 @@ func (s *EbookScanner) isEbookFile(path string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.Error() != "sql: no rows in result set" {
|
||||
} 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)
|
||||
}
|
||||
// Ebook doesn't exist, continue with creation
|
||||
fmt.Printf("Ebook does not exist in database, creating new entry: %s\n", path)
|
||||
|
||||
// Extract metadata
|
||||
// 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)
|
||||
// Continue with basic metadata
|
||||
metadata = &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
Author: "Unknown",
|
||||
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,
|
||||
@@ -162,6 +290,7 @@ func (s *EbookScanner) processEbookFile(ctx context.Context, path string) error
|
||||
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
|
||||
@@ -173,10 +302,12 @@ func (s *EbookScanner) extractMetadata(path string) (*EbookMetadata, error) {
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return s.extractEPUBMetadata(path)
|
||||
case ".pdf":
|
||||
return s.extractPDFMetadata(path)
|
||||
default:
|
||||
// For other formats, return basic metadata
|
||||
return &EbookMetadata{
|
||||
Title: filepath.Base(path),
|
||||
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -209,10 +340,25 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,9 +367,38 @@ func (s *EbookScanner) extractEPUBMetadata(path string) (*EbookMetadata, error)
|
||||
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 {
|
||||
@@ -237,12 +412,12 @@ func (s *EbookScanner) updateEbook(ctx context.Context, ebookID pgtype.UUID, fil
|
||||
ID: ebookID,
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Isbn: pgtype.Text{}, // Keep existing ISBN
|
||||
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{}, // Keep existing tags
|
||||
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 != ""},
|
||||
@@ -284,14 +459,28 @@ func (s *EbookScanner) WatchChanges(ctx context.Context) {
|
||||
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)
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user