Files
silo-server/internal/chapterthumbs/remote.go
d5a8cd5294 fix(chapterthumbs): add software HDR tone-map fallback (#576)
* fix(chapterthumbs): add software HDR tone-map fallback

* fix(chapterthumbs): tighten filter capability detection

* fix(chapterthumbs): harden tone-map fallback retries

* fix(chapterthumbs): coalesce filter probes

* fix(chapterthumbs): name disabled accelerator

* feat(chapterthumbs): add CPU tone-map toggle

* fix(config): reuse CPU tone-map setting key

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-11 12:13:06 -04:00

135 lines
4.3 KiB
Go

package chapterthumbs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/nodepool"
)
const (
chapterThumbnailExecutionSetting = "playback.chapter_thumbnail_execution"
chapterThumbnailExecutionLocal = "local"
chapterThumbnailExecutionPreferTranscode = "prefer_transcode_nodes"
chapterThumbnailExecutionTranscodeOnly = "transcode_nodes_only"
chapterThumbnailNodeCapacitySetting = "playback.chapter_thumbnail_node_capacity"
chapterThumbnailNodeUnavailableReason = "transcode_node_unavailable"
chapterThumbnailNodeCapacityExhaustedReason = "transcode_node_capacity_exhausted"
authJWTSecretSetting = "auth.jwt_secret"
)
// remoteExtractOverheadBuffer covers HTTP transport and node-side scheduling
// overhead on top of the extraction attempt deadlines.
const remoteExtractOverheadBuffer = 3 * time.Second
type RemoteExtractRequest struct {
InputPath string `json:"input_path"`
SeekSeconds float64 `json:"seek_seconds"`
ToneMap bool `json:"tone_map"`
AllowSoftwareToneMap bool `json:"allow_software_tone_map"`
}
type RemoteExtractErrorResponse struct {
Reason string `json:"reason"`
Error string `json:"error,omitempty"`
}
type remoteFrameExtractor interface {
ExtractFrame(ctx context.Context, node *nodepool.Node, jwtSecret string, req RemoteExtractRequest) ([]byte, string, error)
}
type httpRemoteFrameExtractor struct {
client *http.Client
}
func (e *httpRemoteFrameExtractor) ExtractFrame(
ctx context.Context,
node *nodepool.Node,
jwtSecret string,
req RemoteExtractRequest,
) ([]byte, string, error) {
if node == nil {
return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: missing node")
}
if strings.TrimSpace(jwtSecret) == "" {
return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: missing jwt secret")
}
body, err := json.Marshal(req)
if err != nil {
return nil, reasonChapterExtractFailed, fmt.Errorf("chapter thumbnail remote extract: marshal request: %w", err)
}
timeout := remoteExtractTimeout(req.ToneMap, req.AllowSoftwareToneMap)
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
httpReq, err := http.NewRequestWithContext(requestCtx, http.MethodPost, node.URL+"/chapter-thumbnails/extract", bytes.NewReader(body))
if err != nil {
return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+jwtSecret)
client := e.client
if client == nil {
client = &http.Client{Timeout: timeout}
}
resp, err := client.Do(httpReq)
if err != nil {
return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
reason := chapterThumbnailNodeUnavailableReason
message := fmt.Sprintf("node returned status %d", resp.StatusCode)
var payload RemoteExtractErrorResponse
if decodeErr := json.NewDecoder(resp.Body).Decode(&payload); decodeErr == nil {
if payload.Reason != "" {
reason = payload.Reason
}
if payload.Error != "" {
message = payload.Error
}
}
return nil, reason, fmt.Errorf("chapter thumbnail remote extract: %s", message)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: read response: %w", err)
}
if len(data) == 0 {
return nil, reasonChapterExtractFailed, fmt.Errorf("chapter thumbnail remote extract: empty response")
}
return data, "", nil
}
func remoteExtractTimeout(toneMap bool, allowSoftwareToneMap bool) time.Duration {
timeout := extractTimeoutForAttempt(true, toneMap) + remoteExtractOverheadBuffer
if !toneMap || allowSoftwareToneMap {
timeout += extractTimeoutForAttempt(false, toneMap)
}
if toneMap && allowSoftwareToneMap {
timeout += softwareToneMapProbeTimeout
}
return timeout
}
func isInfrastructureRemoteFailure(reason string) bool {
switch reason {
case chapterThumbnailNodeUnavailableReason, chapterThumbnailNodeCapacityExhaustedReason, reasonFFmpegProbeFailed:
return true
default:
return false
}
}