diff --git a/.env.example b/.env.example index b2e25f0..c74f0bf 100644 --- a/.env.example +++ b/.env.example @@ -17,4 +17,12 @@ TEST_MODE=false RATE_LIMIT_ENABLED=true # Number of requests allowed per minute per IP (default: 10) # Increase for integration testing (e.g., 1000) -REQUESTS_PER_MINUTE=10 \ No newline at end of file +REQUESTS_PER_MINUTE=10 + +# Conversion Service Configuration +# Directory for caching converted files (KEPUB, etc.) +BOOKMANN_CONVERSION_CACHE_DIR=/var/bookmann/cache/kepub +# Conversion tool to use (kepubify or ebook-convert) +BOOKMANN_CONVERSION_TOOL=/usr/bin/kepubify +# Cache TTL for converted files (e.g., 24h, 12h, 48h) +BOOKMANN_CONVERSION_CACHE_TTL=24h \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0296618..163d8dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,11 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server FROM alpine:latest RUN apk --no-cache add ca-certificates curl + +# Install kepubify for EPUB→KEPUB conversion +RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \ + && chmod +x /usr/bin/kepubify + WORKDIR /root/ # Copy the binary from builder stage diff --git a/internal/services/conversion_service.go b/internal/services/conversion_service.go new file mode 100644 index 0000000..001abff --- /dev/null +++ b/internal/services/conversion_service.go @@ -0,0 +1,135 @@ +package services + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "bookmann/internal/database" + "github.com/jackc/pgx/v5/pgtype" +) + +type ConvertedKEPUB struct { + Path string + SHA256 string + Cached bool +} + +type ConversionService struct { + db *database.Queries + cacheDir string + conversionTool string + conversionCacheTTL time.Duration +} + +func NewConversionService(db *database.Queries, cacheDir string) *ConversionService { + return &ConversionService{ + db: db, + cacheDir: cacheDir, + conversionTool: "/usr/bin/kepubify", + conversionCacheTTL: 24 * time.Hour, + } +} + +func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) { + existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + }) + if err == nil && existing.FilePath.Valid { + if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL { + return &ConvertedKEPUB{ + Path: existing.FilePath.String, + SHA256: existing.FileSha256.String, + Cached: true, + }, nil + } + } + + kepubPath := filepath.Join(s.cacheDir, fmt.Sprintf("%s.kepub.epub", mediaItemID.Bytes[:])) + + if err := os.MkdirAll(s.cacheDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create cache directory: %w", err) + } + + if err := s.convertEPUB(epubPath, kepubPath); err != nil { + return nil, fmt.Errorf("conversion failed: %w", err) + } + + kepubSHA256, err := s.calculateSHA256(kepubPath) + if err != nil { + return nil, fmt.Errorf("hash calculation failed: %w", err) + } + + epubFormat, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{ + MediaItemID: mediaItemID, + FormatType: "epub", + }) + if err != nil { + return nil, fmt.Errorf("EPUB format not found: %w", err) + } + + fileinfo, err := os.Stat(kepubPath) + if err != nil { + return nil, fmt.Errorf("failed to stat converted file: %w", err) + } + + var fileSize pgtype.Int8 + fileSize.Scan(int64(fileinfo.Size())) + + _, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{ + MediaItemID: mediaItemID, + FormatType: "kepub", + FilePath: pgtype.Text{String: kepubPath, Valid: true}, + FileSha256: pgtype.Text{String: kepubSHA256, Valid: true}, + FileSizeBytes: fileSize, + MimeType: pgtype.Text{String: "application/vnd.kobo+xml+zip", Valid: true}, + ConvertedFromFormatID: pgtype.UUID{Bytes: epubFormat.ID.Bytes, Valid: true}, + }) + if err != nil { + return nil, fmt.Errorf("failed to store converted format: %w", err) + } + + return &ConvertedKEPUB{ + Path: kepubPath, + SHA256: kepubSHA256, + Cached: false, + }, nil +} + +func (s *ConversionService) convertEPUB(epubPath, kepubPath string) error { + if _, err := os.Stat(s.conversionTool); err == nil { + cmd := exec.Command(s.conversionTool, "-i", epubPath, "-o", kepubPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("kepubify failed: %w, output: %s", err, string(output)) + } + return nil + } + + cmd := exec.Command("ebook-convert", epubPath, kepubPath, "--output-format", "kepub") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ebook-convert failed: %w, output: %s", err, string(output)) + } + return nil +} + +func (s *ConversionService) calculateSHA256(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/internal/services/conversion_service_test.go b/internal/services/conversion_service_test.go new file mode 100644 index 0000000..f974c6d --- /dev/null +++ b/internal/services/conversion_service_test.go @@ -0,0 +1,69 @@ +package services + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCalculateSHA256(t *testing.T) { + service := &ConversionService{} + + dir := t.TempDir() + testFile := filepath.Join(dir, "test.epub") + + content := "test content for hash calculation" + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + hash, err := service.calculateSHA256(testFile) + require.NoError(t, err) + + expectedHash := sha256.New() + expectedHash.Write([]byte(content)) + expectedHashStr := hex.EncodeToString(expectedHash.Sum(nil)) + + assert.Equal(t, expectedHashStr, hash) + assert.Len(t, hash, 64) +} + +func TestConvertEPUBToKEPUBCreatesCacheDir(t *testing.T) { + cacheDir := filepath.Join(t.TempDir(), "cache", "kepub") + _ = NewConversionService(nil, cacheDir) + + assert.NoFileExists(t, cacheDir, "Cache directory should not exist initially") + + err := os.MkdirAll(cacheDir, 0755) + require.NoError(t, err, "Should be able to create cache directory") + + assert.DirExists(t, cacheDir, "Cache directory should exist after creation") +} + +func TestConvertedKEPUBStructure(t *testing.T) { + validHash := "abc123def456789abc123def456789abc123def456789abc123def456789abcd" + result := &ConvertedKEPUB{ + Path: "/path/to/book.kepub.epub", + SHA256: validHash, + Cached: false, + } + + assert.NotEmpty(t, result.Path) + assert.NotEmpty(t, result.SHA256) + assert.False(t, result.Cached) + assert.Len(t, result.SHA256, 64) +} + +func TestConversionServiceDefaults(t *testing.T) { + cacheDir := t.TempDir() + service := NewConversionService(nil, cacheDir) + + assert.NotNil(t, service) + assert.Equal(t, cacheDir, service.cacheDir) + assert.Equal(t, "/usr/bin/kepubify", service.conversionTool) + assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours") +}