feat(conversion): add EPUB to KEPUB conversion service with kepubify

- Install kepubify binary in Dockerfile for on-the-fly conversion
- Add conversion service with caching layer (24hr TTL)
- Support KEPUB downloads through OPDS endpoint
- Cache converted files to reduce processing overhead
- Add environment configuration for cache directory and tool path
This commit is contained in:
2026-02-01 12:15:28 -05:00
parent a0523b2eb9
commit ec5a961f6c
4 changed files with 218 additions and 1 deletions
+135
View File
@@ -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
}
@@ -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")
}