Files
bookhoard/internal/services/conversion_service.go
T
john-okeefe d12911d3c8 feat(api): make device rate limits, OPDS page size, and conversion cache configurable
Move three more hardcoded values behind the settings registry. All
apply immediately on the next request (no restart needed).

device_auth.go:
- DeviceAuthMiddleware reads per-route device rate limits (sync /
  progress / metadata per minute) from the registry on each
  authenticated request via a rateLimitConfig() helper, falling back to
  the Default* constants when no registry is wired.
- The X-RateLimit-Limit response header previously hardcoded "60" for
  every request type; it now reflects the actual configured limit for
  the request type via rateLimitForRequestType().

opds.go:
- Default (50) and maximum (200) OPDS page sizes come from the
  registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline
  literals, so catalog pagination can be tuned without a redeploy.

conversion_service.go:
- The 24h kepub cache lifetime is read from the registry via a
  cacheTTL() helper (was a bare 24 * time.Hour literal in the
  constructor). The field default is retained for tests that construct
  the service directly.
- conversion_service_test.go updated to assert both the field default
  and the cacheTTL() accessor return 24h.
2026-08-10 08:01:28 -04:00

160 lines
4.4 KiB
Go

package services
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// defaultConversionCacheTTL is the fallback kepub cache lifetime when no
// settings registry is wired. Matches the historical hardcoded 24h.
const defaultConversionCacheTTL = 24 * time.Hour
type ConvertedKEPUB struct {
Path string
SHA256 string
Cached bool
}
type ConversionService struct {
db *database.Queries
cacheDir string
conversionTool string
conversionCacheTTL time.Duration
settings *database.SettingsRegistry
}
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
return &ConversionService{
db: db,
cacheDir: cacheDir,
conversionTool: "/usr/bin/kepubify",
conversionCacheTTL: defaultConversionCacheTTL,
}
}
// SetSettings wires the tunable settings registry. When wired, the cache TTL
// is read live on each conversion request.
func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
// cacheTTL returns the active conversion cache TTL.
func (s *ConversionService) cacheTTL() time.Duration {
if s.settings != nil {
return s.settings.ConversionCacheTTL()
}
if s.conversionCacheTTL > 0 {
return s.conversionCacheTTL
}
return defaultConversionCacheTTL
}
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.cacheTTL() {
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
err = fileSize.Scan(fileinfo.Size())
if err != nil {
return nil, err
}
_, 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
}