Files
silo-server/internal/api/handlers/ebook_convert_serve.go
b7292a9473 fix(streaming): stop killing healthy streams at the server WriteTimeout (#361)
The main API server's WriteTimeout (120s) is an absolute deadline from
request start, so every streaming response still being written at T+120s
was cut mid-body with a clean close. Clients saw multi-GB direct streams
truncate every two minutes; the Apple client's cursor-resume reconnect
absorbed most kills silently, but one landing during backpressure or a
demuxer resync exhausted its retry budget and forced a full player
teardown (visible stop + historical audio desync seeding).

Fix: internal/httpstream.RollingDeadlineWriter pushes the connection's
write deadline forward with progress via http.ResponseController — a
response that keeps moving lives indefinitely, a stalled one is still
reaped within the window (180s default, SILO_STREAM_WRITE_STALL_TIMEOUT
to override). ReadFrom delegates in bounded slices so http.ServeContent
keeps its sendfile fast path. Wired into direct play, remux, downloads,
the transcode-node proxy, and ebook serving; the server-level 120s guard
stays for every other route.

The metrics and request-logger response writers now implement Unwrap —
without it http.ResponseController cannot traverse to the connection and
SetWriteDeadline fails, silently disabling the fix (exactly what the
first dev deploy showed). A middleware-chain integration test locks the
whole path down against future wrappers missing Unwrap.

Validated on dev: 200s/512MB direct and 300s/768MB via CDN sustained
range-GETs (previously dying at 120s), zero duration_ms=120000 stream
entries since deploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:07:16 -04:00

186 lines
8.1 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/ebookconvert"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
)
// ConversionHeader tells the client how a kindle-family read was served.
// "converted" => body is EPUB; "failed" => body is the raw original and the
// client should fall back to opening it externally. Absent => no conversion
// was attempted (feature off or non-kindle format), serve/behave as before.
const ConversionHeader = "X-Silo-Ebook-Conversion"
// EbookConverter produces (and caches) a converted EPUB for a source file.
// Implemented by *ebookconvert.Cache; an interface here for testability.
type EbookConverter interface {
GetOrConvert(ctx context.Context, srcPath string, key ebookconvert.SourceKey) (string, error)
// Lookup reports a cached conversion WITHOUT converting (used by HEAD so a
// metadata probe never kicks off a full conversion): a path on hit, a
// non-nil error for a negatively-cached source, else a miss.
Lookup(key ebookconvert.SourceKey) (string, error, bool)
}
// EbookConversion bundles the converter with the admin-flag predicate. A nil
// *EbookConversion (or nil Converter) means the feature is off.
type EbookConversion struct {
Converter EbookConverter
// Enabled reports whether the admin flag is on for this request.
Enabled func(ctx context.Context) bool
}
func (c *EbookConversion) active(ctx context.Context) bool {
return c != nil && c.Converter != nil && c.Enabled != nil && c.Enabled(ctx)
}
// kindleReaderFormats are the Kindle-family formats the converter handles.
var kindleReaderFormats = map[string]bool{"mobi": true, "azw": true, "azw3": true}
func isKindleReaderFile(file *models.MediaFile) bool {
return file != nil && kindleReaderFormats[ebookReaderFormat(file.FilePath, file.Container)]
}
// serveEbook serves an ebook file, converting Kindle-family formats to EPUB
// when the feature is enabled. On conversion failure it serves the raw original
// with the ConversionHeader=failed contract so the client opens it externally.
func (h *EbookReaderHandler) serveEbook(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
if h.Conversion.active(r.Context()) && isKindleReaderFile(file) {
// Strong identity for both the conversion cache and the response ETag:
// id + size + mtime (stat) + content hash + module version.
key, statErr := ebookconvert.SourceKeyFromStat(file.ID, file.FilePath)
if statErr != nil {
return h.serveRawKindleFallback(w, r, file)
}
key.Checksum = file.FileHash
// HEAD must not trigger a (possibly minute-long, 1 GiB) conversion. Answer
// from cache only; on a miss, advertise the converted representation
// cheaply and let the GET produce the body + authoritative verdict.
if r.Method == http.MethodHead {
return h.serveKindleHead(w, r, file, key)
}
epubPath, err := h.Conversion.Converter.GetOrConvert(r.Context(), file.FilePath, key)
switch {
case err == nil:
if serveErr := serveConvertedEpub(w, r, file, epubPath, key); serveErr != nil {
// Produced but unservable (open/stat) — honor the contract, not a 500.
return h.serveRawKindleFallback(w, r, file)
}
return nil
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
// Caller went away / request cancelled — not a conversion verdict.
return err
default:
// DRM, corrupt, oversize, timed out, unavailable: fall back to the raw
// original and tell the client so it can open externally.
return h.serveRawKindleFallback(w, r, file)
}
}
// Feature off or non-kindle format: serve the raw original with its native
// MIME and no conversion header. This path deliberately omits the failed
// contract — when conversion is disabled the capability endpoint already
// reports `enabled:false`, so clients keep their external-open path and never
// expect an EPUB here. (No representation flip to guard against.)
return serveEbookInline(w, r, file)
}
// serveRawKindleFallback serves the raw original with the failed-conversion
// contract. no-store because the same URL flips raw/converted with the flag.
func (h *EbookReaderHandler) serveRawKindleFallback(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
w.Header().Set(ConversionHeader, "failed")
w.Header().Set("Cache-Control", "no-store")
return serveEbookInline(w, r, file)
}
// serveKindleHead answers a HEAD for a kindle-family file without converting:
// - cache hit → real converted headers (and ServeContent's Content-Length);
// - neg-cached → the failed contract (raw original headers);
// - miss → converted headers advertised optimistically (no body, no
// Content-Length, no conversion). A HEAD is only a hint; the GET delivers
// the body and the authoritative DRM/failure verdict, which then populates
// the (positive or negative) cache that subsequent HEADs read.
func (h *EbookReaderHandler) serveKindleHead(w http.ResponseWriter, r *http.Request, file *models.MediaFile, key ebookconvert.SourceKey) error {
path, lookupErr, ok := h.Conversion.Converter.Lookup(key)
switch {
case ok:
if serveErr := serveConvertedEpub(w, r, file, path, key); serveErr != nil {
return h.serveRawKindleFallback(w, r, file)
}
return nil
case lookupErr != nil:
return h.serveRawKindleFallback(w, r, file)
default:
setConvertedEpubHeaders(w, file, key)
w.WriteHeader(http.StatusOK)
return nil
}
}
// setConvertedEpubHeaders writes the headers for a converted-EPUB response.
// Because the same URL can return raw or converted bytes depending on the
// flag/outcome, the ETag is derived from the exact conversion cache key (so a
// validator can never disagree with the cache) with a revalidate policy that
// keeps clients/proxies from serving a stale representation.
func setConvertedEpubHeaders(w http.ResponseWriter, file *models.MediaFile, key ebookconvert.SourceKey) {
name := strings.TrimSuffix(filepath.Base(file.FilePath), filepath.Ext(file.FilePath)) + ".epub"
w.Header().Set("Content-Type", "application/epub+zip")
w.Header().Set("Content-Disposition", inlineContentDisposition(name))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set(ConversionHeader, "converted")
w.Header().Set("ETag", fmt.Sprintf("%q", "epubconv-"+key.CacheKey()))
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
}
// serveConvertedEpub serves the cached EPUB with EPUB headers (see
// setConvertedEpubHeaders). For a HEAD, ServeContent emits the headers and
// Content-Length without a body.
func serveConvertedEpub(w http.ResponseWriter, r *http.Request, file *models.MediaFile, epubPath string, key ebookconvert.SourceKey) error {
f, err := os.Open(epubPath)
if err != nil {
return fmt.Errorf("opening converted epub: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return fmt.Errorf("stat converted epub: %w", err)
}
name := strings.TrimSuffix(filepath.Base(file.FilePath), filepath.Ext(file.FilePath)) + ".epub"
setConvertedEpubHeaders(w, file, key)
http.ServeContent(httpstream.NewRollingDeadlineWriter(w), r, name, stat.ModTime(), f)
return nil
}
// ebookConversionCapability is the client-facing advertisement for the
// Kindle->EPUB feature. Clients flip mobi/azw/azw3 to in-app reading only when
// Enabled is true; otherwise they keep the external-open path.
type ebookConversionCapability struct {
Enabled bool `json:"enabled"`
SourceFormats []string `json:"source_formats"`
ServedFormat string `json:"served_format"`
Header string `json:"header"`
HeaderOnFailed string `json:"header_failed_value"`
}
// HandleConversionCapability reports whether Kindle->EPUB conversion is active
// (admin flag on AND the converter initialized). GET /api/v1/ebooks/capability.
func (h *EbookReaderHandler) HandleConversionCapability(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, ebookConversionCapability{
Enabled: h.Conversion.active(r.Context()),
SourceFormats: []string{"mobi", "azw", "azw3"},
ServedFormat: "epub",
Header: ConversionHeader,
HeaderOnFailed: "failed",
})
}