* feat(playback): add IsPGS helper and sup streaming extract path PGS (Blu-ray bitmap) subtitle tracks can be copied losslessly into a .sup elementary stream for client-side rendering, so they no longer have to be burned in. streamExtractOutput maps PGS to (copy, sup), and the seek/-t windowing now skips PGS like ASS: both formats are fetched once and consumed whole by their client-side renderers. This also fixes a pre-existing truncation bug: the -t duration cap was applied unconditionally, cutting embedded ASS extracts off at the default 600s window even though the ASS client fetches the full track. Extract the ffmpeg argument construction into streamExtractArgs for testability, following the buildFFmpegArgs pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(api): expose PGS subtitle tracks as .sup stream URLs PGS tracks were filtered out of /playback/start subtitle_urls entirely, so the web player showed no subtitles for PGS-only files (#34). Include them with a .sup URL extension; DVD/DVB bitmap tracks stay hidden since they still have no non-burn-in delivery path. HandleSubtitle streams the full PGS track as application/octet-stream. The seek/duration window is forced to zero for sup: subtitleSeekPosition falls back to the session's last reported position even without a ?position= query, which would otherwise start the extract mid-file. The proxy-node subtitle handler gets the same sup branch, streaming ffmpeg output directly instead of buffering like its text paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(player): consolidate subtitle codec helpers into subtitleCodecs.ts Rename assSubtitles.ts to subtitleCodecs.ts — the module already labeled every codec, not just ASS — and add isPGSCodec/isBitmapCodec. Replace the duplicated BITMAP_CODECS set in SubtitleTranslateModal with the shared helper so codec lists live in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(player): native PGS subtitle rendering via libpgs Render PGS subtitle tracks client-side instead of leaving them unavailable (#34). usePGSSubtitles mirrors the JASSUB hook: when a PGS track is active it lazy-loads libpgs, which fetches the .sup stream in a worker, decodes display sets progressively as bytes arrive, and draws them onto a canvas positioned over the video. The renderer looks up the display set at currentTime + timeOffset, so the HLS stream origin adds and the user-facing delay subtracts — a positive delay shows subtitles later, matching VTT semantics. Offset changes apply through the timeOffset setter without recreating the renderer; track switches, PiP detach, and unmount dispose it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(player): prefer text over bitmap tracks in subtitle auto-select With PGS tracks now listed, an earlier PGS track would win auto-select over a later same-language SRT/ASS track. Deprioritize bitmap codecs within the same source tier — text is lighter to render and styleable — while a PGS track still wins when it is the only language match, and forced-PGS auto-select now works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
365 lines
12 KiB
Go
365 lines
12 KiB
Go
package proxy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/cors"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/nodeconfig"
|
|
"github.com/Silo-Server/silo-server/internal/nodesessions"
|
|
"github.com/Silo-Server/silo-server/internal/playback"
|
|
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
|
)
|
|
|
|
// Server is the HTTP handler for proxy mode.
|
|
type Server struct {
|
|
watcher *nodeconfig.Watcher
|
|
tracker *nodesessions.Tracker
|
|
httpClient *http.Client
|
|
egress *egressMeter
|
|
}
|
|
|
|
// NewServer creates a new proxy server backed by a config watcher and session tracker.
|
|
func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Server {
|
|
return &Server{
|
|
watcher: watcher,
|
|
tracker: tracker,
|
|
// No overall timeout — stream bodies are long-lived. Hung nodes are
|
|
// bounded by the transport's response-header timeout instead.
|
|
httpClient: &http.Client{Transport: newStreamTransport()},
|
|
egress: newEgressMeter(),
|
|
}
|
|
}
|
|
|
|
// newStreamTransport tunes the proxy→transcode-node connection pool. Many
|
|
// concurrent viewers fan their segment fetches through one proxy→node pair,
|
|
// and Go's default of 2 idle connections per host causes constant connection
|
|
// churn (and TLS re-handshakes) under load. The response-header timeout
|
|
// bounds requests to a hung node; the longest legitimate server-side wait is
|
|
// the 30s manifest-readiness poll on the transcode node.
|
|
func newStreamTransport() *http.Transport {
|
|
t := http.DefaultTransport.(*http.Transport).Clone()
|
|
t.MaxIdleConns = 128
|
|
t.MaxIdleConnsPerHost = 32
|
|
t.ResponseHeaderTimeout = 60 * time.Second
|
|
return t
|
|
}
|
|
|
|
// Handler returns the chi.Router with all proxy routes mounted.
|
|
func (s *Server) Handler() http.Handler {
|
|
r := chi.NewRouter()
|
|
// hls.js uses XHR for manifest/segment fetches which are subject to
|
|
// CORS when the proxy runs on a different origin than the web app.
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Range"},
|
|
MaxAge: 86400,
|
|
}))
|
|
r.Get("/api/v1/health", s.handleHealth)
|
|
r.Group(func(r chi.Router) {
|
|
// Everything under /stream counts toward the node's measured
|
|
// egress bandwidth.
|
|
r.Use(s.meterEgress)
|
|
r.Head("/stream/direct/{token}", s.handleDirectPlay)
|
|
r.Get("/stream/direct/{token}", s.handleDirectPlay)
|
|
r.Head("/stream/remux/{token}", s.handleRemux)
|
|
r.Get("/stream/remux/{token}", s.handleRemux)
|
|
r.Head("/stream/transcode/{token}/master.m3u8", s.handleTranscodeManifest)
|
|
r.Get("/stream/transcode/{token}/master.m3u8", s.handleTranscodeManifest)
|
|
r.Get("/stream/transcode/{token}/segment/{name}", s.handleTranscodeSegment)
|
|
r.Get("/stream/subtitles/{token}/{track}/fonts", s.handleSubtitleFonts)
|
|
r.Get("/stream/subtitles/{token}/{track}", s.handleSubtitle)
|
|
})
|
|
|
|
// Admin routes — bearer-auth protected.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireBearer)
|
|
r.Post("/admin/force-reload", s.handleForceReload)
|
|
r.Get("/status", s.handleStatus)
|
|
})
|
|
return r
|
|
}
|
|
|
|
type healthResponse struct {
|
|
Status string `json:"status"`
|
|
ActiveJobs int `json:"active_jobs"`
|
|
EgressKbps int `json:"egress_kbps"`
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
activeJobs := 0
|
|
if s.tracker != nil {
|
|
activeJobs = s.tracker.ActiveCount()
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(healthResponse{
|
|
Status: "ok",
|
|
ActiveJobs: activeJobs,
|
|
EgressKbps: s.egress.RateKbps(),
|
|
})
|
|
}
|
|
|
|
// requireBearer checks Authorization: Bearer {secret} for admin endpoints.
|
|
func (s *Server) requireBearer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cfg := s.watcher.Config()
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != cfg.Auth.JWTSecret {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// verifyToken extracts and validates the stream token from the URL.
|
|
func (s *Server) verifyToken(w http.ResponseWriter, r *http.Request) *streamtoken.Claims {
|
|
cfg := s.watcher.Config()
|
|
tokenStr := chi.URLParam(r, "token")
|
|
claims, err := streamtoken.Verify(tokenStr, cfg.Auth.JWTSecret)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return nil
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func (s *Server) handleDirectPlay(w http.ResponseWriter, r *http.Request) {
|
|
claims := s.verifyToken(w, r)
|
|
if claims == nil {
|
|
return
|
|
}
|
|
|
|
info := nodesessions.SessionInfo{
|
|
SessionID: claims.SessionID,
|
|
NodeURL: s.tracker.NodeURL(),
|
|
NodeName: s.tracker.NodeName(),
|
|
Type: "direct_play",
|
|
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
s.tracker.Track(r.Context(), info)
|
|
defer s.tracker.Remove(r.Context(), claims.SessionID)
|
|
|
|
http.ServeFile(w, r, claims.MediaPath)
|
|
}
|
|
|
|
func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) {
|
|
claims := s.verifyToken(w, r)
|
|
if claims == nil {
|
|
return
|
|
}
|
|
|
|
info := nodesessions.SessionInfo{
|
|
SessionID: claims.SessionID,
|
|
NodeURL: s.tracker.NodeURL(),
|
|
NodeName: s.tracker.NodeName(),
|
|
Type: "remux",
|
|
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
s.tracker.Track(r.Context(), info)
|
|
defer s.tracker.Remove(r.Context(), claims.SessionID)
|
|
|
|
seekSeconds := 0.0
|
|
if seekStr := r.URL.Query().Get("seek"); seekStr != "" {
|
|
if v, err := strconv.ParseFloat(seekStr, 64); err == nil {
|
|
seekSeconds = v
|
|
}
|
|
}
|
|
_ = playback.ServeRemux(w, r, claims.MediaPath, "mp4", seekSeconds, claims.TranscodeAudio, claims.AudioTrackIndex)
|
|
}
|
|
|
|
func (s *Server) handleTranscodeManifest(w http.ResponseWriter, r *http.Request) {
|
|
claims := s.verifyToken(w, r)
|
|
if claims == nil {
|
|
return
|
|
}
|
|
s.touchTranscodeSession(r, claims)
|
|
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+claims.SessionID+"/master.m3u8")
|
|
}
|
|
|
|
func (s *Server) handleTranscodeSegment(w http.ResponseWriter, r *http.Request) {
|
|
claims := s.verifyToken(w, r)
|
|
if claims == nil {
|
|
return
|
|
}
|
|
s.touchTranscodeSession(r, claims)
|
|
name := chi.URLParam(r, "name")
|
|
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+claims.SessionID+"/segment/"+name)
|
|
}
|
|
|
|
// touchTranscodeSession keeps HLS sessions visible in the active stream count.
|
|
// Unlike direct play and remux, transcode playback reaches the proxy as many
|
|
// short manifest/segment requests, so the session is tracked by recent
|
|
// activity instead of request lifetime.
|
|
func (s *Server) touchTranscodeSession(r *http.Request, claims *streamtoken.Claims) {
|
|
s.tracker.Touch(r.Context(), nodesessions.SessionInfo{
|
|
SessionID: claims.SessionID,
|
|
NodeURL: s.tracker.NodeURL(),
|
|
NodeName: s.tracker.NodeName(),
|
|
Type: "transcode",
|
|
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) {
|
|
claims := s.verifyToken(w, r)
|
|
if claims == nil {
|
|
return
|
|
}
|
|
cfg := s.watcher.Config()
|
|
trackParam := chi.URLParam(r, "track")
|
|
trackIndex, requestedFormat, err := playback.ParseSubtitleTrackParam(trackParam)
|
|
if err != nil {
|
|
http.Error(w, "invalid subtitle index", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// When the URL requests SUP format (e.g. /subtitles/{token}/2.sup),
|
|
// stream the PGS track as a raw .sup elementary stream for client-side
|
|
// bitmap rendering (libpgs). Unlike the buffered text paths below, this
|
|
// streams ffmpeg output directly: the track can be large and the client
|
|
// renders progressively as data arrives.
|
|
if requestedFormat == "sup" {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
err := playback.StreamExtractSubtitle(r.Context(), playback.StreamExtractOpts{
|
|
InputPath: claims.MediaPath,
|
|
TrackIndex: trackIndex,
|
|
SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks
|
|
FFmpegPath: cfg.Playback.FFmpegPath,
|
|
Writer: w,
|
|
})
|
|
if err != nil && r.Context().Err() == nil {
|
|
// Headers already committed — log and let the client see a
|
|
// truncated response.
|
|
slog.Error("stream subtitle (sup)", "error", err, "track", trackIndex,
|
|
"path", claims.MediaPath, "playback_session_id", claims.SessionID)
|
|
}
|
|
return
|
|
}
|
|
|
|
// When the URL requests ASS format (e.g. /subtitles/{token}/2.ass),
|
|
// extract as raw ASS to preserve styling for client-side rendering.
|
|
if requestedFormat == "ass" {
|
|
data, err := playback.ExtractSubtitleWithFormat(r.Context(), claims.MediaPath, trackIndex, "ass", cfg.Playback.FFmpegPath)
|
|
if err != nil {
|
|
slog.Error("extract subtitle (ass)", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID)
|
|
http.Error(w, "subtitle extraction failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
playback.ServeSubtitle(w, data, "ass")
|
|
return
|
|
}
|
|
|
|
data, format, err := playback.ExtractSubtitle(r.Context(), claims.MediaPath, trackIndex, cfg.Playback.FFmpegPath)
|
|
if err != nil {
|
|
slog.Error("extract subtitle", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID)
|
|
http.Error(w, "subtitle extraction failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
vtt, err := playback.ConvertToVTT(data, format)
|
|
if err != nil {
|
|
slog.Error("convert to vtt", "error", err, "playback_session_id", claims.SessionID)
|
|
http.Error(w, "subtitle conversion failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
playback.ServeSubtitle(w, vtt, "vtt")
|
|
}
|
|
|
|
func (s *Server) handleSubtitleFonts(w http.ResponseWriter, r *http.Request) {
|
|
claims := s.verifyToken(w, r)
|
|
if claims == nil {
|
|
return
|
|
}
|
|
cfg := s.watcher.Config()
|
|
trackParam := chi.URLParam(r, "track")
|
|
trackIndex, _, err := playback.ParseSubtitleTrackParam(trackParam)
|
|
if err != nil {
|
|
http.Error(w, "invalid subtitle index", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), claims.MediaPath, cfg.Playback.FFmpegPath)
|
|
if err != nil {
|
|
slog.Error("extract subtitle fonts", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID)
|
|
http.Error(w, "subtitle font extraction failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if err := json.NewEncoder(w).Encode(playback.EncodeSubtitleFontBundle(fonts)); err != nil {
|
|
slog.Warn("subtitle font response encode failed", "error", err, "playback_session_id", claims.SessionID)
|
|
}
|
|
}
|
|
|
|
// proxyToTranscodeNode forwards the request to the transcode node specified in the claims.
|
|
func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, claims *streamtoken.Claims, path string) {
|
|
cfg := s.watcher.Config()
|
|
if claims.TranscodeNode == "" {
|
|
http.Error(w, "no transcode node in token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
targetURL := claims.TranscodeNode + path
|
|
if rawQuery := r.URL.RawQuery; rawQuery != "" {
|
|
targetURL = fmt.Sprintf("%s?%s", targetURL, rawQuery)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, nil)
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret)
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
slog.Error("proxy to transcode node", "error", err, "url", targetURL, "playback_session_id", claims.SessionID)
|
|
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Copy response headers
|
|
for k, vv := range resp.Header {
|
|
for _, v := range vv {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
|
|
func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.watcher.ForceReload(r.Context()); err != nil {
|
|
http.Error(w, "reload failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
slog.Info("proxy force reload completed")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type statusResponse struct {
|
|
ActiveSessions int `json:"active_sessions"`
|
|
}
|
|
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(statusResponse{
|
|
ActiveSessions: s.tracker.ActiveCount(),
|
|
})
|
|
}
|