104 lines
3.8 KiB
Go
104 lines
3.8 KiB
Go
package api
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"log/slog"
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Silo-Server/silo-server/internal/api/handlers"
|
|||
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|||
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|||
|
|
"github.com/Silo-Server/silo-server/internal/ebookconvert"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ebookKindleConversionSettingKey is the admin flag that gates Kindle->EPUB
|
|||
|
|
// conversion. Off by default; toggled via PUT /admin/settings/{key}.
|
|||
|
|
const ebookKindleConversionSettingKey = "ebook.kindle_conversion_enabled"
|
|||
|
|
|
|||
|
|
// buildEbookConversion wires the in-process Kindle->EPUB converter for the read
|
|||
|
|
// handler. It compiles the embedded WASM module once at startup; if that fails
|
|||
|
|
// the feature stays off (returns nil) and the raw original is served as before.
|
|||
|
|
// The admin flag is read with a short TTL (see ebookFlagPredicate), so it can be
|
|||
|
|
// toggled without a restart yet does not hit the DB on every read.
|
|||
|
|
//
|
|||
|
|
// The Converter (a wazero runtime, ~no external resources — per-conversion
|
|||
|
|
// scratch dirs are cleaned as they go) is intentionally owned for the process
|
|||
|
|
// lifetime: it is not Close()d on shutdown because process exit reclaims it and
|
|||
|
|
// there is nothing to flush. Memory is bounded per conversion by
|
|||
|
|
// ebookconvert.DefaultMaxMemoryPages × DefaultConcurrency (see the design doc's
|
|||
|
|
// resource notes); tune via ebookconvert.Options if the deployment is tight.
|
|||
|
|
func buildEbookConversion(deps Dependencies, settings catalog.SettingsStore) *handlers.EbookConversion {
|
|||
|
|
if settings == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
converter, err := ebookconvert.NewConverter(context.Background(), ebookconvert.Options{})
|
|||
|
|
if err != nil {
|
|||
|
|
slog.Warn("ebook Kindle->EPUB conversion unavailable: converter init failed", "error", err)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
cache, err := ebookconvert.NewCache(converter, ebookconvert.CacheOptions{Dir: ebookConversionCacheDir(deps.CurrentConfig())})
|
|||
|
|
if err != nil {
|
|||
|
|
slog.Warn("ebook Kindle->EPUB conversion unavailable: cache init failed", "error", err)
|
|||
|
|
_ = converter.Close(context.Background())
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
slog.Info("ebook Kindle->EPUB conversion ready (admin-flag gated)", "setting", ebookKindleConversionSettingKey)
|
|||
|
|
return &handlers.EbookConversion{
|
|||
|
|
Converter: cache,
|
|||
|
|
Enabled: ebookFlagPredicate(settings, ebookKindleConversionSettingKey),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ebookFlagPredicate returns a cached boolean reader for a server setting. The
|
|||
|
|
// read path (every ebook read + the capability endpoint, which clients may
|
|||
|
|
// poll) would otherwise issue a DB query per request; a short TTL collapses
|
|||
|
|
// bursts to one query while keeping admin toggles effectively instant. On a read
|
|||
|
|
// error it keeps the last known value rather than flapping the feature off.
|
|||
|
|
func ebookFlagPredicate(settings catalog.SettingsStore, key string) func(context.Context) bool {
|
|||
|
|
const ttl = 3 * time.Second
|
|||
|
|
var (
|
|||
|
|
mu sync.Mutex
|
|||
|
|
value bool
|
|||
|
|
fetched time.Time
|
|||
|
|
hasVal bool
|
|||
|
|
)
|
|||
|
|
return func(ctx context.Context) bool {
|
|||
|
|
mu.Lock()
|
|||
|
|
defer mu.Unlock()
|
|||
|
|
if hasVal && time.Since(fetched) < ttl {
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
v, err := settings.Get(ctx, key)
|
|||
|
|
if err != nil {
|
|||
|
|
return value // keep last known (false until the first successful read)
|
|||
|
|
}
|
|||
|
|
value = isTruthySetting(v)
|
|||
|
|
fetched = time.Now()
|
|||
|
|
hasVal = true
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ebookConversionCacheDir derives the converted-EPUB cache directory, a sibling
|
|||
|
|
// of the transcode dir so it lives alongside other derived media.
|
|||
|
|
func ebookConversionCacheDir(cfg *config.Config) string {
|
|||
|
|
base := os.TempDir()
|
|||
|
|
if cfg != nil && strings.TrimSpace(cfg.Playback.TranscodeDir) != "" {
|
|||
|
|
base = filepath.Dir(cfg.Playback.TranscodeDir)
|
|||
|
|
}
|
|||
|
|
return filepath.Join(base, "silo-ebook-epub")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isTruthySetting(v string) bool {
|
|||
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|||
|
|
case "true", "1", "yes", "on", "enabled":
|
|||
|
|
return true
|
|||
|
|
default:
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|