Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e485b3ab9d | ||
|
|
e36612e619 | ||
|
|
75936cdae2 | ||
|
|
1dcdd4b27a | ||
|
|
2dc7d5e36c | ||
|
|
461b51c02c |
@@ -451,17 +451,19 @@ position, `can_seek_anywhere` is true when the runtime is known, and
|
||||
`seek_restoration` is `player_position` — the client seeks locally.
|
||||
|
||||
**Copy remux over HLS** is served from FFmpeg's live, still-growing playlist,
|
||||
which starts at the requested position. So `player_start` is `0`,
|
||||
`stream_origin` and `timeline_offset` both equal the seek position,
|
||||
`seek_window_start_seconds` is that position, and **`seek_window_end_seconds` is
|
||||
deliberately absent**. An open end marks the window as incomplete; combined with
|
||||
`can_seek_anywhere: false` it routes every seek back through the server as a
|
||||
reanchor (§6), which is correct because the playlist has no bytes for positions
|
||||
FFmpeg has not reached yet. `seek_restoration` is `source_position`.
|
||||
which starts at the preceding keyframe selected by FFmpeg's input seek.
|
||||
`stream_origin` and `timeline_offset` both equal that resolved source position,
|
||||
while `player_start` is the requested position minus the resolved origin so the
|
||||
client advances past copied pre-roll. `seek_window_start_seconds` is the resolved
|
||||
origin, and **`seek_window_end_seconds` is deliberately absent**. An open end
|
||||
marks the window as incomplete; combined with `can_seek_anywhere: false` it
|
||||
routes every seek back through the server as a reanchor (§6), which is correct
|
||||
because the playlist has no bytes for positions FFmpeg has not reached yet.
|
||||
`seek_restoration` is `source_position`.
|
||||
|
||||
**Progressive remux** is a freshly generated chunked response with no byte-range
|
||||
support, so it behaves the same way: player-zero is stream-zero is the seek
|
||||
position, the window is open-ended, and seeks go through the server.
|
||||
support, so it uses the same resolved keyframe origin and player pre-roll offset;
|
||||
the window is open-ended and seeks go through the server.
|
||||
|
||||
### `source.duration_seconds`
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func requestAccessFilter(r *http.Request) catalog.AccessFilter {
|
||||
deviceID := deviceMetadataFromRequest(r).DeviceID
|
||||
if scope, ok := access.GetScope(r.Context()); ok {
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
@@ -17,10 +18,12 @@ func requestAccessFilter(r *http.Request) catalog.AccessFilter {
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
}
|
||||
return catalog.AccessFilter{
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogAccessFiltersCarryExplicitDeviceIdentity(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/v1/items/movie-1", nil)
|
||||
req.Header.Set(deviceIDHeader, " apple-tv ")
|
||||
|
||||
if got := requestAccessFilter(req).DeviceID; got != "apple-tv" {
|
||||
t.Fatalf("requestAccessFilter().DeviceID = %q, want apple-tv", got)
|
||||
}
|
||||
if got := (&ItemsHandler{}).accessFilter(req).DeviceID; got != "apple-tv" {
|
||||
t.Fatalf("ItemsHandler.accessFilter().DeviceID = %q, want apple-tv", got)
|
||||
}
|
||||
}
|
||||
@@ -2115,6 +2115,7 @@ func filterSortClause(sort, order string) string {
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
deviceID := deviceMetadataFromRequest(r).DeviceID
|
||||
selectedFileID := 0
|
||||
if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" {
|
||||
if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 {
|
||||
@@ -2141,6 +2142,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2168,6 +2170,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
"github.com/Silo-Server/silo-server/internal/watchstate"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
@@ -134,6 +133,14 @@ type PlaybackOriginalLanguageLookup interface {
|
||||
GetOriginalLanguage(ctx context.Context, contentID string) (string, error)
|
||||
}
|
||||
|
||||
type copySeekAnchorResolver func(
|
||||
ctx context.Context,
|
||||
ffmpegPath string,
|
||||
inputPath string,
|
||||
requestedSeekSeconds float64,
|
||||
segmentDuration int,
|
||||
) (float64, int, error)
|
||||
|
||||
// PlaybackHandler handles playback session HTTP endpoints.
|
||||
type PlaybackHandler struct {
|
||||
sessionMgr SessionManagerInterface
|
||||
@@ -176,6 +183,7 @@ type PlaybackHandler struct {
|
||||
// playbackConfig(), which falls back to defaults when unset.
|
||||
PlaybackConfig func() config.PlaybackConfig
|
||||
FFmpegLogSink playback.FFmpegLogSink
|
||||
copySeekAnchor copySeekAnchorResolver
|
||||
realtimeCommandMu sync.Mutex
|
||||
realtimeCommands map[string]playbackCommandRecord
|
||||
// tm owns the transcode-session lifecycle (live map, recipe cards, and
|
||||
@@ -230,11 +238,10 @@ func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathReso
|
||||
h.tm.Config = func() playback.TranscodeRuntimeConfig {
|
||||
c := h.playbackConfig()
|
||||
return playback.TranscodeRuntimeConfig{
|
||||
TranscodeDir: c.TranscodeDir,
|
||||
FFmpegPath: c.FFmpegPath,
|
||||
HWAccel: c.HWAccel,
|
||||
HWDevice: c.HWDevice,
|
||||
SegmentRetentionSeconds: c.SegmentRetentionSeconds,
|
||||
TranscodeDir: c.TranscodeDir,
|
||||
FFmpegPath: c.FFmpegPath,
|
||||
HWAccel: c.HWAccel,
|
||||
HWDevice: c.HWDevice,
|
||||
}
|
||||
}
|
||||
h.tm.StartThrottler = func(ctx context.Context, ts *playback.TranscodeSession) {
|
||||
@@ -617,32 +624,33 @@ func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *mod
|
||||
return lang
|
||||
}
|
||||
|
||||
// resolvedProfileAudioLanguage returns the effective playback.audio_language
|
||||
// for the profile with no content context, resolved through the settings
|
||||
// contract — the canonical replacement for reading the legacy
|
||||
// user_profiles.language column, matching catalog's detail resolution. It may
|
||||
// return playback.OriginalLanguageSentinel, which the caller resolves to a
|
||||
// concrete language. Returns "" when nothing is stored: the contract default
|
||||
// is null, "no preference".
|
||||
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
|
||||
if store == nil || profileID == "" {
|
||||
return ""
|
||||
// resolvedPlaybackAudioLanguage returns the effective playback.audio_language
|
||||
// for one canonical settings context. It may return
|
||||
// playback.OriginalLanguageSentinel, which the caller resolves to a concrete
|
||||
// language. Returns "" when nothing is stored: the contract default is null,
|
||||
// "no preference". Resolution and decoding failures are returned so playback
|
||||
// does not silently substitute a different track.
|
||||
func resolvedPlaybackAudioLanguage(ctx context.Context, store userstore.UserStore, rc settingsresolve.Context) (string, error) {
|
||||
if store == nil || rc.ProfileID == "" {
|
||||
return "", nil
|
||||
}
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("loading settings contract: %w", err)
|
||||
}
|
||||
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
|
||||
settingsresolve.Context{ProfileID: profileID},
|
||||
resolved, err := settingsresolve.New(contract).Resolve(ctx, store, rc,
|
||||
[]string{settingskeys.PlaybackAudioLanguage}, nil)
|
||||
if err != nil || len(resolved) == 0 {
|
||||
return ""
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving playback audio language: %w", err)
|
||||
}
|
||||
if len(resolved) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var language string
|
||||
if json.Unmarshal(resolved[0].Value, &language) != nil {
|
||||
return ""
|
||||
if err := json.Unmarshal(resolved[0].Value, &language); err != nil {
|
||||
return "", fmt.Errorf("decoding playback audio language: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(language)
|
||||
return strings.TrimSpace(language), nil
|
||||
}
|
||||
|
||||
// --- Persistence helpers ---
|
||||
@@ -1376,7 +1384,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
h.touchSessionActivity(sessionID)
|
||||
|
||||
segmentName := chi.URLParam(r, "name")
|
||||
segmentLease, err := transcodeSession.OpenSegment(segmentName)
|
||||
segmentPath, err := transcodeSession.GetSegment(segmentName)
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||||
segNum, parseErr := playback.ParseSegmentNumber(segmentName)
|
||||
if parseErr == nil {
|
||||
@@ -1413,7 +1421,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
"session", sessionID,
|
||||
"playback_session_id", sessionID,
|
||||
)
|
||||
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, decision.WaitTimeout)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout)
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||||
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "api",
|
||||
"segment", segmentName,
|
||||
@@ -1475,7 +1483,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
); restartErr == nil {
|
||||
// Throttler + exit monitor re-arm via the session's
|
||||
// restart hook.
|
||||
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 30*time.Second)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 30*time.Second)
|
||||
if err == nil && strings.EqualFold(transcodeSession.Opts().TargetCodecVideo, "copy") {
|
||||
// Copy-mode seeks can resume as soon as the target segment
|
||||
// exists, but that sometimes leaves the player one segment
|
||||
@@ -1483,9 +1491,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
// for a single lookahead fragment when available so the
|
||||
// first resumed playback window is less brittle.
|
||||
nextSegmentName := fmt.Sprintf("seg_%05d.m4s", segNum+1)
|
||||
if nextSegment, nextErr := transcodeSession.WaitForOpenSegment(nextSegmentName, 1200*time.Millisecond); nextErr == nil {
|
||||
_ = nextSegment.Close()
|
||||
}
|
||||
_, _ = transcodeSession.WaitForSegment(nextSegmentName, 1200*time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1493,7 +1499,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
} else if transcodeSession.IsRunning() {
|
||||
// Non-numbered segment (e.g., init.mp4 for fMP4 HLS).
|
||||
// Wait briefly — the init segment is written almost immediately.
|
||||
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 10*time.Second)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 10*time.Second)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1505,17 +1511,14 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
// Report segment download for throttle tracking.
|
||||
if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil {
|
||||
transcodeSession.ReportSegmentDownloaded(segNum)
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
defer func() { _ = segmentLease.Close() }()
|
||||
sw := httpstream.NewRollingDeadlineWriter(w)
|
||||
http.ServeContent(sw, r, segmentLease.Info.Name(), segmentLease.Info.ModTime(), segmentLease.File)
|
||||
if r.Method == http.MethodGet &&
|
||||
sw.CompletedFullResponse(segmentLease.Info.Size()) {
|
||||
if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil {
|
||||
transcodeSession.ReportSegmentDownloadedForGeneration(segNum, segmentLease.Generation)
|
||||
}
|
||||
}
|
||||
http.ServeFile(w, r, segmentPath)
|
||||
}
|
||||
|
||||
// buildProxyManifestURL signs a stream token carrying the session's full
|
||||
@@ -1538,9 +1541,6 @@ func (h *PlaybackHandler) buildProxyManifestURL(card playback.RecipeCard, proxyN
|
||||
func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, transcodeNodeURL, path string) {
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
targetURL := transcodeNodeURL + path
|
||||
isSegmentRoute := strings.Contains(path, "/segment/")
|
||||
_, segmentParseErr := playback.ParseSegmentNumber(filepath.Base(path))
|
||||
isMediaSegment := segmentParseErr == nil
|
||||
// Capture the signed stream token ("st") before stripping it from the URL.
|
||||
// We forward it out-of-band as a header so the node can reconstruct after a
|
||||
// self-restart, while keeping it out of the forwarded/logged URL.
|
||||
@@ -1560,14 +1560,6 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+h.JWTSecret)
|
||||
if isSegmentRoute {
|
||||
// The node's immediate transport peer is this API process, so receiving a
|
||||
// complete response there does not prove that the browser received it.
|
||||
// Suppress node-local accounting and acknowledge only after the downstream
|
||||
// writer completes. Forward range validators so both hops serve the same
|
||||
// representation.
|
||||
transcodeproxy.PrepareRequest(req, r)
|
||||
}
|
||||
// Best-effort forward of the stream token as a header so the node's
|
||||
// reconstruct path (X-Silo-Stream-Token) can rebuild after a self-restart.
|
||||
// Verify at the API boundary and confirm it belongs to this session; an
|
||||
@@ -1591,7 +1583,7 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
|
||||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The node strips "st" from the request query (kept out of node URLs/logs),
|
||||
// so the segment/init URIs in the manifest it builds carry no token. Without
|
||||
@@ -1621,22 +1613,16 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
generation := resp.Header.Get(transcodeproxy.GenerationHeader)
|
||||
transcodeproxy.CopyResponseHeaders(w.Header(), resp.Header)
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
// Proxied transcode output can stream past the server's absolute
|
||||
// WriteTimeout; roll the write deadline with progress instead.
|
||||
sw := httpstream.NewRollingDeadlineWriter(w)
|
||||
sw.WriteHeader(resp.StatusCode)
|
||||
if _, copyErr := io.Copy(sw, resp.Body); copyErr != nil {
|
||||
return
|
||||
}
|
||||
fullSize := transcodeproxy.FullRepresentationSize(resp)
|
||||
if isMediaSegment && generation != "" && r.Method == http.MethodGet &&
|
||||
sw.CompletedFullResponse(fullSize) {
|
||||
if ackErr := transcodeproxy.Acknowledge(r.Context(), http.DefaultClient, transcodeNodeURL+path, h.JWTSecret, generation); ackErr != nil {
|
||||
slog.WarnContext(r.Context(), "acknowledge transcode segment completion", "component", "api", "error", ackErr, "playback_session_id", sessionID)
|
||||
}
|
||||
}
|
||||
io.Copy(sw, resp.Body)
|
||||
}
|
||||
|
||||
// maybeStartThrottler reads throttle settings and starts the throttler if enabled.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
func TestPlaybackHandlerTranscodeRuntimeConfigIncludesSegmentRetention(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.PlaybackConfig = func() config.PlaybackConfig {
|
||||
return config.PlaybackConfig{SegmentRetentionSeconds: 300}
|
||||
}
|
||||
|
||||
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
|
||||
t.Fatalf("configured segment retention = %d, want 300", got)
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
)
|
||||
|
||||
func TestProxyToTranscodeNodeAcknowledgesOnlyFullDownstreamResponse(t *testing.T) {
|
||||
const (
|
||||
body = "complete segment"
|
||||
generation = "17"
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
rangeHeader string
|
||||
wantStatus int
|
||||
wantAck int32
|
||||
}{
|
||||
{name: "ordinary get", wantStatus: http.StatusOK, wantAck: 1},
|
||||
{name: "whole-file range", rangeHeader: "bytes=0-", wantStatus: http.StatusPartialContent, wantAck: 1},
|
||||
{name: "partial range", rangeHeader: "bytes=1-", wantStatus: http.StatusPartialContent},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var acknowledgements atomic.Int32
|
||||
seenRange := make(chan string, 1)
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if r.Header.Get(transcodeproxy.RequestHeader) != "1" {
|
||||
t.Error("segment request omitted proxy marker")
|
||||
}
|
||||
seenRange <- r.Header.Get("Range")
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, generation)
|
||||
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
|
||||
case http.MethodPost:
|
||||
if r.Header.Get(transcodeproxy.GenerationHeader) != generation {
|
||||
t.Errorf("ack generation = %q, want %q", r.Header.Get(transcodeproxy.GenerationHeader), generation)
|
||||
}
|
||||
acknowledgements.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer node.Close()
|
||||
|
||||
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
|
||||
req := withPlaybackRouteParam(
|
||||
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
|
||||
"session_id",
|
||||
"public",
|
||||
)
|
||||
req.Header.Set("Range", tt.rangeHeader)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.proxyToTranscodeNode(rr, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
|
||||
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %q", rr.Code, tt.wantStatus, rr.Body.String())
|
||||
}
|
||||
if got := <-seenRange; got != tt.rangeHeader {
|
||||
t.Fatalf("forwarded Range = %q, want %q", got, tt.rangeHeader)
|
||||
}
|
||||
if got := rr.Header().Get(transcodeproxy.GenerationHeader); got != "" {
|
||||
t.Fatalf("internal generation leaked downstream: %q", got)
|
||||
}
|
||||
if got := acknowledgements.Load(); got != tt.wantAck {
|
||||
t.Fatalf("acknowledgements = %d, want %d", got, tt.wantAck)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyToTranscodeNodeDoesNotAcknowledgeFailedDownstreamWrite(t *testing.T) {
|
||||
const body = "complete segment"
|
||||
var acknowledgements atomic.Int32
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
acknowledgements.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, "21")
|
||||
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
|
||||
}))
|
||||
defer node.Close()
|
||||
|
||||
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
|
||||
req := withPlaybackRouteParam(
|
||||
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
|
||||
"session_id",
|
||||
"public",
|
||||
)
|
||||
w := &failingProxyResponseWriter{header: make(http.Header), remaining: 5}
|
||||
handler.proxyToTranscodeNode(w, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
|
||||
|
||||
if got := acknowledgements.Load(); got != 0 {
|
||||
t.Fatalf("failed downstream transfer produced %d acknowledgement(s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failingProxyResponseWriter struct {
|
||||
header http.Header
|
||||
status int
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (w *failingProxyResponseWriter) Header() http.Header { return w.header }
|
||||
|
||||
func (w *failingProxyResponseWriter) WriteHeader(status int) { w.status = status }
|
||||
|
||||
func (w *failingProxyResponseWriter) Write(p []byte) (int, error) {
|
||||
if w.remaining <= 0 {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
n := min(len(p), w.remaining)
|
||||
w.remaining -= n
|
||||
return n, io.ErrClosedPipe
|
||||
}
|
||||
@@ -551,6 +551,8 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
|
||||
file.AudioTracks = []models.AudioTrack{
|
||||
{Language: "eng", Codec: "aac", Channels: 2, Layout: "stereo", Default: true},
|
||||
{Language: "jpn", Codec: "aac", Channels: 2, Layout: "stereo"},
|
||||
{Language: "spa", Codec: "aac", Channels: 2, Layout: "stereo"},
|
||||
{Language: "fra", Codec: "aac", Channels: 2, Layout: "stereo"},
|
||||
}
|
||||
return file
|
||||
}
|
||||
@@ -564,18 +566,49 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) int {
|
||||
setCanonicalLanguage := func(t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, language string) {
|
||||
t.Helper()
|
||||
identity.Key = settingskeys.PlaybackAudioLanguage
|
||||
identity.ProfileID = "profile-1"
|
||||
encoded, err := json.Marshal(language)
|
||||
if err != nil {
|
||||
t.Fatalf("encode canonical audio language: %v", err)
|
||||
}
|
||||
if _, err := store.UpsertSettingValue(context.Background(), identity, encoded); err != nil {
|
||||
t.Fatalf("seed canonical audio language: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
startPlayback := func(
|
||||
t *testing.T,
|
||||
store userstore.UserStore,
|
||||
file *models.MediaFile,
|
||||
deviceID string,
|
||||
mutate func(*playback.StartRequestV3),
|
||||
) int {
|
||||
t.Helper()
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
if file.EpisodeID != "" {
|
||||
handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: file.EpisodeID, SeriesID: "series-1"}}
|
||||
}
|
||||
|
||||
startRequest := v3HandlerStartRequest()
|
||||
startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{Enabled: true, SupportedOnDevice: true}
|
||||
if mutate != nil {
|
||||
mutate(&startRequest)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start",
|
||||
strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
// A client name or user agent is not a stable settings identity. Only
|
||||
// the explicit device header may select a profile_device row.
|
||||
req.Header.Set("User-Agent", "SiloTV/apple-tv")
|
||||
if deviceID != "" {
|
||||
req.Header.Set(deviceIDHeader, deviceID)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
@@ -598,15 +631,11 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
|
||||
t.Run("canonical value wins over legacy column", func(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
setLegacyLanguage(t, store, "eng")
|
||||
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: settingskeys.PlaybackAudioLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
}, json.RawMessage(`"ja"`)); err != nil {
|
||||
t.Fatalf("seed canonical audio language: %v", err)
|
||||
}
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
}, "ja")
|
||||
|
||||
if index := startPlayback(t, store, newFile(t)); index != 1 {
|
||||
if index := startPlayback(t, store, newFile(t), "", nil); index != 1 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", index)
|
||||
}
|
||||
})
|
||||
@@ -617,10 +646,75 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
|
||||
|
||||
// No canonical value stored: the contract default is "no preference",
|
||||
// so selection falls to the file's default track, not the column's.
|
||||
if index := startPlayback(t, store, newFile(t)); index != 0 {
|
||||
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", index)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("device value applies only to the identified device", func(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{
|
||||
Scope: settingscontract.ScopeProfileDevice,
|
||||
DeviceID: "apple-tv",
|
||||
}, "ja")
|
||||
|
||||
if index := startPlayback(t, store, newFile(t), "apple-tv", nil); index != 1 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 1 (device \"ja\" track)", index)
|
||||
}
|
||||
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track without device identity)", index)
|
||||
}
|
||||
if index := startPlayback(t, store, newFile(t), "iphone", nil); index != 0 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track on another device)", index)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("series and library values outrank device value", func(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{
|
||||
Scope: settingscontract.ScopeProfileDevice,
|
||||
DeviceID: "apple-tv",
|
||||
}, "ja")
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{
|
||||
Scope: settingscontract.ScopeProfileLibrary,
|
||||
LibraryID: 12,
|
||||
}, "es")
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{
|
||||
Scope: settingscontract.ScopeProfileSeries,
|
||||
SeriesID: "series-1",
|
||||
}, "fr")
|
||||
|
||||
movie := newFile(t)
|
||||
movie.MediaFolderID = 12
|
||||
if index := startPlayback(t, store, movie, "apple-tv", nil); index != 2 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 2 (library \"es\" track)", index)
|
||||
}
|
||||
|
||||
episode := newFile(t)
|
||||
episode.MediaFolderID = 12
|
||||
episode.EpisodeID = "episode-1"
|
||||
if index := startPlayback(t, store, episode, "apple-tv", nil); index != 3 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 3 (series \"fr\" track)", index)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit track selection outranks saved settings", func(t *testing.T) {
|
||||
store := newPlaybackTestStore(t)
|
||||
setCanonicalLanguage(t, store, userstore.SettingIdentity{
|
||||
Scope: settingscontract.ScopeProfileDevice,
|
||||
DeviceID: "apple-tv",
|
||||
}, "ja")
|
||||
file := newFile(t)
|
||||
if index := startPlayback(t, store, file, "apple-tv", func(request *playback.StartRequestV3) {
|
||||
explicit := 0
|
||||
request.AudioTrackIndex = &explicit
|
||||
request.AudioTrackID = playback.TrackIDV3(file.ID, "audio", explicit)
|
||||
}); index != 0 {
|
||||
t.Fatalf("AudioTrackIndex = %d, want 0 (explicit \"en\" track)", index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An omitted start_position asks the server for its resume policy; an explicit
|
||||
|
||||
@@ -26,21 +26,23 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPlaybackV3BodyBytes = 256 << 10
|
||||
maxPlaybackV3EventBodyBytes = 32 << 10
|
||||
replanLeaseDurationV3 = 15 * time.Second
|
||||
replanReleaseTimeoutV3 = 3 * time.Second
|
||||
v3NodeCapabilityTTL = time.Minute
|
||||
playbackNodeIntegratedV3 = "integrated"
|
||||
subtitleFormatVTTV3 = "vtt"
|
||||
subtitleMIMEVTTV3 = "text/vtt"
|
||||
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
|
||||
seekRestorationPlayerV3 = "player_position"
|
||||
maxPlaybackV3BodyBytes = 256 << 10
|
||||
maxPlaybackV3EventBodyBytes = 32 << 10
|
||||
replanLeaseDurationV3 = 15 * time.Second
|
||||
replanReleaseTimeoutV3 = 3 * time.Second
|
||||
v3NodeCapabilityTTL = time.Minute
|
||||
playbackNodeIntegratedV3 = "integrated"
|
||||
subtitleFormatVTTV3 = "vtt"
|
||||
subtitleMIMEVTTV3 = "text/vtt"
|
||||
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
|
||||
transcodeStartFailedReasonV3 = "transcode_start_failed"
|
||||
seekRestorationPlayerV3 = "player_position"
|
||||
// Failed capability fetches are memoized briefly so an unreachable node
|
||||
// costs one timeout per window instead of one per planning request.
|
||||
v3NodeCapabilityErrorTTL = 15 * time.Second
|
||||
@@ -68,6 +70,13 @@ type preparedTransportV3 struct {
|
||||
afterDurableCommit func()
|
||||
}
|
||||
|
||||
type preparedTimelineV3 struct {
|
||||
seekSeconds float64
|
||||
streamOriginSeconds float64
|
||||
startSegmentNumber int
|
||||
copySeekAnchorResolved bool
|
||||
}
|
||||
|
||||
type transportErrorV3 struct {
|
||||
reason string
|
||||
message string
|
||||
@@ -354,11 +363,11 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
userID := apimw.GetUserID(r.Context())
|
||||
digestBytes := sha256.Sum256(body)
|
||||
requestDigest := hex.EncodeToString(digestBytes[:])
|
||||
deviceID := deviceMetadataFromRequest(r).DeviceID
|
||||
requestDigests := newPlaybackStartRequestDigestsV3(body, deviceID)
|
||||
if existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID); lookupErr == nil {
|
||||
if existing.UserID != userID || existing.ProfileID != profileID || existing.RequestedMediaFileID != req.FileID ||
|
||||
(existing.RequestDigest != "" && existing.RequestDigest != requestDigest) {
|
||||
!requestDigests.matches(existing.RequestDigest) {
|
||||
writeError(w, http.StatusConflict, "playback_attempt_reused", "The playback attempt ID belongs to a different request")
|
||||
return
|
||||
}
|
||||
@@ -396,7 +405,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
if req.AudioTrackID == "" && req.AudioTrackIndex == nil {
|
||||
audioIndex, err = h.preferredAudioTrackIndexV3(r.Context(), userID, profileID, requestedFile)
|
||||
audioIndex, err = h.preferredAudioTrackIndexV3(r.Context(), userID, profileID, deviceID, requestedFile)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load the saved audio preference")
|
||||
return
|
||||
@@ -426,7 +435,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
effectiveFile = h.ensurePlaybackProbe(r.Context(), alternate)
|
||||
audioIndex = remapAudioIndexV3(requestedFile, effectiveFile, audioIndex)
|
||||
if err := h.remapSubtitleSelectionV3(r.Context(), requestedFile, effectiveFile, &req); err != nil {
|
||||
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false))
|
||||
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false))
|
||||
if persistErr != nil {
|
||||
writeStartAttemptPersistenceErrorV3(w, persistErr)
|
||||
return
|
||||
@@ -449,7 +458,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
"file_id", effectiveFile.ID,
|
||||
"quality_preference", req.QualityPreference,
|
||||
)
|
||||
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
|
||||
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
|
||||
if persistErr != nil {
|
||||
writeStartAttemptPersistenceErrorV3(w, persistErr)
|
||||
return
|
||||
@@ -478,7 +487,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
"bandwidth_estimate_kbps", intOrZeroHandlerV3(req.BandwidthEstimateKbps),
|
||||
)
|
||||
result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, warnings...)
|
||||
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigest, requestedFile, effectiveFile, audioIndex, result)
|
||||
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigests, requestedFile, effectiveFile, audioIndex, result)
|
||||
if statusErr != nil {
|
||||
if statusErr.reason == "playback_attempt_reused" {
|
||||
writeError(w, http.StatusConflict, "playback_attempt_reused", statusErr.message)
|
||||
@@ -487,7 +496,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
if statusErr.reason == "internal_error" {
|
||||
slog.ErrorContext(r.Context(), "protocol v3 start failed", "component", "api", "reason", statusErr.reason, "error", statusErr.cause)
|
||||
}
|
||||
persistedResponse, persistErr := h.startFailureDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, statusErr)
|
||||
persistedResponse, persistErr := h.startFailureDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, statusErr)
|
||||
if persistErr != nil {
|
||||
writeStartAttemptPersistenceErrorV3(w, persistErr)
|
||||
return
|
||||
@@ -498,7 +507,32 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
|
||||
writeJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
|
||||
type playbackStartRequestDigestsV3 struct {
|
||||
current string
|
||||
legacy string
|
||||
}
|
||||
|
||||
// newPlaybackStartRequestDigestsV3 fingerprints both the body and normalized
|
||||
// device identity because either can change the selected playback plan. It
|
||||
// also retains the pre-device digest while attempts written by an older
|
||||
// server can still be replayed during a rolling deployment.
|
||||
func newPlaybackStartRequestDigestsV3(body []byte, deviceID string) playbackStartRequestDigestsV3 {
|
||||
hasher := sha256.New()
|
||||
_, _ = fmt.Fprintf(hasher, "%d:", len(body))
|
||||
_, _ = hasher.Write(body)
|
||||
_, _ = hasher.Write([]byte(deviceID))
|
||||
legacy := sha256.Sum256(body)
|
||||
return playbackStartRequestDigestsV3{
|
||||
current: hex.EncodeToString(hasher.Sum(nil)),
|
||||
legacy: hex.EncodeToString(legacy[:]),
|
||||
}
|
||||
}
|
||||
|
||||
func (d playbackStartRequestDigestsV3) matches(stored string) bool {
|
||||
return stored == "" || stored == d.current || stored == d.legacy
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
|
||||
if result.Plan == nil {
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."}
|
||||
}
|
||||
@@ -566,7 +600,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
|
||||
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
|
||||
}
|
||||
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
|
||||
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, StartResponse: response, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
|
||||
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, StartResponse: response, RequestDigest: requestDigests.current, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
|
||||
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
|
||||
transport.rollback()
|
||||
abort()
|
||||
@@ -575,12 +609,9 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
|
||||
if err := h.PlanStoreV3.SaveAttempt(r.Context(), record); err != nil {
|
||||
transport.rollback()
|
||||
abort()
|
||||
if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."}
|
||||
}
|
||||
if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
|
||||
if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) || errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
|
||||
existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID)
|
||||
if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID {
|
||||
if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID && requestDigests.matches(existing.RequestDigest) {
|
||||
// Replaying a concurrent duplicate is only valid while its
|
||||
// session is alive; otherwise tell the client to mint a new
|
||||
// attempt rather than hand it a plan it can never stream.
|
||||
@@ -589,6 +620,9 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
|
||||
}
|
||||
return decisionResponseFromAttemptV3(existing), nil
|
||||
}
|
||||
if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."}
|
||||
}
|
||||
}
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to persist the playback plan.", cause: err}
|
||||
}
|
||||
@@ -633,8 +667,12 @@ func (h *PlaybackHandler) persistSeriesSelectionsV3(ctx context.Context, userID
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) {
|
||||
timeline, timelineErr := h.prepareTransportTimelineV3(r.Context(), session, file, result)
|
||||
if timelineErr != nil {
|
||||
return preparedTransportV3{}, timelineErr
|
||||
}
|
||||
if result.Plan.Delivery != playback.DeliveryTranscodeHLSV3 && result.Plan.Delivery != playback.DeliveryRemuxHLSV3 {
|
||||
return h.prepareIdentityTransportV3(session, result), nil
|
||||
return h.prepareIdentityTransportV3(session, result, timeline), nil
|
||||
}
|
||||
if h.NodePlanner != nil {
|
||||
plan := h.planNodeSessionV3(r.Context(), session, result)
|
||||
@@ -644,7 +682,7 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
|
||||
err = validateAdvertisedTransformationsV3(result.Plan, transformations)
|
||||
}
|
||||
if err == nil {
|
||||
transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan)
|
||||
transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan, timeline)
|
||||
if transportErr != nil {
|
||||
if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok {
|
||||
releaser.ReleaseSession(session.ID)
|
||||
@@ -674,7 +712,54 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_node_capability_unavailable", message: "No available transcode executor can run the selected playback recipe.", retryable: true, cause: err}
|
||||
}
|
||||
}
|
||||
return h.prepareLocalTransportV3(r, session, file, result)
|
||||
return h.prepareLocalTransportV3(r, session, file, result, timeline)
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) prepareTransportTimelineV3(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTimelineV3, *transportErrorV3) {
|
||||
if result.Plan == nil {
|
||||
return preparedTimelineV3{}, nil
|
||||
}
|
||||
|
||||
requested := result.Plan.Timeline.SourceStartSeconds
|
||||
switch result.Plan.Delivery {
|
||||
case playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3:
|
||||
// Audio-only remuxes have no copied video keyframe to resolve. Keep the
|
||||
// requested position as their exact stream origin: the chunked output
|
||||
// clock restarts at zero and later seeks require another server reanchor.
|
||||
if file != nil && file.IsAudioOnly() {
|
||||
configureCopyRemuxTimelineV3(result.Plan, requested)
|
||||
return preparedTimelineV3{seekSeconds: requested, streamOriginSeconds: requested}, nil
|
||||
}
|
||||
origin, startSegment := 0.0, 0
|
||||
if requested > 0 {
|
||||
if file == nil || strings.TrimSpace(file.FilePath) == "" {
|
||||
return preparedTimelineV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to resolve remux seek position.", retryable: true, cause: errors.New("copy seek anchor requires a media file path")}
|
||||
}
|
||||
resolver := h.copySeekAnchor
|
||||
if resolver == nil {
|
||||
resolver = playback.ResolveCopySeekAnchor
|
||||
}
|
||||
var err error
|
||||
origin, startSegment, err = resolver(ctx, h.playbackConfig().FFmpegPath, file.FilePath, requested, 2)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "failed to resolve protocol v3 copy-video seek anchor",
|
||||
"component", "api",
|
||||
"playback_session_id", session.ID,
|
||||
"requested_seek_seconds", requested,
|
||||
"error", err,
|
||||
)
|
||||
return preparedTimelineV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to resolve remux seek position.", retryable: true, cause: err}
|
||||
}
|
||||
}
|
||||
configureCopyRemuxTimelineV3(result.Plan, origin)
|
||||
return preparedTimelineV3{seekSeconds: requested, streamOriginSeconds: origin, startSegmentNumber: startSegment, copySeekAnchorResolved: true}, nil
|
||||
case playback.DeliveryTranscodeHLSV3:
|
||||
sourceMetadata := sourceExecutionMetadataV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, result.TargetVideoCodec, 2, sourceMetadata.DurationSeconds)
|
||||
return preparedTimelineV3{seekSeconds: seekSeconds, streamOriginSeconds: result.Plan.Timeline.StreamOriginSeconds, startSegmentNumber: startSegment}, nil
|
||||
default:
|
||||
return preparedTimelineV3{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// planRequiresServerTransformationsV3 reports whether the plan carries any
|
||||
@@ -692,7 +777,7 @@ func planRequiresServerTransformationsV3(plan *playback.PlanV3) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3) preparedTransportV3 {
|
||||
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3, timeline preparedTimelineV3) preparedTransportV3 {
|
||||
routeSession := *session
|
||||
routeSession.PlayMethod = result.PlayMethod
|
||||
routeSession.BasePlayMethod = result.PlayMethod
|
||||
@@ -709,8 +794,7 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session,
|
||||
committed := false
|
||||
streamURL := h.playbackStreamURL(&routeSession)
|
||||
if result.Plan != nil && result.Plan.Delivery == playback.DeliveryRemuxProgressiveV3 {
|
||||
configureProgressiveRemuxTimelineV3(result.Plan)
|
||||
if seek := result.Plan.Timeline.StreamOriginSeconds; seek > 0 {
|
||||
if seek := timeline.seekSeconds; seek > 0 {
|
||||
streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64))
|
||||
}
|
||||
}
|
||||
@@ -756,14 +840,14 @@ func sessionOwnsResumeTimelineV3(file *models.MediaFile) bool {
|
||||
}
|
||||
|
||||
// preferredAudioTrackIndexV3 answers what an omitted audio track means: the
|
||||
// language this profile has settled on for this series, this library, or
|
||||
// generally — the same resolution the catalog performs when it publishes
|
||||
// `effective_audio_track_index`, so the track a client sees on the detail page
|
||||
// is the track that plays when it does not ask for one.
|
||||
// language this profile has settled on for this series, this library, this
|
||||
// device, or generally — the same resolution the catalog performs when it
|
||||
// publishes `effective_audio_track_index`, so the track a client sees on the
|
||||
// detail page is the track that plays when it does not ask for one.
|
||||
//
|
||||
// The client sends a track identity only when the viewer picked one. Defaulting
|
||||
// to ordinal zero instead would silently play the first track on the reel.
|
||||
func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID int, profileID string, file *models.MediaFile) (int, error) {
|
||||
func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID int, profileID, deviceID string, file *models.MediaFile) (int, error) {
|
||||
if file == nil || len(file.AudioTracks) == 0 || h.StoreProvider == nil {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -772,8 +856,9 @@ func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID
|
||||
slog.ErrorContext(ctx, "protocol v3 start: audio preference store lookup failed", "component", "api", "user_id", userID, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
seriesID := h.resolveSeriesID(ctx, file)
|
||||
var seriesPref *playback.AudioTrackPreference
|
||||
if seriesID := h.resolveSeriesID(ctx, file); seriesID != "" {
|
||||
if seriesID != "" {
|
||||
stored, prefErr := store.GetAudioPreference(ctx, profileID, seriesID)
|
||||
if prefErr != nil {
|
||||
slog.ErrorContext(ctx, "protocol v3 start: series audio preference lookup failed", "component", "api", "profile_id", profileID, "series_id", seriesID, "error", prefErr)
|
||||
@@ -781,36 +866,38 @@ func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID
|
||||
}
|
||||
if stored != nil {
|
||||
seriesPref = &playback.AudioTrackPreference{AudioTrackIndex: stored.AudioTrackIndex, AudioLanguage: stored.AudioLanguage, TrackSignature: stored.TrackSignature}
|
||||
if seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
|
||||
seriesPref.AudioLanguage = h.resolveOriginalLanguage(ctx, file)
|
||||
}
|
||||
}
|
||||
rc := settingsresolve.Context{
|
||||
ProfileID: profileID,
|
||||
DeviceID: deviceID,
|
||||
LibraryIDs: []int{file.MediaFolderID},
|
||||
}
|
||||
if seriesID != "" {
|
||||
rc.SeriesIDs = []string{seriesID}
|
||||
}
|
||||
preferredLang, err := resolvedPlaybackAudioLanguage(ctx, store, rc)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "protocol v3 start: canonical audio preference lookup failed", "component", "api", "profile_id", profileID, "device_id", deviceID, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
if preferredLang == playback.OriginalLanguageSentinel {
|
||||
preferredLang = h.resolveOriginalLanguage(ctx, file)
|
||||
if preferredLang == "" {
|
||||
preferredLang, err = resolvedPlaybackAudioLanguage(ctx, store, settingsresolve.Context{ProfileID: profileID})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "protocol v3 start: profile audio preference fallback failed", "component", "api", "profile_id", profileID, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
if preferredLang == playback.OriginalLanguageSentinel {
|
||||
preferredLang = h.resolveOriginalLanguage(ctx, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
preferredLang := resolvedProfileAudioLanguage(ctx, store, profileID)
|
||||
// A library override applies only where no series-sticky choice exists;
|
||||
// having watched this series in a language outranks the library default.
|
||||
libraryLang := ""
|
||||
if seriesPref == nil {
|
||||
stored, prefErr := store.GetLibraryPlaybackPreference(ctx, profileID, file.MediaFolderID)
|
||||
if prefErr != nil {
|
||||
slog.ErrorContext(ctx, "protocol v3 start: library audio preference lookup failed", "component", "api", "profile_id", profileID, "library_id", file.MediaFolderID, "error", prefErr)
|
||||
return 0, prefErr
|
||||
}
|
||||
if stored != nil {
|
||||
libraryLang = stored.AudioLanguage
|
||||
}
|
||||
}
|
||||
if preferredLang == playback.OriginalLanguageSentinel || libraryLang == playback.OriginalLanguageSentinel {
|
||||
originalLang := h.resolveOriginalLanguage(ctx, file)
|
||||
if preferredLang == playback.OriginalLanguageSentinel {
|
||||
preferredLang = originalLang
|
||||
}
|
||||
if libraryLang == playback.OriginalLanguageSentinel {
|
||||
libraryLang = originalLang
|
||||
}
|
||||
}
|
||||
if libraryLang != "" {
|
||||
preferredLang = libraryLang
|
||||
if seriesPref != nil {
|
||||
// The specialized row supplies concrete track identity; canonical
|
||||
// settings own the language and its scope precedence.
|
||||
seriesPref.AudioLanguage = preferredLang
|
||||
}
|
||||
return normalizeAudioTrackIndex(file, playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)), nil
|
||||
}
|
||||
@@ -849,16 +936,16 @@ func (h *PlaybackHandler) resumePositionV3(ctx context.Context, userID int, prof
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// A progressive remux is a freshly generated, chunked MP4 response and does
|
||||
// not implement byte ranges. Its player clock therefore begins at zero at the
|
||||
// requested source origin, and arbitrary seeks must request another server
|
||||
// reanchor rather than issuing a Range request against the remux pipe.
|
||||
func configureProgressiveRemuxTimelineV3(plan *playback.PlanV3) {
|
||||
// A copy remux starts at the preceding keyframe selected by the demuxer, not
|
||||
// necessarily at the requested source position. Its player clock therefore
|
||||
// begins at the resolved stream origin and advances through the copied pre-roll
|
||||
// before reaching the requested position. Neither progressive nor growing HLS
|
||||
// copy transports can seek arbitrarily inside their current response.
|
||||
func configureCopyRemuxTimelineV3(plan *playback.PlanV3, origin float64) {
|
||||
if plan == nil {
|
||||
return
|
||||
}
|
||||
origin := plan.Timeline.SourceStartSeconds
|
||||
plan.Timeline.PlayerStartSeconds = 0
|
||||
plan.Timeline.PlayerStartSeconds = max(0, plan.Timeline.SourceStartSeconds-origin)
|
||||
plan.Timeline.StreamOriginSeconds = origin
|
||||
plan.Timeline.TimelineOffsetSeconds = origin
|
||||
plan.Timeline.SeekWindowStartSeconds = &origin
|
||||
@@ -875,7 +962,7 @@ func appendPlaybackQueryV3(rawURL, key, value string) string {
|
||||
return rawURL + separator + key + "=" + value
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) {
|
||||
func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
|
||||
cfg := h.playbackConfig()
|
||||
if err := os.MkdirAll(cfg.TranscodeDir, 0o755); err != nil {
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to prepare the transcode directory.", cause: err}
|
||||
@@ -888,13 +975,12 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
|
||||
}
|
||||
sourceMetadata := sourceExecutionMetadataV3(file, result)
|
||||
sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceMetadata.DurationSeconds)
|
||||
unlock := h.tm.LockSessionLifecycle(session.ID)
|
||||
opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, SegmentRetentionSeconds: cfg.SegmentRetentionSeconds, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}
|
||||
opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}
|
||||
ts, err := h.startLocalPlaybackTransport(r.Context(), opts)
|
||||
if err != nil {
|
||||
unlock()
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to start the playback transport.", retryable: true, cause: err}
|
||||
}
|
||||
if _, readyErr := ts.WaitForManifest(playback.ManifestStartupTimeout); readyErr != nil {
|
||||
wasRunning := ts.IsRunning()
|
||||
@@ -921,7 +1007,7 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
|
||||
ts, err = h.startLocalPlaybackTransport(r.Context(), retryOpts)
|
||||
if err != nil {
|
||||
unlock()
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to start the playback transport.", retryable: true, cause: err}
|
||||
}
|
||||
if _, retryReadyErr := ts.WaitForManifest(playback.ManifestStartupTimeout); retryReadyErr != nil {
|
||||
transportErr = manifestStartupTransportErrorV3(ts.IsRunning(), retryReadyErr)
|
||||
@@ -973,10 +1059,10 @@ func manifestStartupTransportErrorV3(running bool, cause error) *transportErrorV
|
||||
if running {
|
||||
message = "The playback transport did not become ready in time."
|
||||
}
|
||||
return &transportErrorV3{reason: "transcode_start_failed", message: message, retryable: running, cause: cause}
|
||||
return &transportErrorV3{reason: transcodeStartFailedReasonV3, message: message, retryable: running, cause: cause}
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan) (preparedTransportV3, *transportErrorV3) {
|
||||
func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
|
||||
node := nodePlan.TranscodeNode
|
||||
transportID := transportGenerationV3(session.ID, result.Plan.PlanID)
|
||||
videoCodec := result.TargetVideoCodec
|
||||
@@ -985,8 +1071,7 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
|
||||
}
|
||||
sourceMetadata := sourceExecutionMetadataV3(file, result)
|
||||
sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceMetadata.DurationSeconds)
|
||||
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true}
|
||||
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true}
|
||||
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
|
||||
if err != nil {
|
||||
// A timeout can fire after the node actually started the job; the
|
||||
@@ -997,10 +1082,10 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
|
||||
}
|
||||
if status != http.StatusAccepted {
|
||||
h.tm.StopRemoteTranscode(transportID, node.URL)
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "The selected transcode node rejected the playback transport.", retryable: true}
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "The selected transcode node rejected the playback transport.", retryable: true}
|
||||
}
|
||||
hw := firstNonEmptyHandlerV3(strings.TrimSpace(nodeResp.HWAccel), strings.TrimSpace(req.HWAccel))
|
||||
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, VideoBitstreamFilter: req.VideoBitstreamFilter, SeekSeconds: req.SeekSeconds, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
|
||||
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, VideoBitstreamFilter: req.VideoBitstreamFilter, SeekSeconds: req.SeekSeconds, StreamOriginSeconds: req.StreamOriginSeconds, CopySeekAnchorResolved: req.CopySeekAnchorResolved, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
|
||||
url := h.buildProxyManifestURL(card, nodePlan.ProxyNode)
|
||||
committed := false
|
||||
previousNodeURL := session.TranscodeNodeURL
|
||||
@@ -2621,7 +2706,7 @@ func sessionStartErrorV3(err error) *transportErrorV3 {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFileID, effectiveFileID int, response playback.DecisionResponseV3) (playback.DecisionResponseV3, error) {
|
||||
func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFileID, effectiveFileID int, response playback.DecisionResponseV3) (playback.DecisionResponseV3, error) {
|
||||
record := playback.AttemptRecordV3{
|
||||
PlaybackAttemptID: req.PlaybackAttemptID,
|
||||
UserID: userID,
|
||||
@@ -2630,12 +2715,12 @@ func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, us
|
||||
EffectiveMediaFileID: effectiveFileID,
|
||||
NormalizedRequest: req,
|
||||
StartResponse: response,
|
||||
RequestDigest: requestDigest,
|
||||
RequestDigest: requestDigests.current,
|
||||
ExpiresAt: time.Now().Add(playback.MaxTokenTTL),
|
||||
}
|
||||
if err := h.PlanStoreV3.SaveAttempt(ctx, record); err == nil {
|
||||
return response, nil
|
||||
} else if !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
|
||||
} else if !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) && !errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
|
||||
return playback.DecisionResponseV3{}, err
|
||||
}
|
||||
|
||||
@@ -2644,15 +2729,15 @@ func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, us
|
||||
return playback.DecisionResponseV3{}, err
|
||||
}
|
||||
if existing.UserID != userID || existing.ProfileID != profileID ||
|
||||
existing.RequestedMediaFileID != requestedFileID || existing.RequestDigest != requestDigest {
|
||||
existing.RequestedMediaFileID != requestedFileID || !requestDigests.matches(existing.RequestDigest) {
|
||||
return playback.DecisionResponseV3{}, playback.ErrIdempotencyKeyReusedV3
|
||||
}
|
||||
return decisionResponseFromAttemptV3(existing), nil
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) startFailureDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFileID, effectiveFileID int, failure *transportErrorV3) (playback.DecisionResponseV3, error) {
|
||||
func (h *PlaybackHandler) startFailureDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFileID, effectiveFileID int, failure *transportErrorV3) (playback.DecisionResponseV3, error) {
|
||||
response := playback.NewTerminalResponseV3(failure.reason, failure.message, failure.retryable)
|
||||
return h.persistTerminalStartDecisionV3(ctx, userID, profileID, req, requestDigest, requestedFileID, effectiveFileID, response)
|
||||
return h.persistTerminalStartDecisionV3(ctx, userID, profileID, req, requestDigests, requestedFileID, effectiveFileID, response)
|
||||
}
|
||||
|
||||
func writeStartAttemptPersistenceErrorV3(w http.ResponseWriter, err error) {
|
||||
@@ -2884,12 +2969,12 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
|
||||
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
|
||||
startSegment := computeStartSegment(seek, segmentDuration)
|
||||
plan.Timeline.SourceStartSeconds = requested
|
||||
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
|
||||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
|
||||
usesGrowingManifest := !playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
|
||||
if usesGrowingManifest {
|
||||
// Encoded streams seek to the preceding segment boundary. Preserve the
|
||||
// requested sub-segment offset so playback still begins at the exact
|
||||
// requested source position. Copy seeks are already exact, making this 0.
|
||||
// requested source position. Copy remuxes are configured separately with
|
||||
// their probed keyframe origin.
|
||||
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
|
||||
plan.Timeline.StreamOriginSeconds = seek
|
||||
plan.Timeline.TimelineOffsetSeconds = seek
|
||||
|
||||
@@ -3,6 +3,8 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -49,12 +51,12 @@ func (s failingProgressStoreV3) GetProgress(context.Context, string, string) (*u
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
type failingLibraryPlaybackPreferenceStoreV3 struct {
|
||||
type failingSettingsResolutionStoreV3 struct {
|
||||
userstore.UserStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (s failingLibraryPlaybackPreferenceStoreV3) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
|
||||
func (s failingSettingsResolutionStoreV3) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
@@ -311,6 +313,86 @@ func TestHandleStartPlaybackV3CommitsStartSideEffectsOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlaybackV3RejectsAttemptReplayFromAnotherDevice(t *testing.T) {
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
body := marshalV3StartRequest(t, v3HandlerStartRequest())
|
||||
|
||||
start := func(deviceID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(body)).WithContext(newAuthorizedPlaybackContext())
|
||||
req.Header.Set(deviceIDHeader, deviceID)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
first := start(" living-room ")
|
||||
sameDevice := start("living-room")
|
||||
otherDevice := start("bedroom")
|
||||
if first.Code != http.StatusCreated || sameDevice.Code != http.StatusCreated {
|
||||
t.Fatalf("same-device statuses = %d, %d; first=%s replay=%s", first.Code, sameDevice.Code, first.Body.String(), sameDevice.Body.String())
|
||||
}
|
||||
if first.Body.String() != sameDevice.Body.String() {
|
||||
t.Fatalf("normalized same-device replay changed response:\nfirst=%s\nreplay=%s", first.Body.String(), sameDevice.Body.String())
|
||||
}
|
||||
if otherDevice.Code != http.StatusConflict || !strings.Contains(otherDevice.Body.String(), "playback_attempt_reused") {
|
||||
t.Fatalf("other-device status = %d, body = %s", otherDevice.Code, otherDevice.Body.String())
|
||||
}
|
||||
if got := len(manager.AllSessions()); got != 1 {
|
||||
t.Fatalf("sessions = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlaybackV3AcceptsPreDeviceDigestReplay(t *testing.T) {
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
body := marshalV3StartRequest(t, startRequest)
|
||||
|
||||
start := func(requestBody, deviceID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(requestBody)).WithContext(newAuthorizedPlaybackContext())
|
||||
req.Header.Set(deviceIDHeader, deviceID)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
first := start(body, "living-room")
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first status = %d, body = %s", first.Code, first.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyDigest := sha256.Sum256([]byte(body))
|
||||
record.RequestDigest = hex.EncodeToString(legacyDigest[:])
|
||||
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
|
||||
|
||||
replay := start(body, "living-room")
|
||||
if replay.Code != http.StatusCreated || replay.Body.String() != first.Body.String() {
|
||||
t.Fatalf("legacy replay status = %d, body = %s; first = %s", replay.Code, replay.Body.String(), first.Body.String())
|
||||
}
|
||||
|
||||
changedRequest := startRequest
|
||||
position := 42.0
|
||||
changedRequest.StartPosition = &position
|
||||
conflict := start(marshalV3StartRequest(t, changedRequest), "living-room")
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "playback_attempt_reused") {
|
||||
t.Fatalf("changed legacy replay status = %d, body = %s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlaybackCapabilityV3AdvertisesTheFinalizedContract(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
|
||||
@@ -530,22 +612,21 @@ func TestPreferredAudioTrackIndexV3PropagatesSeriesPreferenceReadFailure(t *test
|
||||
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
|
||||
}
|
||||
|
||||
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
|
||||
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "", file); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredAudioTrackIndexV3PropagatesLibraryPreferenceReadFailure(t *testing.T) {
|
||||
wantErr := errors.New("library playback preference store unavailable")
|
||||
store := failingLibraryPlaybackPreferenceStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
|
||||
func TestPreferredAudioTrackIndexV3PropagatesCanonicalPreferenceReadFailure(t *testing.T) {
|
||||
wantErr := errors.New("settings resolution unavailable")
|
||||
store := failingSettingsResolutionStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.StoreProvider = testUserStoreProvider{store: store}
|
||||
file := &models.MediaFile{
|
||||
MediaFolderID: 9,
|
||||
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
|
||||
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
|
||||
}
|
||||
|
||||
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
|
||||
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "living-room", file); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
@@ -566,6 +647,7 @@ func TestHandleReplanPlaybackV3UpdatesSelectedAudioAndReplaysIdempotently(t *tes
|
||||
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
stubCopySeekAnchorV3(handler)
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
@@ -1186,6 +1268,7 @@ func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T)
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
stubCopySeekAnchorV3(handler)
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
|
||||
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
|
||||
@@ -1269,6 +1352,7 @@ func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
stubCopySeekAnchorV3(handler)
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
@@ -1368,6 +1452,7 @@ func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *te
|
||||
source.ID: source, alternate.ID: alternate,
|
||||
}
|
||||
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
|
||||
stubCopySeekAnchorV3(handler)
|
||||
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{
|
||||
source.ContentID: {source, alternate},
|
||||
}}
|
||||
@@ -2054,29 +2139,40 @@ func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
|
||||
func TestPrepareTransportV3ProgressiveRemuxUsesResolvedCopyAnchor(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
handler.copySeekAnchor = func(_ context.Context, _ string, inputPath string, requested float64, segmentDuration int) (float64, int, error) {
|
||||
if inputPath != "/media/movie.mkv" || segmentDuration != 2 {
|
||||
t.Fatalf("copy seek probe input=%q segment=%d", inputPath, segmentDuration)
|
||||
}
|
||||
return requested - 0.75, int((requested - 0.75) / float64(segmentDuration)), nil
|
||||
}
|
||||
session := &playback.Session{ID: "session-progressive", UserID: 7, ProfileID: "profile-1", MediaFileID: 42, PlayMethod: playback.PlayRemux, BasePlayMethod: playback.PlayRemux, AudioTrackIndex: 0}
|
||||
for index, origin := range []float64{321.25, 654.5} {
|
||||
file := &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"}
|
||||
for index, requested := range []float64{321.25, 654.5} {
|
||||
plan := &playback.PlanV3{
|
||||
PlanID: "plan:progressive",
|
||||
Delivery: playback.DeliveryRemuxProgressiveV3,
|
||||
EffectiveMediaFileID: 42,
|
||||
Timeline: playback.TimelineV3{SourceStartSeconds: origin, PlayerStartSeconds: origin, CanSeekAnywhere: true, SeekRestoration: "player_position"},
|
||||
Timeline: playback.TimelineV3{SourceStartSeconds: requested, PlayerStartSeconds: requested, CanSeekAnywhere: true, SeekRestoration: "player_position"},
|
||||
}
|
||||
transport, transportErr := handler.prepareTransportV3(httptest.NewRequest(http.MethodPost, "/", nil), session, file, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
|
||||
if transportErr != nil {
|
||||
t.Fatalf("prepare progressive transport: %v", transportErr)
|
||||
}
|
||||
transport := handler.prepareIdentityTransportV3(session, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
|
||||
|
||||
parsed, err := url.Parse(transport.url)
|
||||
if err != nil {
|
||||
transport.rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(origin, 'f', -1, 64) {
|
||||
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(requested, 'f', -1, 64) {
|
||||
transport.rollback()
|
||||
t.Fatalf("progressive reanchor URL %d = %q", index, transport.url)
|
||||
}
|
||||
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.StreamOriginSeconds != origin ||
|
||||
origin := requested - 0.75
|
||||
if plan.Timeline.PlayerStartSeconds != 0.75 || plan.Timeline.StreamOriginSeconds != origin ||
|
||||
plan.Timeline.TimelineOffsetSeconds != origin || plan.Timeline.CanSeekAnywhere ||
|
||||
plan.Timeline.SeekWindowStartSeconds == nil || *plan.Timeline.SeekWindowStartSeconds != origin ||
|
||||
plan.Timeline.SeekWindowEndSeconds != nil || plan.Timeline.SeekRestoration != "source_position" {
|
||||
@@ -2087,6 +2183,77 @@ func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3AudioOnlyRemuxSkipsVideoCopyAnchor(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
probeCalls := 0
|
||||
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
|
||||
probeCalls++
|
||||
return 0, 0, errors.New("audio-only remux must not probe a video keyframe")
|
||||
}
|
||||
requested := 321.25
|
||||
plan := &playback.PlanV3{
|
||||
PlanID: "plan:audio-only-progressive",
|
||||
Delivery: playback.DeliveryRemuxProgressiveV3,
|
||||
Timeline: playback.TimelineV3{
|
||||
SourceStartSeconds: requested,
|
||||
PlayerStartSeconds: requested,
|
||||
CanSeekAnywhere: true,
|
||||
SeekRestoration: "player_position",
|
||||
},
|
||||
}
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
BaseType: "audiobook",
|
||||
FilePath: "/media/book.m4b",
|
||||
CodecAudio: "aac",
|
||||
AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2}},
|
||||
}
|
||||
transport, transportErr := handler.prepareTransportV3(
|
||||
httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
&playback.Session{ID: "session-audio-only", MediaFileID: 42},
|
||||
file,
|
||||
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TargetAudioCodec: "aac"},
|
||||
)
|
||||
if transportErr != nil {
|
||||
t.Fatalf("prepare audio-only transport: %v", transportErr)
|
||||
}
|
||||
defer transport.rollback()
|
||||
if probeCalls != 0 {
|
||||
t.Fatalf("audio-only copy anchor probes = %d, want 0", probeCalls)
|
||||
}
|
||||
parsed, err := url.Parse(transport.url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Query().Get("seek") != strconv.FormatFloat(requested, 'f', -1, 64) {
|
||||
t.Fatalf("audio-only seek URL = %q", transport.url)
|
||||
}
|
||||
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.CanSeekAnywhere ||
|
||||
plan.Timeline.SeekRestoration != "source_position" || plan.Timeline.StreamOriginSeconds != requested ||
|
||||
plan.Timeline.TimelineOffsetSeconds != requested || plan.Timeline.SeekWindowStartSeconds == nil ||
|
||||
*plan.Timeline.SeekWindowStartSeconds != requested || plan.Timeline.SeekWindowEndSeconds != nil {
|
||||
t.Fatalf("audio-only timeline changed = %#v", plan.Timeline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3CopyAnchorFailureIsRetryable(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
|
||||
return 0, 0, errors.New("probe failed")
|
||||
}
|
||||
plan := &playback.PlanV3{PlanID: "plan:copy-failure", Delivery: playback.DeliveryRemuxHLSV3, Timeline: playback.TimelineV3{SourceStartSeconds: 120}}
|
||||
_, transportErr := handler.prepareTransportV3(
|
||||
httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
&playback.Session{ID: "session-copy-failure"},
|
||||
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"},
|
||||
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux},
|
||||
)
|
||||
if transportErr == nil || transportErr.reason != "transcode_start_failed" || !transportErr.retryable || transportErr.cause == nil || transportErr.cause.Error() != "probe failed" {
|
||||
t.Fatalf("transport error = %#v, want retryable copy anchor failure", transportErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3RejectsNodeMissingRequiredTransformation(t *testing.T) {
|
||||
startHits := 0
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2167,6 +2334,52 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3SendsResolvedCopyAnchorToRemoteExecutor(t *testing.T) {
|
||||
var startRequest transcodenode.TranscodeStartRequest
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
|
||||
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
|
||||
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
|
||||
t.Errorf("decode remote start: %v", err)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
|
||||
case r.Method == http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
|
||||
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
|
||||
return 1085.501, 542, nil
|
||||
}
|
||||
plan := &playback.PlanV3{PlanID: "plan:remote-copy-anchor", Delivery: playback.DeliveryRemuxHLSV3, Timeline: playback.TimelineV3{SourceStartSeconds: 1086.2}}
|
||||
transport, transportErr := handler.prepareTransportV3(
|
||||
httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
&playback.Session{ID: "session-remote-copy-anchor", UserID: 7, ProfileID: "profile-1"},
|
||||
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", CodecVideo: "h264"},
|
||||
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TargetAudioCodec: "aac"},
|
||||
)
|
||||
if transportErr != nil {
|
||||
t.Fatalf("prepare remote copy transport: %v", transportErr)
|
||||
}
|
||||
defer transport.rollback()
|
||||
if startRequest.SeekSeconds != 1086.2 || startRequest.StreamOriginSeconds != 1085.501 ||
|
||||
!startRequest.CopySeekAnchorResolved || startRequest.StartSegmentNumber != 542 {
|
||||
t.Fatalf("remote copy timeline = %#v", startRequest)
|
||||
}
|
||||
if plan.Timeline.StreamOriginSeconds != 1085.501 || plan.Timeline.TimelineOffsetSeconds != 1085.501 ||
|
||||
math.Abs(plan.Timeline.PlayerStartSeconds-0.699) > 0.0001 {
|
||||
t.Fatalf("advertised copy timeline = %#v", plan.Timeline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
|
||||
var startRequest transcodenode.TranscodeStartRequest
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2261,7 +2474,11 @@ func TestPrepareLocalTransportV3ReturnsStableTerminalWhenFFmpegExitsBeforeReady(
|
||||
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
transport, transportErr := handler.prepareLocalTransportV3(request, &playback.Session{ID: "session-startup-failure", UserID: 7, ProfileID: "profile-1"}, file, result)
|
||||
timeline, timelineErr := handler.prepareTransportTimelineV3(request.Context(), &playback.Session{ID: "session-startup-failure"}, file, result)
|
||||
if timelineErr != nil {
|
||||
t.Fatalf("prepare timeline: %v", timelineErr)
|
||||
}
|
||||
transport, transportErr := handler.prepareLocalTransportV3(request, &playback.Session{ID: "session-startup-failure", UserID: 7, ProfileID: "profile-1"}, file, result, timeline)
|
||||
if transportErr == nil {
|
||||
transport.rollback()
|
||||
t.Fatal("failed ffmpeg startup returned a playable transport")
|
||||
@@ -2301,7 +2518,8 @@ func TestManifestStartupTimeoutWhileRunningIsPersistedIdempotently(t *testing.T)
|
||||
failure := manifestStartupTransportErrorV3(session.IsRunning(), waitErr)
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
request := v3HandlerStartRequest()
|
||||
response, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, "digest", request.FileID, request.FileID, failure)
|
||||
requestDigests := playbackStartRequestDigestsV3{current: "digest"}
|
||||
response, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, requestDigests, request.FileID, request.FileID, failure)
|
||||
if err != nil {
|
||||
t.Fatalf("start failure decision: %v", err)
|
||||
}
|
||||
@@ -2312,7 +2530,7 @@ func TestManifestStartupTimeoutWhileRunningIsPersistedIdempotently(t *testing.T)
|
||||
if err != nil || record.StartResponse.Terminal == nil || !record.StartResponse.Terminal.Retryable {
|
||||
t.Fatalf("retryable startup timeout attempt = %#v, err=%v", record, err)
|
||||
}
|
||||
replayed, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, "digest", request.FileID, request.FileID, failure)
|
||||
replayed, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, requestDigests, request.FileID, request.FileID, failure)
|
||||
if err != nil || replayed.Terminal == nil || replayed.Terminal.Reason != response.Terminal.Reason {
|
||||
t.Fatalf("retryable timeout replay = %#v, err=%v", replayed, err)
|
||||
}
|
||||
@@ -2325,12 +2543,12 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
|
||||
// locally seekable — sending them past the produced head instead of back
|
||||
// to the server. The runtime belongs on source.duration_seconds.
|
||||
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
|
||||
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
|
||||
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
|
||||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
|
||||
configureCopyRemuxTimelineV3(copyPlan, 16)
|
||||
if copyPlan.Timeline.StreamOriginSeconds != 16 || copyPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(copyPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || copyPlan.Timeline.CanSeekAnywhere ||
|
||||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 16 ||
|
||||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
|
||||
copyPlan.Timeline.SeekRestoration != "source_position" {
|
||||
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
|
||||
t.Fatalf("copy timeline=%#v", copyPlan.Timeline)
|
||||
}
|
||||
|
||||
encodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
|
||||
@@ -2532,6 +2750,12 @@ func v3HandlerFixtureFile(t *testing.T) *models.MediaFile {
|
||||
return &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie.mp4"), Container: "mp4", CodecVideo: "h264", CodecAudio: "aac", Resolution: "1080p", Bitrate: 8_000, AudioChannels: 2, Duration: 3600, VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo", Default: true}}}
|
||||
}
|
||||
|
||||
func stubCopySeekAnchorV3(handler *PlaybackHandler) {
|
||||
handler.copySeekAnchor = func(_ context.Context, _ string, _ string, requested float64, segmentDuration int) (float64, int, error) {
|
||||
return requested, computeStartSegment(requested, segmentDuration), nil
|
||||
}
|
||||
}
|
||||
|
||||
func v3HandlerStartRequest() playback.StartRequestV3 {
|
||||
return playback.StartRequestV3{ProtocolVersion: playback.ProtocolV3, ClientFeatures: []string{playback.FeaturePlaybackPlanV3}, FileID: 42, ProfileID: "profile-1", PlaybackAttemptID: "attempt-handler-0001", QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3, Capabilities: playback.ClientCodecCapabilitiesV3{VideoEvidence: playback.EvidenceExactV3, AudioEvidence: playback.EvidenceExactV3, CodecsVideo: []string{"h264"}, CodecsVideoHardware: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p", VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}}, ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, FormFactor: "tv", AppVersion: "test", Device: playback.DeviceContextV3{Platform: "android"}, Output: playback.OutputContextV3{OutputContextID: "route-1"}, Deliveries: map[string]playback.DeliveryCapabilityV3{playback.DeliveryClassOriginalHTTPV3: {Enabled: true, SupportedOnDevice: true, Subtitles: playback.DeliverySubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}}}}
|
||||
}
|
||||
@@ -2637,6 +2861,7 @@ func TestHandleReplanPlaybackV3TrackChangeStaysOnEffectiveAlternate(t *testing.T
|
||||
files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate}
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
|
||||
stubCopySeekAnchorV3(handler)
|
||||
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{source.ContentID: {source, alternate}}}
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "false"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
@@ -2683,6 +2908,7 @@ func TestHandleReplanPlaybackV3TrackChangeOperation(t *testing.T) {
|
||||
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
stubCopySeekAnchorV3(handler)
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
|
||||
@@ -30,6 +30,9 @@ type AccessFilter struct {
|
||||
SelectedFileID int
|
||||
UserID int
|
||||
ProfileID string
|
||||
// DeviceID identifies the requesting client for device-scoped setting
|
||||
// resolution. It does not participate in catalog access control.
|
||||
DeviceID string
|
||||
// NamePrefix, when non-empty, restricts results to items whose
|
||||
// LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the
|
||||
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
|
||||
|
||||
@@ -3105,6 +3105,7 @@ type audioPrefResolver struct {
|
||||
store userstore.UserStore
|
||||
valid bool
|
||||
profileID string
|
||||
deviceID string
|
||||
contentID string
|
||||
|
||||
profileDone bool
|
||||
@@ -3136,6 +3137,7 @@ func (s *DetailService) newAudioPrefResolver(ctx context.Context, filter AccessF
|
||||
r := &audioPrefResolver{
|
||||
svc: s,
|
||||
profileID: filter.ProfileID,
|
||||
deviceID: filter.DeviceID,
|
||||
contentID: audioPreferenceContentID,
|
||||
resolvedLang: map[int]string{},
|
||||
}
|
||||
@@ -3176,15 +3178,16 @@ func (r *audioPrefResolver) audioPreference(ctx context.Context) *playback.Audio
|
||||
|
||||
// audioLanguage resolves with every content identity in context. The language
|
||||
// preference lives in the canonical table at profile_series/profile_library/
|
||||
// profile scopes; the specialized audio row supplies only concrete track
|
||||
// identity. Caching by library keeps a multi-file item at one canonical read
|
||||
// per distinct folder rather than one read per file.
|
||||
// profile_device/profile scopes; the specialized audio row supplies only
|
||||
// concrete track identity. Caching by library keeps a multi-file item at one
|
||||
// canonical read per distinct folder rather than one read per file.
|
||||
func (r *audioPrefResolver) audioLanguage(ctx context.Context, libraryID int) string {
|
||||
if lang, ok := r.resolvedLang[libraryID]; ok {
|
||||
return lang
|
||||
}
|
||||
rc := settingsresolve.Context{
|
||||
ProfileID: r.profileID,
|
||||
DeviceID: r.deviceID,
|
||||
LibraryIDs: []int{libraryID},
|
||||
}
|
||||
if strings.TrimSpace(r.contentID) != "" {
|
||||
|
||||
@@ -204,6 +204,42 @@ func TestEffectiveAudioTrackIndex_PrefersSeriesAudioPreferenceOverLibraryAndProf
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAudioTrackIndex_ResolvesDeviceLanguageWithCanonicalPrecedence(t *testing.T) {
|
||||
store := newDetailTestStore(t)
|
||||
setProfileAudioLanguage(t, store, "en")
|
||||
setScopedAudioLanguageForDevice(t, store, settingscontract.ScopeProfileDevice, "", 0, "apple-tv", "ja")
|
||||
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileLibrary, "", 12, "es")
|
||||
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileSeries, "series-1", 0, "fr")
|
||||
|
||||
service := &DetailService{}
|
||||
service.SetUserStoreProvider(testDetailUserStoreProvider{store: store})
|
||||
file := &models.MediaFile{
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Language: "en", Default: true},
|
||||
{Language: "ja"},
|
||||
{Language: "es"},
|
||||
{Language: "fr"},
|
||||
},
|
||||
}
|
||||
|
||||
assertIndex := func(name, deviceID, contentID string, libraryID, want int) {
|
||||
t.Helper()
|
||||
file.MediaFolderID = libraryID
|
||||
if got := service.effectiveAudioTrackIndex(context.Background(), AccessFilter{
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
DeviceID: deviceID,
|
||||
}, contentID, file); got != want {
|
||||
t.Fatalf("%s: effectiveAudioTrackIndex() = %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
assertIndex("device override", "apple-tv", "movie-1", 99, 1)
|
||||
assertIndex("other device isolation", "iphone", "movie-1", 99, 0)
|
||||
assertIndex("library over device", "apple-tv", "movie-1", 12, 2)
|
||||
assertIndex("series over library and device", "apple-tv", "series-1", 12, 3)
|
||||
}
|
||||
|
||||
func TestBuildPlaybackInfo_SetsEffectiveAudioLanguageFromOriginalWhenTrackLanguageMissing(t *testing.T) {
|
||||
store := newDetailTestStore(t)
|
||||
language := playback.OriginalLanguageSentinel
|
||||
@@ -511,6 +547,19 @@ func setScopedAudioLanguage(
|
||||
seriesID string,
|
||||
libraryID int,
|
||||
language string,
|
||||
) {
|
||||
t.Helper()
|
||||
setScopedAudioLanguageForDevice(t, store, scope, seriesID, libraryID, "", language)
|
||||
}
|
||||
|
||||
func setScopedAudioLanguageForDevice(
|
||||
t *testing.T,
|
||||
store userstore.UserStore,
|
||||
scope settingscontract.Scope,
|
||||
seriesID string,
|
||||
libraryID int,
|
||||
deviceID string,
|
||||
language string,
|
||||
) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(language)
|
||||
@@ -523,6 +572,7 @@ func setScopedAudioLanguage(
|
||||
ProfileID: "profile-1",
|
||||
SeriesID: seriesID,
|
||||
LibraryID: libraryID,
|
||||
DeviceID: deviceID,
|
||||
}, encoded); err != nil {
|
||||
t.Fatalf("seeding %s: %v", settingskeys.PlaybackAudioLanguage, err)
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@ import (
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
cloudflareURLMode = "cloudflare_token"
|
||||
playbackSegmentRetentionSettingKey = "playback.segment_retention_seconds"
|
||||
chapterThumbnailSoftwareToneMapKey = "playback.chapter_thumbnail_software_tone_map_enabled"
|
||||
)
|
||||
const cloudflareURLMode = "cloudflare_token"
|
||||
const chapterThumbnailSoftwareToneMapKey = "playback.chapter_thumbnail_software_tone_map_enabled"
|
||||
|
||||
// adminSettingDefaults is the effective value shown by the Admin UI when no
|
||||
// row exists in server_settings. Keep these values aligned with the runtime
|
||||
@@ -54,7 +51,6 @@ var adminSettingDefaults = map[string]string{
|
||||
|
||||
"playback.ffmpeg_path": "/usr/lib/jellyfin-ffmpeg/ffmpeg",
|
||||
"playback.transcode_dir": DefaultTranscodeDir,
|
||||
playbackSegmentRetentionSettingKey: "600",
|
||||
"playback.hw_accel": "auto",
|
||||
"playback.transcode_enabled": "true",
|
||||
"playback.local_transcode_fallback": "true",
|
||||
@@ -305,16 +301,6 @@ func NormalizeAdminSetting(key, raw string) (string, error) {
|
||||
return normalizeAdminInt(key, value, 1, 99)
|
||||
case "transcode_throttle_seconds":
|
||||
return normalizeAdminInt(key, value, 60, 86400)
|
||||
case playbackSegmentRetentionSettingKey:
|
||||
normalized, err := normalizeAdminInt(key, value, 0, 86400)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
seconds, _ := strconv.Atoi(normalized)
|
||||
if seconds != 0 && seconds < 120 {
|
||||
return "", fmt.Errorf("%s must be 0 or between 120 and 86400", key)
|
||||
}
|
||||
return normalized, nil
|
||||
case "ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs":
|
||||
return normalizeAdminInt(key, value, 1, 1024)
|
||||
case "subtitle_ai.batch_size":
|
||||
|
||||
@@ -225,7 +225,6 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
|
||||
{key: "theme.catalog_url", value: "http://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json"},
|
||||
{key: "theme.catalog_url", value: "https://example.com/catalog.json"},
|
||||
{key: "redis.url", value: "not-a-url"},
|
||||
{key: "playback.segment_retention_seconds", value: "119"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
@@ -236,20 +235,6 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdminSettingAcceptsSegmentRetentionBounds(t *testing.T) {
|
||||
for _, value := range []string{"0", "120", "86400"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
got, err := NormalizeAdminSetting("playback.segment_retention_seconds", value)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeAdminSetting: %v", err)
|
||||
}
|
||||
if got != value {
|
||||
t.Fatalf("normalized retention = %q, want %q", got, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAdminSettingAcceptsApprovedThemeCatalogURL(t *testing.T) {
|
||||
got, err := NormalizeAdminSetting(
|
||||
"theme.catalog_url",
|
||||
|
||||
@@ -157,10 +157,9 @@ func (c MatcherConfig) TVSeriesRootQueueEnabled() bool {
|
||||
|
||||
// PlaybackConfig holds transcoding and playback settings.
|
||||
type PlaybackConfig struct {
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TranscodeDir string `yaml:"transcode_dir"`
|
||||
SegmentRetentionSeconds int `yaml:"segment_retention_seconds"`
|
||||
HWAccel string `yaml:"hw_accel"`
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TranscodeDir string `yaml:"transcode_dir"`
|
||||
HWAccel string `yaml:"hw_accel"`
|
||||
// HWDevice is the GPU render device for hardware transcodes. A single
|
||||
// path pins every GPU workload to that device; a comma-separated list
|
||||
// (e.g. "/dev/dri/renderD128,/dev/dri/renderD129") balances workloads
|
||||
@@ -480,7 +479,6 @@ func setDefaults() *configRaw {
|
||||
Playback: PlaybackConfig{
|
||||
FFmpegPath: "/usr/lib/jellyfin-ffmpeg/ffmpeg",
|
||||
TranscodeDir: DefaultTranscodeDir,
|
||||
SegmentRetentionSeconds: 600,
|
||||
HWAccel: "auto",
|
||||
ChapterThumbnailWorkers: 1,
|
||||
ChapterThumbnailExecution: "local",
|
||||
|
||||
@@ -317,14 +317,6 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
// Playback
|
||||
cfg.Playback.FFmpegPath = stringOr(m, "playback.ffmpeg_path", "/usr/lib/jellyfin-ffmpeg/ffmpeg")
|
||||
cfg.Playback.TranscodeDir = stringOr(m, "playback.transcode_dir", DefaultTranscodeDir)
|
||||
segmentRetentionSeconds, err := intOr(m, playbackSegmentRetentionSettingKey, 600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if segmentRetentionSeconds != 0 && (segmentRetentionSeconds < 120 || segmentRetentionSeconds > 86400) {
|
||||
return nil, fmt.Errorf("%s must be 0 or between 120 and 86400", playbackSegmentRetentionSettingKey)
|
||||
}
|
||||
cfg.Playback.SegmentRetentionSeconds = segmentRetentionSeconds
|
||||
cfg.Playback.HWAccel = stringOr(m, "playback.hw_accel", "auto")
|
||||
cfg.Playback.HWDevice = stringOr(m, "playback.hw_device", "")
|
||||
chapterThumbnailWorkers, err := intOr(m, "playback.chapter_thumbnail_workers", 1)
|
||||
|
||||
@@ -35,17 +35,6 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBRejectsInvalidSegmentRetention(t *testing.T) {
|
||||
for _, value := range []string{"-1", "119", "86401"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
_, err := LoadFromDB(map[string]string{playbackSegmentRetentionSettingKey: value})
|
||||
if err == nil || !strings.Contains(err.Error(), playbackSegmentRetentionSettingKey) {
|
||||
t.Fatalf("LoadFromDB() error = %v, want retention bounds error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromDBJellyfinWebEnabledDefaultsToTrue(t *testing.T) {
|
||||
cfg, err := LoadFromDB(map[string]string{})
|
||||
if err != nil {
|
||||
@@ -197,16 +186,6 @@ func TestYAMLToSettingsMapJellyfinCompatEnabledDefaultsToLegacyListener(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLToSettingsMapPreservesExplicitlyDisabledSegmentRetention(t *testing.T) {
|
||||
m := yamlSettingsMapFromString(t, `
|
||||
playback:
|
||||
segment_retention_seconds: 0
|
||||
`)
|
||||
if got := m[playbackSegmentRetentionSettingKey]; got != "0" {
|
||||
t.Fatalf("segment retention = %q, want explicit disable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func yamlSettingsMapFromString(t *testing.T, body string) map[string]string {
|
||||
t.Helper()
|
||||
path := t.TempDir() + "/silo.yaml"
|
||||
|
||||
@@ -209,7 +209,6 @@ func YAMLToSettingsMap(path string) (map[string]string, error) {
|
||||
// Playback
|
||||
setIfNonEmpty(m, "playback.ffmpeg_path", raw.Playback.FFmpegPath)
|
||||
setIfNonEmpty(m, "playback.transcode_dir", raw.Playback.TranscodeDir)
|
||||
m[playbackSegmentRetentionSettingKey] = strconv.Itoa(raw.Playback.SegmentRetentionSeconds)
|
||||
setIfNonEmpty(m, "playback.hw_accel", raw.Playback.HWAccel)
|
||||
if raw.Playback.ChapterThumbnailWorkers != 0 {
|
||||
m["playback.chapter_thumbnail_workers"] = strconv.Itoa(raw.Playback.ChapterThumbnailWorkers)
|
||||
|
||||
@@ -12,7 +12,6 @@ package httpstream
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -172,26 +171,6 @@ func (s *RollingDeadlineWriter) BytesWritten() int64 {
|
||||
return s.bytesWritten
|
||||
}
|
||||
|
||||
// CompletedFullResponse reports whether the complete representation was
|
||||
// accepted by the transport without a write error. The request context is not
|
||||
// consulted because net/http may cancel it after the response reaches the
|
||||
// client but before the handler performs post-response accounting. ServeContent
|
||||
// returns 206 for a whole-file Range request such as "bytes=0-", so byte count
|
||||
// alone is not enough to distinguish that transfer from a partial range.
|
||||
func (s *RollingDeadlineWriter) CompletedFullResponse(fullSize int64) bool {
|
||||
if fullSize <= 0 || s.bytesWritten != fullSize || s.firstWriteErr != nil {
|
||||
return false
|
||||
}
|
||||
switch s.statusCode {
|
||||
case http.StatusOK:
|
||||
return true
|
||||
case http.StatusPartialContent:
|
||||
return s.Header().Get("Content-Range") == fmt.Sprintf("bytes 0-%d/%d", fullSize-1, fullSize)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome classifies the first write failure, or a canceled request when no
|
||||
// write failure was surfaced by the transport.
|
||||
func (s *RollingDeadlineWriter) Outcome(ctx context.Context) StreamOutcome {
|
||||
|
||||
@@ -271,42 +271,6 @@ func TestServeContentReadFromOutcomeCompletedAndCounted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedFullResponse(t *testing.T) {
|
||||
const body = "complete segment"
|
||||
tests := []struct {
|
||||
name string
|
||||
rangeHeader string
|
||||
method string
|
||||
cancelAfterWrite bool
|
||||
want bool
|
||||
}{
|
||||
{name: "ordinary get", method: http.MethodGet, want: true},
|
||||
{name: "completed get with canceled context", method: http.MethodGet, cancelAfterWrite: true, want: true},
|
||||
{name: "open ended full range", method: http.MethodGet, rangeHeader: "bytes=0-", want: true},
|
||||
{name: "explicit full range", method: http.MethodGet, rangeHeader: "bytes=0-15", want: true},
|
||||
{name: "partial range", method: http.MethodGet, rangeHeader: "bytes=1-", want: false},
|
||||
{name: "head", method: http.MethodHead, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := newRollingDeadlineWriter(rr, time.Second, 0)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
req := httptest.NewRequest(tt.method, "/segment.ts", nil).WithContext(ctx)
|
||||
req.Header.Set("Range", tt.rangeHeader)
|
||||
http.ServeContent(sw, req, "segment.ts", time.Time{}, strings.NewReader(body))
|
||||
if tt.cancelAfterWrite {
|
||||
cancel()
|
||||
}
|
||||
if got := sw.CompletedFullResponse(int64(len(body))); got != tt.want {
|
||||
t.Fatalf("CompletedFullResponse = %v, want %v (status=%d range=%q bytes=%d)",
|
||||
got, tt.want, sw.StatusCode(), rr.Header().Get("Content-Range"), sw.BytesWritten())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFromPreservesCompletion exercises the io.ReaderFrom path used by
|
||||
// http.ServeContent (sendfile) under a server WriteTimeout shorter than the
|
||||
// transfer, with a source large enough to require multiple bounded slices.
|
||||
|
||||
@@ -184,7 +184,6 @@ type PlaybackHandler struct {
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
TranscodeDir string
|
||||
SegmentRetentionSeconds func() int
|
||||
// tm is the shared transcode-session lifecycle (live map, reconstruct) — the
|
||||
// same type the native handler uses, so jellycompat gets the reconstruct cap
|
||||
// and node-affinity rule for free. The reconstruction recipe is carried in the
|
||||
@@ -269,40 +268,36 @@ func NewPlaybackHandler(
|
||||
transcodeDir := filepath.Join(os.TempDir(), "silo-transcode")
|
||||
ffmpegPath := ""
|
||||
hwAccel := ""
|
||||
segmentRetentionSeconds := 600
|
||||
if cfg != nil {
|
||||
if cfg.Playback.TranscodeDir != "" {
|
||||
transcodeDir = cfg.Playback.TranscodeDir
|
||||
}
|
||||
ffmpegPath = cfg.Playback.FFmpegPath
|
||||
hwAccel = cfg.Playback.HWAccel
|
||||
segmentRetentionSeconds = cfg.Playback.SegmentRetentionSeconds
|
||||
}
|
||||
|
||||
h := &PlaybackHandler{
|
||||
cfg: cfg,
|
||||
content: content,
|
||||
codec: codec,
|
||||
deviceProfiles: deviceProfiles,
|
||||
playbackStore: playbackStore,
|
||||
sessionMgr: sessionMgr,
|
||||
fileResolver: fileResolver,
|
||||
storeProvider: storeProvider,
|
||||
FFmpegPath: ffmpegPath,
|
||||
HWAccel: hwAccel,
|
||||
TranscodeDir: transcodeDir,
|
||||
SegmentRetentionSeconds: func() int { return segmentRetentionSeconds },
|
||||
tm: playback.NewTranscodeManager(),
|
||||
cfg: cfg,
|
||||
content: content,
|
||||
codec: codec,
|
||||
deviceProfiles: deviceProfiles,
|
||||
playbackStore: playbackStore,
|
||||
sessionMgr: sessionMgr,
|
||||
fileResolver: fileResolver,
|
||||
storeProvider: storeProvider,
|
||||
FFmpegPath: ffmpegPath,
|
||||
HWAccel: hwAccel,
|
||||
TranscodeDir: transcodeDir,
|
||||
tm: playback.NewTranscodeManager(),
|
||||
}
|
||||
// Wire the shared transcode manager with closures so it reads the handler's
|
||||
// (late-set) JWTSecret lazily, matching the native handler.
|
||||
h.tm.JWTSecretFn = func() string { return h.JWTSecret }
|
||||
h.tm.Config = func() playback.TranscodeRuntimeConfig {
|
||||
return playback.TranscodeRuntimeConfig{
|
||||
TranscodeDir: h.TranscodeDir,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
SegmentRetentionSeconds: h.segmentRetentionSeconds(),
|
||||
TranscodeDir: h.TranscodeDir,
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
}
|
||||
}
|
||||
if reg, ok := sessionMgr.(interface {
|
||||
@@ -342,13 +337,6 @@ func NewPlaybackHandler(
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) segmentRetentionSeconds() int {
|
||||
if h.SegmentRetentionSeconds != nil {
|
||||
return h.SegmentRetentionSeconds()
|
||||
}
|
||||
return 600
|
||||
}
|
||||
|
||||
// CleanupOrphanedTranscodes removes stale per-session transcode dirs, sparing
|
||||
// those whose recipe card still exists. Delegates to the shared manager.
|
||||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
)
|
||||
|
||||
func TestPlaybackHandlerReadsLiveSegmentRetention(t *testing.T) {
|
||||
cfg := &config.Config{Playback: config.PlaybackConfig{SegmentRetentionSeconds: 300}}
|
||||
handler := NewPlaybackHandler(cfg, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
|
||||
t.Fatalf("configured segment retention = %d, want 300", got)
|
||||
}
|
||||
liveRetention := 300
|
||||
handler.SegmentRetentionSeconds = func() int { return liveRetention }
|
||||
liveRetention = 120
|
||||
if got := handler.tm.Config().SegmentRetentionSeconds; got != 120 {
|
||||
t.Fatalf("reloaded segment retention = %d, want 120", got)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,13 @@ var (
|
||||
|
||||
var jsonNULCodePoint = []byte(`\u0000`)
|
||||
|
||||
// compatNegotiationLockSQL takes token, device ID, and route item ID as
|
||||
// separate text parameters and joins them with chr(31) inside PostgreSQL.
|
||||
const compatNegotiationLockSQL = `
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtextextended($1 || chr(31) || $2 || chr(31) || $3, 0)
|
||||
)`
|
||||
|
||||
// marshalPlaybackSession removes NUL code points from every nested string in
|
||||
// the JSON document. PostgreSQL JSONB rejects U+0000 even when Go's encoder
|
||||
// represents it as \u0000. Escaped literal text (\\u0000) remains unchanged.
|
||||
@@ -218,8 +225,14 @@ func (d *DurableCompatPlaybackStore) replaceUnstartedNegotiation(
|
||||
|
||||
var removed []string
|
||||
if session.CompatToken != "" && session.ClientDeviceID != "" && session.RouteItemID != "" {
|
||||
scope := session.CompatToken + "\x00" + session.ClientDeviceID + "\x00" + session.RouteItemID
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, scope); err != nil {
|
||||
// Join the lock key inside PostgreSQL. A Go-side NUL delimiter cannot
|
||||
// be bound as text: PostgreSQL rejects U+0000 (SQLSTATE 22021), the
|
||||
// transaction aborts, and the negotiated session stays cache-only.
|
||||
if _, err := tx.Exec(ctx, compatNegotiationLockSQL,
|
||||
stripCompatNUL(session.CompatToken),
|
||||
stripCompatNUL(session.ClientDeviceID),
|
||||
stripCompatNUL(session.RouteItemID),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,15 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestCompatNegotiationLockSQLDoesNotBindNULDelimiter(t *testing.T) {
|
||||
if strings.Contains(compatNegotiationLockSQL, "\x00") {
|
||||
t.Fatal("advisory lock SQL must not embed a NUL delimiter")
|
||||
}
|
||||
if !strings.Contains(compatNegotiationLockSQL, "chr(31)") {
|
||||
t.Fatal("advisory lock SQL should join key parts with chr(31) inside PostgreSQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalPlaybackSessionStripsNestedNUL(t *testing.T) {
|
||||
wantLiteral := `literal\u0000text`
|
||||
data, err := marshalPlaybackSession(PlaybackSession{
|
||||
|
||||
@@ -103,13 +103,6 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
}
|
||||
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
|
||||
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
|
||||
startupSegmentRetention := playbackHandler.SegmentRetentionSeconds
|
||||
playbackHandler.SegmentRetentionSeconds = func() int {
|
||||
if cfg := deps.CurrentConfig(); cfg != nil {
|
||||
return cfg.Playback.SegmentRetentionSeconds
|
||||
}
|
||||
return startupSegmentRetention()
|
||||
}
|
||||
if deps.DB != nil {
|
||||
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/httpstream"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
@@ -419,8 +418,8 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
segmentName := name + "." + ext
|
||||
segmentLease, err := transcodeSession.OpenSegment(segmentName)
|
||||
segmentFile := name + "." + ext
|
||||
segmentPath, err := transcodeSession.GetSegment(segmentFile)
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||||
segNum, parseErr := playback.ParseSegmentNumber(name)
|
||||
if parseErr == nil {
|
||||
@@ -431,7 +430,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds()
|
||||
}
|
||||
slog.InfoContext(r.Context(), "transcode segment missing", "component", "jellycompat",
|
||||
"segment", segmentName,
|
||||
"segment", segmentFile,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||||
@@ -445,7 +444,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
)
|
||||
if decision.Wait {
|
||||
slog.InfoContext(r.Context(), "transcode segment wait", "component", "jellycompat",
|
||||
"segment", segmentName,
|
||||
"segment", segmentFile,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||||
@@ -457,10 +456,10 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
"session", playSession.UpstreamSessionID,
|
||||
"playback_session_id", playSession.UpstreamSessionID,
|
||||
)
|
||||
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, decision.WaitTimeout)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentFile, decision.WaitTimeout)
|
||||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||||
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "jellycompat",
|
||||
"segment", segmentName,
|
||||
"segment", segmentFile,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||||
@@ -480,7 +479,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) {
|
||||
slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "jellycompat",
|
||||
"error", seekErr,
|
||||
"segment", segmentName,
|
||||
"segment", segmentFile,
|
||||
"play_session", playSessionID,
|
||||
"session", playSession.UpstreamSessionID,
|
||||
"playback_session_id", playSession.UpstreamSessionID,
|
||||
@@ -499,7 +498,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
if ok {
|
||||
slog.InfoContext(r.Context(), "transcode seek restart", "component", "jellycompat",
|
||||
"segment", segmentName,
|
||||
"segment", segmentFile,
|
||||
"requested_segment", segNum,
|
||||
"produced_head", decision.Progress.ProducedHead,
|
||||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||||
@@ -520,14 +519,14 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
seekSeconds,
|
||||
segNum,
|
||||
); restartErr == nil {
|
||||
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 30*time.Second)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentFile, 30*time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if transcodeSession.IsRunning() {
|
||||
// Non-numbered segment (e.g. init.mp4 for fMP4 HLS).
|
||||
// Wait briefly — the init segment is written almost immediately.
|
||||
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 10*time.Second)
|
||||
segmentPath, err = transcodeSession.WaitForSegment(segmentFile, 10*time.Second)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -536,15 +535,11 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = segmentLease.Close() }()
|
||||
sw := httpstream.NewRollingDeadlineWriter(w)
|
||||
http.ServeContent(sw, r, segmentLease.Info.Name(), segmentLease.Info.ModTime(), segmentLease.File)
|
||||
if r.Method == http.MethodGet &&
|
||||
sw.CompletedFullResponse(segmentLease.Info.Size()) {
|
||||
if segNum, parseErr := playback.ParseSegmentNumber(name); parseErr == nil {
|
||||
transcodeSession.ReportSegmentDownloadedForGeneration(segNum, segmentLease.Generation)
|
||||
}
|
||||
if segNum, parseErr := playback.ParseSegmentNumber(name); parseErr == nil {
|
||||
transcodeSession.ReportSegmentDownloaded(segNum)
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, segmentPath)
|
||||
}
|
||||
|
||||
// hlsSegmentErrorResponse maps a segment-retrieval error to a Jellyfin-faithful
|
||||
@@ -1674,7 +1669,6 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
TotalDuration: float64(source.Version.Duration),
|
||||
FastStart: true,
|
||||
}
|
||||
opts.SegmentRetentionSeconds = h.segmentRetentionSeconds()
|
||||
if source.TranscodeAudio {
|
||||
opts.TargetCodecVideo = "copy"
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ func resolveCopySeekAnchor(
|
||||
interval := strconv.FormatFloat(requestedSeekSeconds, 'f', 6, 64) + "%+0.001"
|
||||
cmd := exec.CommandContext(ctx, ffprobePathFromFFmpeg(ffmpegPath),
|
||||
"-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
// Match the remux transport's 0:V:0 map. Uppercase V excludes attached
|
||||
// pictures, thumbnails, and cover art from the video stream ordinal.
|
||||
"-select_streams", "V:0",
|
||||
"-read_intervals", interval,
|
||||
"-show_entries", "packet=pts_time,dts_time,flags",
|
||||
"-of", "json",
|
||||
|
||||
@@ -17,10 +17,12 @@ func TestResolveCopySeekAnchorUsesKeyPacketTimestamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ffmpegPath := filepath.Join(dir, "ffmpeg")
|
||||
ffprobePath := filepath.Join(dir, "ffprobe")
|
||||
argsPath := filepath.Join(dir, "ffprobe-args")
|
||||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
probe := `#!/bin/sh
|
||||
printf '%s\n' "$@" > "` + argsPath + `"
|
||||
printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}]}'
|
||||
`
|
||||
if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil {
|
||||
@@ -34,6 +36,13 @@ printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}
|
||||
if anchor != 14.5 || segment != 7 {
|
||||
t.Fatalf("resolved anchor = %v, segment = %d; want 14.5, 7", anchor, segment)
|
||||
}
|
||||
args, err := os.ReadFile(argsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ffprobe args: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(args), "-select_streams\nV:0\n") {
|
||||
t.Fatalf("ffprobe did not select the remux video stream:\n%s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCopySeekAnchorCoalescesMatchingConcurrentProbes(t *testing.T) {
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
segmentPruneHysteresis = 5
|
||||
segmentPruneBatchSize = 512
|
||||
segmentPruneRetryDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type segmentPruneCandidate struct {
|
||||
number int
|
||||
path string
|
||||
}
|
||||
|
||||
// scheduleSegmentPruneLocked starts one asynchronous prune pass after the
|
||||
// client has advanced far enough to make useful work. The caller must hold
|
||||
// s.mu. Segment retention is measured in media time so custom HLS segment
|
||||
// durations retain the same back-seek window.
|
||||
func (s *TranscodeSession) scheduleSegmentPruneLocked() {
|
||||
retentionSeconds := s.opts.SegmentRetentionSeconds
|
||||
if retentionSeconds <= 0 || s.segmentPruneRunning || s.restarting {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.EqualFold(s.opts.TargetCodecVideo, "copy") {
|
||||
// Copy-mode fragments follow source keyframes, so their real media-time
|
||||
// floor is resolved asynchronously from EXTINF durations below. Use the
|
||||
// download high-water mark only to avoid reparsing the manifest for every
|
||||
// segment response.
|
||||
if s.lastCompletedSegment-s.lastPruneHighWater < segmentPruneHysteresis {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
segmentDuration := s.opts.SegmentDuration
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = defaultSegmentDuration
|
||||
}
|
||||
retainedSegments := (retentionSeconds + segmentDuration - 1) / segmentDuration
|
||||
floor := s.lastCompletedSegment - retainedSegments
|
||||
beforeStartReady := s.pruneBeforeStart && floor == s.opts.StartSegmentNumber
|
||||
if floor < s.opts.StartSegmentNumber || (!beforeStartReady && floor == s.opts.StartSegmentNumber) || floor-s.lastPruneFloor < segmentPruneHysteresis {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.segmentPruneRunning = true
|
||||
generation := s.segmentGeneration
|
||||
downloadedThrough := s.lastCompletedSegment
|
||||
s.lastPruneHighWater = downloadedThrough
|
||||
go s.pruneDownloadedSegments(generation, downloadedThrough, false)
|
||||
}
|
||||
|
||||
// pruneDownloadedSegments removes completed media files strictly behind floor.
|
||||
// It visits each newly expired segment number at most once in bounded batches,
|
||||
// avoiding repeated full-directory scans when FFmpeg has generated far ahead
|
||||
// of the client. The current process's startup window remains present so real
|
||||
// manifest reloads cannot get stuck behind startupFilesReady after cleanup.
|
||||
func (s *TranscodeSession) pruneDownloadedSegments(generation uint64, downloadedThrough int, continuation bool) {
|
||||
started := time.Now()
|
||||
s.mu.Lock()
|
||||
if generation != s.segmentGeneration || s.restarting {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
opts := s.opts
|
||||
outputDir := s.outputDir
|
||||
fromFloor := s.lastPruneFloor
|
||||
pruneBeforeStart := s.pruneBeforeStart
|
||||
s.mu.Unlock()
|
||||
|
||||
floor, complete, err := segmentRetentionFloor(outputDir, opts, downloadedThrough)
|
||||
if err != nil {
|
||||
slog.Warn("resolve transcode segment retention floor", "component", "playback", "error", err, "session", opts.SessionID, "playback_session_id", opts.SessionID)
|
||||
s.finishSegmentPrune(generation, fromFloor, fromFloor, downloadedThrough, segmentPruneRetryDelay)
|
||||
return
|
||||
}
|
||||
beforeStartReady := pruneBeforeStart && floor == opts.StartSegmentNumber
|
||||
if !complete || floor < opts.StartSegmentNumber || (!beforeStartReady && floor == opts.StartSegmentNumber) || (!continuation && floor-fromFloor < segmentPruneHysteresis) {
|
||||
s.finishSegmentPruneAttempt(generation)
|
||||
return
|
||||
}
|
||||
|
||||
segmentDuration := opts.SegmentDuration
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = defaultSegmentDuration
|
||||
}
|
||||
freshGuard := 2*time.Duration(segmentDuration)*time.Second + 30*time.Second
|
||||
startupEnd := opts.StartSegmentNumber + startupSegmentRequirement(opts)
|
||||
targetFloor := floor
|
||||
fromSegment := max(fromFloor, startupEnd)
|
||||
if pruneBeforeStart {
|
||||
// A forward restart intentionally keeps the previous generation's back
|
||||
// buffer until the replacement has produced its own. Retire that older
|
||||
// range first, including its startup files, then resume normal pruning
|
||||
// without touching the replacement process's startup window. The old
|
||||
// range can be very sparse after a long seek, so enumerate actual files
|
||||
// below the new start rather than walking every intervening number.
|
||||
targetFloor = min(floor, opts.StartSegmentNumber)
|
||||
}
|
||||
if !pruneBeforeStart && fromSegment >= targetFloor {
|
||||
s.finishSegmentPrune(generation, targetFloor, targetFloor, downloadedThrough, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(outputDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.finishSegmentPrune(generation, targetFloor, targetFloor, downloadedThrough, 0)
|
||||
return
|
||||
}
|
||||
slog.Warn("stat transcode directory for pruning", "component", "playback", "error", err, "session", opts.SessionID, "playback_session_id", opts.SessionID)
|
||||
retryFloor := fromSegment
|
||||
if pruneBeforeStart {
|
||||
retryFloor = fromFloor
|
||||
}
|
||||
s.finishSegmentPrune(generation, retryFloor, targetFloor, downloadedThrough, segmentPruneRetryDelay)
|
||||
return
|
||||
}
|
||||
|
||||
var candidates []segmentPruneCandidate
|
||||
moreCandidates := false
|
||||
if pruneBeforeStart {
|
||||
entries, readErr := os.ReadDir(outputDir)
|
||||
if readErr != nil {
|
||||
slog.Warn("read transcode directory for preserved segment pruning", "component", "playback", "error", readErr, "session", opts.SessionID, "playback_session_id", opts.SessionID)
|
||||
s.finishSegmentPrune(generation, fromFloor, targetFloor, downloadedThrough, segmentPruneRetryDelay)
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
segment, parseErr := ParseSegmentNumber(entry.Name())
|
||||
if entry.IsDir() || parseErr != nil || segment >= targetFloor || entry.Name() != segmentFilename(segment, opts) {
|
||||
continue
|
||||
}
|
||||
if len(candidates) == segmentPruneBatchSize {
|
||||
moreCandidates = true
|
||||
break
|
||||
}
|
||||
candidates = append(candidates, segmentPruneCandidate{
|
||||
number: segment,
|
||||
path: filepath.Join(outputDir, entry.Name()),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toSegment := min(targetFloor, fromSegment+segmentPruneBatchSize)
|
||||
for segment := fromSegment; segment < toSegment; segment++ {
|
||||
candidates = append(candidates, segmentPruneCandidate{
|
||||
number: segment,
|
||||
path: filepath.Join(outputDir, segmentFilename(segment, opts)),
|
||||
})
|
||||
}
|
||||
moreCandidates = toSegment < targetFloor
|
||||
}
|
||||
if len(candidates) == 0 && !moreCandidates {
|
||||
s.finishSegmentPrune(generation, targetFloor, targetFloor, downloadedThrough, 0)
|
||||
return
|
||||
}
|
||||
|
||||
processedFloor := targetFloor
|
||||
if !pruneBeforeStart {
|
||||
processedFloor = candidates[len(candidates)-1].number + 1
|
||||
}
|
||||
var retryAfter time.Duration
|
||||
removed := 0
|
||||
var freedBytes int64
|
||||
for _, candidate := range candidates {
|
||||
info, err := os.Stat(candidate.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if pruneBeforeStart {
|
||||
retryAfter = max(retryAfter, segmentPruneRetryDelay)
|
||||
} else {
|
||||
processedFloor, retryAfter = earlierPruneRetry(processedFloor, retryAfter, candidate.number, segmentPruneRetryDelay)
|
||||
}
|
||||
slog.Warn("stat downloaded transcode segment", "component", "playback", "error", err, "segment", candidate.number, "session", opts.SessionID, "playback_session_id", opts.SessionID)
|
||||
continue
|
||||
}
|
||||
freshUntil := info.ModTime().Add(freshGuard)
|
||||
if delay := time.Until(freshUntil); delay > 0 {
|
||||
if pruneBeforeStart {
|
||||
retryAfter = max(retryAfter, delay)
|
||||
} else {
|
||||
processedFloor, retryAfter = earlierPruneRetry(processedFloor, retryAfter, candidate.number, delay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Serialize the unlink with restart and shutdown generation changes.
|
||||
// Once either advances the generation, this pass cannot delete files
|
||||
// generated for a replacement timeline or session object.
|
||||
s.mu.Lock()
|
||||
if generation != s.segmentGeneration || s.restarting {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
removeErr := os.Remove(candidate.path)
|
||||
s.mu.Unlock()
|
||||
if removeErr != nil {
|
||||
if !errors.Is(removeErr, os.ErrNotExist) {
|
||||
if pruneBeforeStart {
|
||||
retryAfter = max(retryAfter, segmentPruneRetryDelay)
|
||||
} else {
|
||||
processedFloor, retryAfter = earlierPruneRetry(processedFloor, retryAfter, candidate.number, segmentPruneRetryDelay)
|
||||
}
|
||||
slog.Warn("remove downloaded transcode segment", "component", "playback", "error", removeErr, "segment", candidate.number, "session", opts.SessionID, "playback_session_id", opts.SessionID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
freedBytes += info.Size()
|
||||
}
|
||||
if pruneBeforeStart && (moreCandidates || retryAfter > 0) {
|
||||
processedFloor = fromFloor
|
||||
}
|
||||
|
||||
s.finishSegmentPrune(generation, processedFloor, targetFloor, downloadedThrough, retryAfter)
|
||||
if removed > 0 {
|
||||
slog.Info("pruned downloaded transcode segments",
|
||||
"component", "playback",
|
||||
"count", removed,
|
||||
"freed_bytes", freedBytes,
|
||||
"floor_segment", processedFloor,
|
||||
"duration_ms", time.Since(started).Milliseconds(),
|
||||
"session", opts.SessionID,
|
||||
"playback_session_id", opts.SessionID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// segmentRetentionFloor returns the first segment that must remain to preserve
|
||||
// the configured media-time window behind downloadedThrough. Encoded HLS uses
|
||||
// fixed-duration fragments. Copy HLS follows source keyframes, so its floor is
|
||||
// derived from the current manifest's actual EXTINF durations. complete is
|
||||
// false when the manifest does not yet cover the full requested back buffer.
|
||||
func segmentRetentionFloor(outputDir string, opts TranscodeOpts, downloadedThrough int) (floor int, complete bool, err error) {
|
||||
retentionSeconds := opts.SegmentRetentionSeconds
|
||||
if retentionSeconds <= 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
if !strings.EqualFold(opts.TargetCodecVideo, "copy") {
|
||||
segmentDuration := opts.SegmentDuration
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = defaultSegmentDuration
|
||||
}
|
||||
retainedSegments := (retentionSeconds + segmentDuration - 1) / segmentDuration
|
||||
return downloadedThrough - retainedSegments, true, nil
|
||||
}
|
||||
|
||||
manifest, err := os.ReadFile(filepath.Join(outputDir, "stream.m3u8"))
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("read copy manifest: %w", err)
|
||||
}
|
||||
timeline, err := parseManifestTimeline(manifest)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("parse copy manifest: %w", err)
|
||||
}
|
||||
|
||||
downloadedIndex := -1
|
||||
for i, entry := range timeline.entries {
|
||||
if entry.duration <= 0 {
|
||||
return 0, false, fmt.Errorf("copy segment %d has non-positive duration %.6f", entry.number, entry.duration)
|
||||
}
|
||||
if entry.number == downloadedThrough {
|
||||
downloadedIndex = i
|
||||
}
|
||||
}
|
||||
if downloadedIndex < 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
retainedSeconds := 0.0
|
||||
for i := downloadedIndex - 1; i >= 0; i-- {
|
||||
floor = timeline.entries[i].number
|
||||
retainedSeconds += timeline.entries[i].duration
|
||||
if retainedSeconds >= float64(retentionSeconds) {
|
||||
return floor, true, nil
|
||||
}
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func earlierPruneRetry(currentFloor int, currentDelay time.Duration, segment int, delay time.Duration) (int, time.Duration) {
|
||||
if segment < currentFloor {
|
||||
return segment, max(delay, time.Millisecond)
|
||||
}
|
||||
return currentFloor, currentDelay
|
||||
}
|
||||
|
||||
func (s *TranscodeSession) finishSegmentPruneAttempt(generation uint64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if generation != s.segmentGeneration {
|
||||
return
|
||||
}
|
||||
s.segmentPruneRunning = false
|
||||
s.scheduleSegmentPruneLocked()
|
||||
}
|
||||
|
||||
func (s *TranscodeSession) finishSegmentPrune(
|
||||
generation uint64,
|
||||
processedFloor int,
|
||||
targetFloor int,
|
||||
downloadedThrough int,
|
||||
retryAfter time.Duration,
|
||||
) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if generation != s.segmentGeneration {
|
||||
return
|
||||
}
|
||||
if processedFloor > s.lastPruneFloor {
|
||||
s.lastPruneFloor = processedFloor
|
||||
}
|
||||
if s.pruneBeforeStart && s.lastPruneFloor >= s.opts.StartSegmentNumber {
|
||||
s.pruneBeforeStart = false
|
||||
}
|
||||
if retryAfter > 0 {
|
||||
time.AfterFunc(retryAfter, func() {
|
||||
s.mu.Lock()
|
||||
if generation != s.segmentGeneration || s.restarting {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
go s.pruneDownloadedSegments(generation, downloadedThrough, true)
|
||||
})
|
||||
return
|
||||
}
|
||||
if processedFloor < targetFloor {
|
||||
go s.pruneDownloadedSegments(generation, downloadedThrough, true)
|
||||
return
|
||||
}
|
||||
s.segmentPruneRunning = false
|
||||
s.scheduleSegmentPruneLocked()
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportSegmentDownloadedPrunesOnlyExpiredBackBuffer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := time.Now().Add(-time.Minute)
|
||||
for segment := 0; segment <= 15; segment++ {
|
||||
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})), []byte("segment"), old)
|
||||
}
|
||||
writePrunerTestFile(t, filepath.Join(dir, "init.mp4"), []byte("init"), old)
|
||||
writePrunerTestFile(t, filepath.Join(dir, "stream.m3u8"), []byte("manifest"), old)
|
||||
writePrunerTestFile(t, filepath.Join(dir, "seg_00004.ts.tmp"), []byte("temporary"), old)
|
||||
writePrunerTestFile(t, filepath.Join(dir, "notes.txt"), []byte("other"), old)
|
||||
// A newly-created file below the floor may belong to a replacement ffmpeg
|
||||
// process and must survive this pass.
|
||||
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(4, TranscodeOpts{})), []byte("fresh"), time.Now())
|
||||
|
||||
session := &TranscodeSession{
|
||||
opts: TranscodeOpts{
|
||||
SessionID: "prune-test",
|
||||
SegmentDuration: 2,
|
||||
SegmentRetentionSeconds: 10,
|
||||
},
|
||||
outputDir: dir,
|
||||
lastPruneFloor: -1,
|
||||
lastRequestedSegment: 0,
|
||||
}
|
||||
session.ReportSegmentDownloaded(15)
|
||||
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(9, TranscodeOpts{})))
|
||||
t.Cleanup(func() {
|
||||
session.mu.Lock()
|
||||
session.segmentGeneration++
|
||||
session.segmentPruneRunning = false
|
||||
session.mu.Unlock()
|
||||
})
|
||||
|
||||
for _, segment := range []int{0, 1, 2, 4, 10, 11, 12, 13, 14, 15} {
|
||||
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})))
|
||||
}
|
||||
for _, segment := range []int{3, 5, 6, 7, 8, 9} {
|
||||
assertPrunerFileMissing(t, filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})))
|
||||
}
|
||||
for _, name := range []string{"init.mp4", "stream.m3u8", "seg_00004.ts.tmp", "notes.txt"} {
|
||||
assertPrunerFileExists(t, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshSegmentIsRetriedAfterGuardExpires(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := TranscodeOpts{
|
||||
SessionID: "retry-test",
|
||||
SegmentDuration: 2,
|
||||
SegmentRetentionSeconds: 10,
|
||||
}
|
||||
guard := 2*time.Duration(opts.SegmentDuration)*time.Second + 30*time.Second
|
||||
path := filepath.Join(dir, segmentFilename(3, opts))
|
||||
writePrunerTestFile(t, path, []byte("segment"), time.Now().Add(-guard+time.Second))
|
||||
session := &TranscodeSession{
|
||||
opts: opts,
|
||||
outputDir: dir,
|
||||
lastPruneFloor: -1,
|
||||
lastRequestedSegment: 0,
|
||||
}
|
||||
|
||||
session.ReportSegmentDownloaded(9)
|
||||
waitForPrunerFileMissing(t, path)
|
||||
waitForPrunePass(t, session, 4)
|
||||
}
|
||||
|
||||
func TestPruningContinuesAcrossBoundedBatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := time.Now().Add(-time.Minute)
|
||||
opts := TranscodeOpts{
|
||||
SessionID: "batch-test",
|
||||
SegmentDuration: 2,
|
||||
SegmentRetentionSeconds: 10,
|
||||
}
|
||||
for segment := 0; segment <= 600; segment++ {
|
||||
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, opts)), []byte("segment"), old)
|
||||
}
|
||||
session := &TranscodeSession{
|
||||
opts: opts,
|
||||
outputDir: dir,
|
||||
lastPruneFloor: -1,
|
||||
lastRequestedSegment: 0,
|
||||
}
|
||||
|
||||
session.ReportSegmentDownloaded(600)
|
||||
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(594, opts)))
|
||||
waitForPrunePass(t, session, 595)
|
||||
|
||||
for _, segment := range []int{0, 1, 2, 595, 600} {
|
||||
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(segment, opts)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardRestartPrunesOnlyFilesPresentAcrossSparseGap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := time.Now().Add(-time.Minute)
|
||||
opts := TranscodeOpts{
|
||||
SessionID: "sparse-restart-test",
|
||||
SegmentDuration: 2,
|
||||
SegmentRetentionSeconds: 10,
|
||||
StartSegmentNumber: 100_000,
|
||||
}
|
||||
for _, segment := range []int{3, 70, 99_999, 100_000} {
|
||||
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, opts)), []byte("segment"), old)
|
||||
}
|
||||
writePrunerTestFile(t, filepath.Join(dir, "seg_00004.ts.tmp"), []byte("temporary"), old)
|
||||
session := &TranscodeSession{
|
||||
opts: opts,
|
||||
outputDir: dir,
|
||||
lastPruneFloor: -1,
|
||||
lastRequestedSegment: opts.StartSegmentNumber,
|
||||
lastCompletedSegment: opts.StartSegmentNumber - 1,
|
||||
pruneBeforeStart: true,
|
||||
}
|
||||
|
||||
session.ReportSegmentDownloaded(opts.StartSegmentNumber + 5)
|
||||
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(99_999, opts)))
|
||||
waitForPrunePass(t, session, opts.StartSegmentNumber)
|
||||
|
||||
for _, segment := range []int{3, 70, 99_999} {
|
||||
assertPrunerFileMissing(t, filepath.Join(dir, segmentFilename(segment, opts)))
|
||||
}
|
||||
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(100_000, opts)))
|
||||
assertPrunerFileExists(t, filepath.Join(dir, "seg_00004.ts.tmp"))
|
||||
}
|
||||
|
||||
func TestCopyRetentionUsesManifestDurations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := time.Now().Add(-time.Minute)
|
||||
opts := TranscodeOpts{
|
||||
SessionID: "copy-duration-test",
|
||||
SourceVideoCodec: "h264",
|
||||
TargetCodecVideo: "copy",
|
||||
SegmentDuration: 2,
|
||||
SegmentRetentionSeconds: 10,
|
||||
}
|
||||
manifest := strings.Join([]string{
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:7",
|
||||
"#EXT-X-MEDIA-SEQUENCE:8",
|
||||
"#EXTINF:1.0,",
|
||||
"seg_00008.m4s",
|
||||
"#EXTINF:2.0,",
|
||||
"seg_00009.m4s",
|
||||
"#EXTINF:3.0,",
|
||||
"seg_00010.m4s",
|
||||
"#EXTINF:4.0,",
|
||||
"seg_00011.m4s",
|
||||
"#EXTINF:5.0,",
|
||||
"seg_00012.m4s",
|
||||
"#EXTINF:6.0,",
|
||||
"seg_00013.m4s",
|
||||
"#EXTINF:7.0,",
|
||||
"seg_00014.m4s",
|
||||
"#EXTINF:8.0,",
|
||||
"seg_00015.m4s",
|
||||
"",
|
||||
}, "\n")
|
||||
writePrunerTestFile(t, filepath.Join(dir, "stream.m3u8"), []byte(manifest), old)
|
||||
for segment := 8; segment <= 15; segment++ {
|
||||
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, opts)), []byte("segment"), old)
|
||||
}
|
||||
|
||||
session := &TranscodeSession{
|
||||
opts: opts,
|
||||
outputDir: dir,
|
||||
lastPruneFloor: -1,
|
||||
lastRequestedSegment: 0,
|
||||
}
|
||||
session.ReportSegmentDownloaded(15)
|
||||
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(12, opts)))
|
||||
t.Cleanup(func() {
|
||||
session.mu.Lock()
|
||||
session.segmentGeneration++
|
||||
session.segmentPruneRunning = false
|
||||
session.mu.Unlock()
|
||||
})
|
||||
|
||||
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(13, opts)))
|
||||
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(14, opts)))
|
||||
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(15, opts)))
|
||||
}
|
||||
|
||||
func TestReportSegmentDownloadedDisabledKeepsSegments(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, segmentFilename(3, TranscodeOpts{}))
|
||||
writePrunerTestFile(t, path, []byte("segment"), time.Now().Add(-time.Minute))
|
||||
session := &TranscodeSession{
|
||||
opts: TranscodeOpts{SegmentDuration: 2},
|
||||
outputDir: dir,
|
||||
lastPruneFloor: -1,
|
||||
lastRequestedSegment: 0,
|
||||
}
|
||||
|
||||
session.ReportSegmentDownloaded(500)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
assertPrunerFileExists(t, path)
|
||||
}
|
||||
|
||||
func TestOldGenerationDownloadDoesNotAdvanceRestartedSession(t *testing.T) {
|
||||
session := &TranscodeSession{
|
||||
opts: TranscodeOpts{SegmentRetentionSeconds: 600},
|
||||
lastRequestedSegment: 100,
|
||||
}
|
||||
oldGeneration := session.SegmentGeneration()
|
||||
|
||||
session.mu.Lock()
|
||||
session.segmentGeneration++
|
||||
session.lastRequestedSegment = 20
|
||||
session.mu.Unlock()
|
||||
|
||||
session.ReportSegmentDownloadedForGeneration(101, oldGeneration)
|
||||
if got := session.LastRequestedSegment(); got != 20 {
|
||||
t.Fatalf("last requested segment = %d, want restarted position 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseInvalidatesInFlightSegmentGeneration(t *testing.T) {
|
||||
session := &TranscodeSession{
|
||||
opts: TranscodeOpts{SegmentRetentionSeconds: 600},
|
||||
outputDir: t.TempDir(),
|
||||
lastRequestedSegment: 20,
|
||||
}
|
||||
downloadGeneration := session.SegmentGeneration()
|
||||
|
||||
if err := session.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
session.ReportSegmentDownloadedForGeneration(100, downloadGeneration)
|
||||
if got := session.LastRequestedSegment(); got != 20 {
|
||||
t.Fatalf("last requested segment = %d, want closed-session position 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSegmentDescriptorSurvivesUnlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seg_00001.ts")
|
||||
want := []byte("complete segment")
|
||||
writePrunerTestFile(t, path, want, time.Now())
|
||||
session := &TranscodeSession{outputDir: dir}
|
||||
|
||||
segment, err := session.OpenSegment("seg_00001.ts")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSegment: %v", err)
|
||||
}
|
||||
defer func() { _ = segment.Close() }()
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove opened segment: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(segment.File)
|
||||
if err != nil {
|
||||
t.Fatalf("read opened segment: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("opened segment = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSegmentLeaseCapturesGeneration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seg_00001.ts")
|
||||
writePrunerTestFile(t, path, []byte("complete segment"), time.Now())
|
||||
session := &TranscodeSession{outputDir: dir, segmentGeneration: 7}
|
||||
|
||||
segment, err := session.OpenSegment("seg_00001.ts")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSegment: %v", err)
|
||||
}
|
||||
defer func() { _ = segment.Close() }()
|
||||
if segment.Generation != 7 {
|
||||
t.Fatalf("segment generation = %d, want 7", segment.Generation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationTokenRejectsPriorSessionIncarnation(t *testing.T) {
|
||||
oldDir := t.TempDir()
|
||||
newDir := t.TempDir()
|
||||
const name = "seg_00007.ts"
|
||||
writePrunerTestFile(t, filepath.Join(oldDir, name), []byte("old segment"), time.Now())
|
||||
writePrunerTestFile(t, filepath.Join(newDir, name), []byte("new segment"), time.Now())
|
||||
|
||||
oldSession := &TranscodeSession{outputDir: oldDir}
|
||||
oldLease, err := oldSession.OpenSegment(name)
|
||||
if err != nil {
|
||||
t.Fatalf("open old segment: %v", err)
|
||||
}
|
||||
defer func() { _ = oldLease.Close() }()
|
||||
|
||||
newSession := &TranscodeSession{outputDir: newDir}
|
||||
newLease, err := newSession.OpenSegment(name)
|
||||
if err != nil {
|
||||
t.Fatalf("open new segment: %v", err)
|
||||
}
|
||||
defer func() { _ = newLease.Close() }()
|
||||
if oldLease.Generation != newLease.Generation {
|
||||
t.Fatalf("numeric generations differ: old=%d new=%d; test requires restart collision", oldLease.Generation, newLease.Generation)
|
||||
}
|
||||
if oldLease.GenerationToken == newLease.GenerationToken {
|
||||
t.Fatal("separate session objects reused an opaque generation token")
|
||||
}
|
||||
|
||||
newSession.ReportSegmentDownloadedForGenerationToken(7, oldLease.GenerationToken)
|
||||
if got := newSession.LastRequestedSegment(); got != 0 {
|
||||
t.Fatalf("prior-session token advanced replacement to %d", got)
|
||||
}
|
||||
newSession.ReportSegmentDownloadedForGenerationToken(7, newLease.GenerationToken)
|
||||
if got := newSession.LastRequestedSegment(); got != 7 {
|
||||
t.Fatalf("current token advanced replacement to %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSegmentWaitsThroughRestartInsteadOfLeasingStaleFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "seg_00001.ts")
|
||||
writePrunerTestFile(t, path, []byte("stale segment"), time.Now())
|
||||
session := &TranscodeSession{outputDir: dir, restarting: true}
|
||||
|
||||
if segment, err := session.OpenSegment("seg_00001.ts"); !errors.Is(err, ErrSegmentNotFound) {
|
||||
if segment != nil {
|
||||
_ = segment.Close()
|
||||
}
|
||||
t.Fatalf("OpenSegment error = %v, want ErrSegmentNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writePrunerTestFile(t *testing.T, path string, contents []byte, modTime time.Time) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||
t.Fatalf("set times for %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPrunePass(t *testing.T, session *TranscodeSession, wantFloor int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
session.mu.Lock()
|
||||
running := session.segmentPruneRunning
|
||||
floor := session.lastPruneFloor
|
||||
session.mu.Unlock()
|
||||
if !running && floor >= wantFloor {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("segment prune pass did not finish")
|
||||
}
|
||||
|
||||
func waitForPrunerFileMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expected %s to be removed", filepath.Base(path))
|
||||
}
|
||||
|
||||
func assertPrunerFileExists(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPrunerFileMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be removed, stat error = %v", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -53,16 +52,15 @@ type TranscodeOpts struct {
|
||||
StreamOriginSeconds float64
|
||||
// CopySeekAnchorResolved distinguishes a valid zero-second origin from
|
||||
// older/shared recipes that never resolved a copy seek anchor.
|
||||
CopySeekAnchorResolved bool
|
||||
TargetResolution string // e.g., 1080p, 720p
|
||||
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
|
||||
TargetCodecAudio string // e.g., aac
|
||||
SegmentDuration int // seconds, default 6
|
||||
SegmentRetentionSeconds int // downloaded media retained behind the client; 0 disables pruning
|
||||
StartSegmentNumber int // -hls_segment_start_number, default 0
|
||||
FFmpegPath string // optional explicit ffmpeg binary path
|
||||
HWAccel string // auto, qsv, vaapi, nvenc, none
|
||||
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
|
||||
CopySeekAnchorResolved bool
|
||||
TargetResolution string // e.g., 1080p, 720p
|
||||
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
|
||||
TargetCodecAudio string // e.g., aac
|
||||
SegmentDuration int // seconds, default 6
|
||||
StartSegmentNumber int // -hls_segment_start_number, default 0
|
||||
FFmpegPath string // optional explicit ffmpeg binary path
|
||||
HWAccel string // auto, qsv, vaapi, nvenc, none
|
||||
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
|
||||
// AvoidHWDevice asks the initial multi-device allocator to prefer any other
|
||||
// present render device. It is a process-local startup hint used after an
|
||||
// early GPU failure; the selected concrete device remains fully reserved and
|
||||
@@ -121,13 +119,6 @@ type TranscodeSession struct {
|
||||
done chan struct{} // closed when the monitor goroutine finishes
|
||||
stdinPipe io.WriteCloser
|
||||
lastRequestedSegment int
|
||||
lastCompletedSegment int
|
||||
lastPruneFloor int
|
||||
lastPruneHighWater int
|
||||
segmentPruneRunning bool
|
||||
pruneBeforeStart bool
|
||||
segmentGeneration uint64
|
||||
segmentIncarnation string
|
||||
throttler *TranscodeThrottler
|
||||
stderrLinesLogged int
|
||||
stderrBytesLogged int
|
||||
@@ -255,10 +246,6 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
|
||||
done: make(chan struct{}),
|
||||
stderr: newBoundedTailBuffer(stderrTailMaxBytes),
|
||||
lastRequestedSegment: opts.StartSegmentNumber,
|
||||
lastCompletedSegment: opts.StartSegmentNumber - 1,
|
||||
lastPruneFloor: opts.StartSegmentNumber - 1,
|
||||
lastPruneHighWater: opts.StartSegmentNumber - 1,
|
||||
segmentIncarnation: uuid.NewString(),
|
||||
reserveHWDeviceOnRestart: reserveHWDeviceOnRestart,
|
||||
}
|
||||
|
||||
@@ -1891,59 +1878,6 @@ func (s *TranscodeSession) GetSegment(name string) (string, error) {
|
||||
return segPath, nil
|
||||
}
|
||||
|
||||
// SegmentLease binds an opened segment descriptor to the FFmpeg generation it
|
||||
// came from. Keeping the descriptor open makes it a filesystem-backed lease: a
|
||||
// concurrent prune may unlink the directory entry, but the response can still
|
||||
// read the complete file on POSIX filesystems.
|
||||
type SegmentLease struct {
|
||||
File *os.File
|
||||
Info os.FileInfo
|
||||
Generation uint64
|
||||
GenerationToken string
|
||||
}
|
||||
|
||||
// Close releases the opened segment descriptor.
|
||||
func (l *SegmentLease) Close() error {
|
||||
return l.File.Close()
|
||||
}
|
||||
|
||||
// OpenSegment opens a completed segment for serving and captures its FFmpeg
|
||||
// generation atomically with the open.
|
||||
func (s *TranscodeSession) OpenSegment(name string) (*SegmentLease, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.restarting {
|
||||
return nil, ErrSegmentNotFound
|
||||
}
|
||||
|
||||
clean := filepath.Base(name)
|
||||
segment, err := os.Open(filepath.Join(s.outputDir, clean))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrSegmentNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("open segment: %w", err)
|
||||
}
|
||||
info, err := segment.Stat()
|
||||
if err != nil {
|
||||
_ = segment.Close()
|
||||
return nil, fmt.Errorf("stat segment: %w", err)
|
||||
}
|
||||
if info.Size() <= 0 {
|
||||
_ = segment.Close()
|
||||
return nil, ErrSegmentNotFound
|
||||
}
|
||||
if s.segmentIncarnation == "" {
|
||||
s.segmentIncarnation = uuid.NewString()
|
||||
}
|
||||
return &SegmentLease{
|
||||
File: segment,
|
||||
Info: info,
|
||||
Generation: s.segmentGeneration,
|
||||
GenerationToken: s.segmentGenerationTokenLocked(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close terminates the ffmpeg process and removes the temporary output directory.
|
||||
func (s *TranscodeSession) Close() error {
|
||||
return s.shutdown(true)
|
||||
@@ -1978,8 +1912,6 @@ func (s *TranscodeSession) shutdown(removeOutput bool) error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.running = false
|
||||
s.segmentGeneration++
|
||||
s.segmentPruneRunning = false
|
||||
|
||||
// Clean up temporary directory.
|
||||
if removeOutput && s.outputDir != "" {
|
||||
@@ -2095,22 +2027,6 @@ func (s *TranscodeSession) restart(
|
||||
return nil
|
||||
}
|
||||
s.restarting = true
|
||||
s.segmentGeneration++
|
||||
s.segmentPruneRunning = false
|
||||
previousRequestedSegment := s.lastRequestedSegment
|
||||
forwardRestart := startSegment > previousRequestedSegment
|
||||
s.lastRequestedSegment = startSegment
|
||||
s.lastCompletedSegment = startSegment - 1
|
||||
if forwardRestart && s.opts.SegmentRetentionSeconds > 0 {
|
||||
// Keep the old prune cursor reachable until the replacement process has
|
||||
// produced a complete back buffer. Otherwise every forward restart leaves
|
||||
// one retained window permanently behind startSegment.
|
||||
s.pruneBeforeStart = true
|
||||
} else {
|
||||
s.lastPruneFloor = startSegment - 1
|
||||
s.pruneBeforeStart = false
|
||||
}
|
||||
s.lastPruneHighWater = startSegment - 1
|
||||
cancelCurrent := s.cancel
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
@@ -2207,7 +2123,6 @@ func (s *TranscodeSession) restart(
|
||||
s.restarting = false
|
||||
s.stdinPipe = stdinPipe
|
||||
s.lastRequestedSegment = startSegment
|
||||
s.lastCompletedSegment = startSegment - 1
|
||||
s.done = make(chan struct{})
|
||||
hook := s.restartHook
|
||||
s.mu.Unlock()
|
||||
@@ -2271,49 +2186,6 @@ func (s *TranscodeSession) WaitForSegment(name string, timeout time.Duration) (s
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForOpenSegment is the opened-file counterpart to WaitForSegment. It
|
||||
// closes the stat-to-open race for HTTP serving while preserving the same
|
||||
// restart and ffmpeg-exit error contract.
|
||||
func (s *TranscodeSession) WaitForOpenSegment(name string, timeout time.Duration) (*SegmentLease, error) {
|
||||
deadline := time.After(timeout)
|
||||
for {
|
||||
segment, err := s.OpenSegment(name)
|
||||
if err == nil {
|
||||
return segment, nil
|
||||
}
|
||||
if !errors.Is(err, ErrSegmentNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
running := s.running
|
||||
restarting := s.restarting
|
||||
waitErr := s.waitErr
|
||||
s.mu.Unlock()
|
||||
|
||||
if restarting {
|
||||
select {
|
||||
case <-deadline:
|
||||
return nil, ErrSegmentNotFound
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !running && waitErr != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrTranscodeFailed, waitErr)
|
||||
}
|
||||
if !running {
|
||||
return nil, ErrSegmentNotFound
|
||||
}
|
||||
|
||||
select {
|
||||
case <-deadline:
|
||||
return nil, ErrSegmentNotFound
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteManifestPaths prefixes relative segment references in an HLS manifest
|
||||
// with segPrefix (e.g. "segment/") and optionally appends rawQuery as a query
|
||||
// string. This ensures the HLS player's segment requests match server routes
|
||||
@@ -2561,54 +2433,9 @@ func (s *TranscodeSession) RestartSeekTarget(segNum int) (float64, bool, error)
|
||||
func (s *TranscodeSession) ReportSegmentDownloaded(segNum int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.reportSegmentDownloadedLocked(segNum)
|
||||
}
|
||||
|
||||
// SegmentGeneration identifies the current ffmpeg timeline. Callers that hold
|
||||
// an open segment across a restart use it to prevent the old response from
|
||||
// advancing the replacement process's download position.
|
||||
func (s *TranscodeSession) SegmentGeneration() uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.segmentGeneration
|
||||
}
|
||||
|
||||
// ReportSegmentDownloadedForGeneration records completion only if the served
|
||||
// file belongs to the current ffmpeg timeline.
|
||||
func (s *TranscodeSession) ReportSegmentDownloadedForGeneration(segNum int, generation uint64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if generation != s.segmentGeneration {
|
||||
return
|
||||
}
|
||||
s.reportSegmentDownloadedLocked(segNum)
|
||||
}
|
||||
|
||||
// ReportSegmentDownloadedForGenerationToken records a completion relayed over
|
||||
// HTTP only when it belongs to this exact session object and FFmpeg timeline.
|
||||
// The opaque incarnation prevents a delayed proxy acknowledgement from matching
|
||||
// a reconstructed session whose numeric generation restarted at zero.
|
||||
func (s *TranscodeSession) ReportSegmentDownloadedForGenerationToken(segNum int, generationToken string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if generationToken == "" || generationToken != s.segmentGenerationTokenLocked() {
|
||||
return
|
||||
}
|
||||
s.reportSegmentDownloadedLocked(segNum)
|
||||
}
|
||||
|
||||
func (s *TranscodeSession) segmentGenerationTokenLocked() string {
|
||||
return s.segmentIncarnation + ":" + strconv.FormatUint(s.segmentGeneration, 10)
|
||||
}
|
||||
|
||||
func (s *TranscodeSession) reportSegmentDownloadedLocked(segNum int) {
|
||||
if segNum > s.lastRequestedSegment {
|
||||
s.lastRequestedSegment = segNum
|
||||
}
|
||||
if segNum > s.lastCompletedSegment {
|
||||
s.lastCompletedSegment = segNum
|
||||
}
|
||||
s.scheduleSegmentPruneLocked()
|
||||
}
|
||||
|
||||
// LastRequestedSegment returns the highest segment number downloaded by the client.
|
||||
|
||||
@@ -20,11 +20,10 @@ import (
|
||||
// internal/playback does not import internal/config (avoiding an import cycle);
|
||||
// each embedding handler adapts its own config snapshot into this shape.
|
||||
type TranscodeRuntimeConfig struct {
|
||||
TranscodeDir string
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
SegmentRetentionSeconds int
|
||||
TranscodeDir string
|
||||
FFmpegPath string
|
||||
HWAccel string
|
||||
HWDevice string
|
||||
}
|
||||
|
||||
// sessionReconstructor is the SessionManager capability used to re-register a
|
||||
@@ -580,7 +579,6 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID
|
||||
// operator config change applies to reconstructed sessions too.
|
||||
opts.HWAccel = cfg.HWAccel
|
||||
opts.HWDevice = cfg.HWDevice
|
||||
opts.SegmentRetentionSeconds = cfg.SegmentRetentionSeconds
|
||||
|
||||
// Resume near the segment the client is actually requesting. The card records
|
||||
// the original start; if the client has played past it, spawning ffmpeg at the
|
||||
|
||||
@@ -2,148 +2,11 @@ package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestForwardRestartPreservesConfiguredBackBuffer(t *testing.T) {
|
||||
truePath, err := exec.LookPath("true")
|
||||
if err != nil {
|
||||
t.Skipf("`true` not found in PATH: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
old := time.Now().Add(-time.Minute)
|
||||
for _, segment := range []int{3, 40, 64, 65, 69, 70, 100} {
|
||||
name := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
|
||||
if err := os.WriteFile(name, []byte("segment"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(name, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
session := &TranscodeSession{
|
||||
outputDir: dir,
|
||||
lastRequestedSegment: 70,
|
||||
lastCompletedSegment: 70,
|
||||
lastPruneFloor: -1,
|
||||
opts: TranscodeOpts{
|
||||
OutputDir: dir,
|
||||
TargetCodecVideo: "h264",
|
||||
SegmentDuration: 4,
|
||||
SegmentRetentionSeconds: 20,
|
||||
StartSegmentNumber: 0,
|
||||
FFmpegPath: truePath,
|
||||
},
|
||||
}
|
||||
|
||||
if err := session.Restart(context.Background(), 400, 100); err != nil {
|
||||
t.Fatalf("Restart: %v", err)
|
||||
}
|
||||
// Restart itself has no downstream-completion proof for the replacement
|
||||
// generation, so it must not unlink even old files synchronously.
|
||||
for _, segment := range []int{3, 40, 64, 65, 69, 70, 100} {
|
||||
path := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("segment %d was removed during restart: %v", segment, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the replacement generation has produced a complete back buffer,
|
||||
// the preserved range must become eligible again rather than leaking one
|
||||
// full window after every forward seek.
|
||||
session.ReportSegmentDownloaded(105)
|
||||
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(70, TranscodeOpts{})))
|
||||
for _, segment := range []int{3, 40, 64, 65, 69, 70} {
|
||||
path := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("expired pre-restart segment %d survived completed-download pruning: %v", segment, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, segmentFilename(100, TranscodeOpts{}))); err != nil {
|
||||
t.Fatalf("replacement startup segment was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardRestartWithoutCompletedDownloadPreservesFreshFiles(t *testing.T) {
|
||||
truePath, err := exec.LookPath("true")
|
||||
if err != nil {
|
||||
t.Skipf("`true` not found in PATH: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
for _, segment := range []int{3, 40, 70} {
|
||||
if err := os.WriteFile(filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})), []byte("segment"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
session := &TranscodeSession{
|
||||
outputDir: dir,
|
||||
lastRequestedSegment: 70,
|
||||
lastCompletedSegment: -1,
|
||||
lastPruneFloor: -1,
|
||||
opts: TranscodeOpts{
|
||||
OutputDir: dir,
|
||||
TargetCodecVideo: "h264",
|
||||
SegmentDuration: 4,
|
||||
SegmentRetentionSeconds: 20,
|
||||
StartSegmentNumber: 0,
|
||||
FFmpegPath: truePath,
|
||||
},
|
||||
}
|
||||
|
||||
if err := session.Restart(context.Background(), 400, 100); err != nil {
|
||||
t.Fatalf("Restart: %v", err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
for _, segment := range []int{3, 40, 70} {
|
||||
if _, err := os.Stat(filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))); err != nil {
|
||||
t.Errorf("fresh segment %d was removed without a completed download: %v", segment, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardRestartKeepsSegmentsWhenRetentionDisabled(t *testing.T) {
|
||||
truePath, err := exec.LookPath("true")
|
||||
if err != nil {
|
||||
t.Skipf("`true` not found in PATH: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
for _, segment := range []int{3, 40, 70} {
|
||||
name := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
|
||||
if err := os.WriteFile(name, []byte("segment"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
session := &TranscodeSession{
|
||||
outputDir: dir,
|
||||
lastRequestedSegment: 70,
|
||||
lastCompletedSegment: 70,
|
||||
opts: TranscodeOpts{
|
||||
OutputDir: dir,
|
||||
TargetCodecVideo: "h264",
|
||||
SegmentDuration: 4,
|
||||
StartSegmentNumber: 0,
|
||||
FFmpegPath: truePath,
|
||||
},
|
||||
}
|
||||
|
||||
if err := session.Restart(context.Background(), 400, 100); err != nil {
|
||||
t.Fatalf("Restart: %v", err)
|
||||
}
|
||||
for _, segment := range []int{3, 40, 70} {
|
||||
path := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("disabled retention removed segment %d: %v", segment, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSegmentRecoveryDecisionWaitsWhileRestarting covers half of issue #243's
|
||||
// seek-freeze: while a restart is already in flight, a concurrent segment
|
||||
// request must WAIT for the restart's output rather than trigger another
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/nodeconfig"
|
||||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
)
|
||||
|
||||
func TestTranscodeProxyAcknowledgesOnlyFullDownstreamResponse(t *testing.T) {
|
||||
const (
|
||||
body = "complete segment"
|
||||
generation = "incarnation:17"
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
rangeHeader string
|
||||
wantStatus int
|
||||
wantAck int32
|
||||
}{
|
||||
{name: "ordinary get", wantStatus: http.StatusOK, wantAck: 1},
|
||||
{name: "whole-file range", rangeHeader: "bytes=0-", wantStatus: http.StatusPartialContent, wantAck: 1},
|
||||
{name: "partial range", rangeHeader: "bytes=1-", wantStatus: http.StatusPartialContent},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var acknowledgements atomic.Int32
|
||||
seenRange := make(chan string, 1)
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if r.Header.Get(transcodeproxy.RequestHeader) != "1" {
|
||||
t.Error("segment request omitted proxy marker")
|
||||
}
|
||||
seenRange <- r.Header.Get("Range")
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, generation)
|
||||
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
|
||||
case http.MethodPost:
|
||||
if r.Header.Get(transcodeproxy.GenerationHeader) != generation {
|
||||
t.Errorf("ack generation = %q, want %q", r.Header.Get(transcodeproxy.GenerationHeader), generation)
|
||||
}
|
||||
acknowledgements.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
defer node.Close()
|
||||
|
||||
server := newCompletionTestProxy(node.Client())
|
||||
claims := &streamtoken.Claims{SessionID: "public", TranscodeNode: node.URL}
|
||||
req := httptest.NewRequest(http.MethodGet, "/stream/transcode/token/segment/seg_00007.ts", nil)
|
||||
req.Header.Set("Range", tt.rangeHeader)
|
||||
rr := httptest.NewRecorder()
|
||||
server.proxyToTranscodeNode(rr, req, claims, "/transcode/remote/segment/seg_00007.ts")
|
||||
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %q", rr.Code, tt.wantStatus, rr.Body.String())
|
||||
}
|
||||
if got := <-seenRange; got != tt.rangeHeader {
|
||||
t.Fatalf("forwarded Range = %q, want %q", got, tt.rangeHeader)
|
||||
}
|
||||
if got := rr.Header().Get(transcodeproxy.GenerationHeader); got != "" {
|
||||
t.Fatalf("internal generation leaked downstream: %q", got)
|
||||
}
|
||||
if got := acknowledgements.Load(); got != tt.wantAck {
|
||||
t.Fatalf("acknowledgements = %d, want %d", got, tt.wantAck)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeProxyDoesNotAcknowledgeFailedDownstreamWrite(t *testing.T) {
|
||||
var acknowledgements atomic.Int32
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
acknowledgements.Add(1)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, "incarnation:21")
|
||||
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader("complete segment"))
|
||||
}))
|
||||
defer node.Close()
|
||||
|
||||
server := newCompletionTestProxy(node.Client())
|
||||
claims := &streamtoken.Claims{SessionID: "public", TranscodeNode: node.URL}
|
||||
req := httptest.NewRequest(http.MethodGet, "/stream/transcode/token/segment/seg_00007.ts", nil)
|
||||
w := &failingCompletionResponseWriter{header: make(http.Header), remaining: 5}
|
||||
server.proxyToTranscodeNode(w, req, claims, "/transcode/remote/segment/seg_00007.ts")
|
||||
|
||||
if got := acknowledgements.Load(); got != 0 {
|
||||
t.Fatalf("failed downstream transfer produced %d acknowledgement(s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newCompletionTestProxy(client *http.Client) *Server {
|
||||
watcher := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.JWTSecret = "proxy-test-secret"
|
||||
watcher.SetConfigForTest(cfg)
|
||||
server := NewServer(watcher, nil)
|
||||
server.httpClient = client
|
||||
return server
|
||||
}
|
||||
|
||||
type failingCompletionResponseWriter struct {
|
||||
header http.Header
|
||||
status int
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (w *failingCompletionResponseWriter) Header() http.Header { return w.header }
|
||||
|
||||
func (w *failingCompletionResponseWriter) WriteHeader(status int) { w.status = status }
|
||||
|
||||
func (w *failingCompletionResponseWriter) Write(p []byte) (int, error) {
|
||||
if w.remaining <= 0 {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
n := min(len(p), w.remaining)
|
||||
w.remaining -= n
|
||||
return n, io.ErrClosedPipe
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,12 +13,10 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/httpstream"
|
||||
"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"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
)
|
||||
|
||||
// Server is the HTTP handler for proxy mode.
|
||||
@@ -346,9 +343,6 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl
|
||||
}
|
||||
|
||||
targetURL := claims.TranscodeNode + path
|
||||
isSegmentRoute := strings.Contains(path, "/segment/")
|
||||
_, segmentParseErr := playback.ParseSegmentNumber(filepath.Base(path))
|
||||
isMediaSegment := segmentParseErr == nil
|
||||
if rawQuery := r.URL.RawQuery; rawQuery != "" {
|
||||
targetURL = fmt.Sprintf("%s?%s", targetURL, rawQuery)
|
||||
}
|
||||
@@ -359,9 +353,6 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret)
|
||||
if isSegmentRoute {
|
||||
transcodeproxy.PrepareRequest(req, r)
|
||||
}
|
||||
// Forward the verified stream token so the transcode node can self-reconstruct
|
||||
// a lost session after its OWN restart: the token carries the full byte-affecting
|
||||
// recipe, so the node can re-spawn ffmpeg seeked to the requested segment instead
|
||||
@@ -377,21 +368,16 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl
|
||||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer resp.Body.Close()
|
||||
|
||||
generation := resp.Header.Get(transcodeproxy.GenerationHeader)
|
||||
transcodeproxy.CopyResponseHeaders(w.Header(), resp.Header)
|
||||
sw := httpstream.NewRollingDeadlineWriter(w)
|
||||
sw.WriteHeader(resp.StatusCode)
|
||||
if _, copyErr := io.Copy(sw, resp.Body); copyErr != nil {
|
||||
return
|
||||
}
|
||||
if isMediaSegment && generation != "" && r.Method == http.MethodGet &&
|
||||
sw.CompletedFullResponse(transcodeproxy.FullRepresentationSize(resp)) {
|
||||
if ackErr := transcodeproxy.Acknowledge(r.Context(), s.httpClient, claims.TranscodeNode+path, cfg.Auth.JWTSecret, generation); ackErr != nil {
|
||||
slog.WarnContext(r.Context(), "acknowledge transcode segment completion", "component", "proxy", "error", ackErr, "playback_session_id", claims.SessionID)
|
||||
// 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) {
|
||||
|
||||
@@ -19,12 +19,10 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/chapterthumbs"
|
||||
"github.com/Silo-Server/silo-server/internal/httpstream"
|
||||
"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"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
)
|
||||
|
||||
// TranscodeStartRequest is the JSON body for POST /transcode/start.
|
||||
@@ -389,7 +387,6 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Delete("/transcode/{session_id}", s.handleStop)
|
||||
r.Get("/transcode/{session_id}/master.m3u8", s.handleManifest)
|
||||
r.Get("/transcode/{session_id}/segment/{name}", s.handleSegment)
|
||||
r.Post("/transcode/{session_id}/segment/{name}/downloaded", s.handleSegmentDownloaded)
|
||||
r.Post("/admin/force-reload", s.handleForceReload)
|
||||
r.Get("/status", s.handleStatus)
|
||||
})
|
||||
@@ -484,28 +481,27 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
outputDir := filepath.Join(cfg.Playback.TranscodeDir, req.SessionID)
|
||||
|
||||
opts := playback.TranscodeOpts{
|
||||
InputPath: req.InputPath,
|
||||
OutputDir: outputDir,
|
||||
SessionID: req.SessionID,
|
||||
SourceVideoCodec: req.SourceVideoCodec,
|
||||
SourceVideoProfile: req.SourceVideoProfile,
|
||||
SourceVideoBitDepth: req.SourceVideoBitDepth,
|
||||
SoftwareVideoDecode: req.SoftwareVideoDecode,
|
||||
VideoBitstreamFilter: req.VideoBitstreamFilter,
|
||||
SeekSeconds: req.SeekSeconds,
|
||||
StreamOriginSeconds: req.StreamOriginSeconds,
|
||||
CopySeekAnchorResolved: req.CopySeekAnchorResolved,
|
||||
StartSegmentNumber: req.StartSegmentNumber,
|
||||
TargetResolution: req.TargetResolution,
|
||||
TargetCodecVideo: req.TargetCodecVideo,
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetAudioChannels: req.TargetAudioChannels,
|
||||
TargetAudioBitrateKbps: req.TargetAudioBitrateKbps,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
SegmentRetentionSeconds: cfg.Playback.SegmentRetentionSeconds,
|
||||
FFmpegPath: cfg.Playback.FFmpegPath,
|
||||
HWAccel: req.HWAccel,
|
||||
InputPath: req.InputPath,
|
||||
OutputDir: outputDir,
|
||||
SessionID: req.SessionID,
|
||||
SourceVideoCodec: req.SourceVideoCodec,
|
||||
SourceVideoProfile: req.SourceVideoProfile,
|
||||
SourceVideoBitDepth: req.SourceVideoBitDepth,
|
||||
SoftwareVideoDecode: req.SoftwareVideoDecode,
|
||||
VideoBitstreamFilter: req.VideoBitstreamFilter,
|
||||
SeekSeconds: req.SeekSeconds,
|
||||
StreamOriginSeconds: req.StreamOriginSeconds,
|
||||
CopySeekAnchorResolved: req.CopySeekAnchorResolved,
|
||||
StartSegmentNumber: req.StartSegmentNumber,
|
||||
TargetResolution: req.TargetResolution,
|
||||
TargetCodecVideo: req.TargetCodecVideo,
|
||||
TargetCodecAudio: req.TargetCodecAudio,
|
||||
TargetAudioChannels: req.TargetAudioChannels,
|
||||
TargetAudioBitrateKbps: req.TargetAudioBitrateKbps,
|
||||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
FFmpegPath: cfg.Playback.FFmpegPath,
|
||||
HWAccel: req.HWAccel,
|
||||
// This node's configured device (or device list — StartTranscode
|
||||
// resolves it to one GPU), matching what reconstruction uses so fresh
|
||||
// and reconstructed sessions balance identically.
|
||||
@@ -710,7 +706,6 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe
|
||||
// rebuild. Run as a transcode node, not integrated (card.TranscodeOpts defaults).
|
||||
opts.HWAccel = cfg.Playback.HWAccel
|
||||
opts.HWDevice = cfg.Playback.HWDevice
|
||||
opts.SegmentRetentionSeconds = cfg.Playback.SegmentRetentionSeconds
|
||||
opts.NodeType = "transcode"
|
||||
opts.ExecutionMode = "transcode_node"
|
||||
|
||||
@@ -905,7 +900,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
|
||||
s.touchSession(sessionID)
|
||||
}
|
||||
|
||||
segmentLease, err := session.OpenSegment(name)
|
||||
segPath, err := session.GetSegment(name)
|
||||
if err != nil && err == playback.ErrSegmentNotFound {
|
||||
segNum, parseErr := playback.ParseSegmentNumber(name)
|
||||
if parseErr == nil {
|
||||
@@ -940,7 +935,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
|
||||
"session", sessionID,
|
||||
"playback_session_id", sessionID,
|
||||
)
|
||||
segmentLease, err = session.WaitForOpenSegment(name, decision.WaitTimeout)
|
||||
segPath, err = session.WaitForSegment(name, decision.WaitTimeout)
|
||||
if err != nil && err == playback.ErrSegmentNotFound {
|
||||
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "transcodenode",
|
||||
"segment", name,
|
||||
@@ -985,7 +980,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
|
||||
seekSeconds,
|
||||
segNum,
|
||||
); restartErr == nil {
|
||||
segmentLease, err = session.WaitForOpenSegment(name, 30*time.Second)
|
||||
segPath, err = session.WaitForSegment(name, 30*time.Second)
|
||||
}
|
||||
}
|
||||
if !ok && session.IsCopyVideo() {
|
||||
@@ -995,7 +990,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
|
||||
} else if session.IsRunning() {
|
||||
// Non-numbered segment (e.g., init.mp4 for fMP4 HLS).
|
||||
// Wait briefly — the init segment is written almost immediately.
|
||||
segmentLease, err = session.WaitForOpenSegment(name, 10*time.Second)
|
||||
segPath, err = session.WaitForSegment(name, 10*time.Second)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1005,46 +1000,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
proxied := r.Header.Get(transcodeproxy.RequestHeader) == "1"
|
||||
if proxied {
|
||||
w.Header().Set(transcodeproxy.GenerationHeader, segmentLease.GenerationToken)
|
||||
}
|
||||
defer func() { _ = segmentLease.Close() }()
|
||||
sw := httpstream.NewRollingDeadlineWriter(w)
|
||||
http.ServeContent(sw, r, segmentLease.Info.Name(), segmentLease.Info.ModTime(), segmentLease.File)
|
||||
if !proxied && r.Method == http.MethodGet &&
|
||||
sw.CompletedFullResponse(segmentLease.Info.Size()) {
|
||||
if segmentNumber, parseErr := playback.ParseSegmentNumber(name); parseErr == nil {
|
||||
session.ReportSegmentDownloadedForGeneration(segmentNumber, segmentLease.Generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleSegmentDownloaded records completion only after the central API has
|
||||
// delivered the full segment to its downstream playback client. The generation
|
||||
// returned with the original segment response prevents a delayed acknowledgement
|
||||
// from advancing a replacement FFmpeg timeline.
|
||||
func (s *Server) handleSegmentDownloaded(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := chi.URLParam(r, "session_id")
|
||||
name := chi.URLParam(r, "name")
|
||||
segmentNumber, err := playback.ParseSegmentNumber(name)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid segment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
generationToken := r.Header.Get(transcodeproxy.GenerationHeader)
|
||||
if generationToken == "" {
|
||||
http.Error(w, "invalid segment generation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := s.acquireSessionTouched(sessionID)
|
||||
if !ok {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
session.ReportSegmentDownloadedForGenerationToken(segmentNumber, generationToken)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
http.ServeFile(w, r, segPath)
|
||||
}
|
||||
|
||||
func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/nodesessions"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
|
||||
)
|
||||
|
||||
const testSecret = "node-reconstruct-test-secret"
|
||||
@@ -36,122 +34,11 @@ func newTestServer(t *testing.T) *Server {
|
||||
cfg.Playback.TranscodeDir = t.TempDir()
|
||||
w.SetConfigForTest(cfg)
|
||||
return &Server{
|
||||
watcher: w,
|
||||
sessions: make(map[string]*playback.TranscodeSession),
|
||||
lastAccess: make(map[string]time.Time),
|
||||
watcher: w,
|
||||
sessions: make(map[string]*playback.TranscodeSession),
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedSegmentCompletionRequiresDownstreamAcknowledgement(t *testing.T) {
|
||||
truePath, err := exec.LookPath("true")
|
||||
if err != nil {
|
||||
t.Skipf("`true` not found in PATH: %v", err)
|
||||
}
|
||||
|
||||
const sessionID = "proxy-completion-session"
|
||||
server := newTestServer(t)
|
||||
outputDir := t.TempDir()
|
||||
session, err := playback.StartTranscode(context.Background(), playback.TranscodeOpts{
|
||||
SessionID: sessionID,
|
||||
OutputDir: outputDir,
|
||||
FFmpegPath: truePath,
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
SegmentRetentionSeconds: 600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTranscode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = session.Close() })
|
||||
server.sessions[sessionID] = session
|
||||
server.lastAccess[sessionID] = time.Now()
|
||||
|
||||
const segmentName = "seg_00007.ts"
|
||||
if err := os.WriteFile(filepath.Join(outputDir, segmentName), []byte("complete segment"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
segmentReq := withNodeRouteParams(
|
||||
httptest.NewRequest(http.MethodGet, "/transcode/"+sessionID+"/segment/"+segmentName, nil),
|
||||
map[string]string{"session_id": sessionID, "name": segmentName},
|
||||
)
|
||||
segmentReq.Header.Set(transcodeproxy.RequestHeader, "1")
|
||||
segmentRR := httptest.NewRecorder()
|
||||
server.handleSegment(segmentRR, segmentReq)
|
||||
if segmentRR.Code != http.StatusOK {
|
||||
t.Fatalf("segment status = %d, body = %q", segmentRR.Code, segmentRR.Body.String())
|
||||
}
|
||||
generation := segmentRR.Header().Get(transcodeproxy.GenerationHeader)
|
||||
if generation == "" {
|
||||
t.Fatal("proxied segment omitted generation")
|
||||
}
|
||||
if got := session.LastRequestedSegment(); got != 0 {
|
||||
t.Fatalf("node counted proxy-hop completion as client completion: got %d, want 0", got)
|
||||
}
|
||||
|
||||
ackReq := withNodeRouteParams(
|
||||
httptest.NewRequest(http.MethodPost, "/transcode/"+sessionID+"/segment/"+segmentName+"/downloaded", nil),
|
||||
map[string]string{"session_id": sessionID, "name": segmentName},
|
||||
)
|
||||
ackReq.Header.Set(transcodeproxy.GenerationHeader, generation)
|
||||
ackRR := httptest.NewRecorder()
|
||||
server.handleSegmentDownloaded(ackRR, ackReq)
|
||||
if ackRR.Code != http.StatusNoContent {
|
||||
t.Fatalf("ack status = %d, body = %q", ackRR.Code, ackRR.Body.String())
|
||||
}
|
||||
if got := session.LastRequestedSegment(); got != 7 {
|
||||
t.Fatalf("acknowledged segment = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedSegmentCompletionRejectsStaleGeneration(t *testing.T) {
|
||||
truePath, err := exec.LookPath("true")
|
||||
if err != nil {
|
||||
t.Skipf("`true` not found in PATH: %v", err)
|
||||
}
|
||||
|
||||
const sessionID = "stale-proxy-completion-session"
|
||||
server := newTestServer(t)
|
||||
session, err := playback.StartTranscode(context.Background(), playback.TranscodeOpts{
|
||||
SessionID: sessionID,
|
||||
OutputDir: t.TempDir(),
|
||||
FFmpegPath: truePath,
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTranscode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = session.Close() })
|
||||
server.sessions[sessionID] = session
|
||||
server.lastAccess[sessionID] = time.Now()
|
||||
|
||||
const segmentName = "seg_00007.ts"
|
||||
ackReq := withNodeRouteParams(
|
||||
httptest.NewRequest(http.MethodPost, "/transcode/"+sessionID+"/segment/"+segmentName+"/downloaded", nil),
|
||||
map[string]string{"session_id": sessionID, "name": segmentName},
|
||||
)
|
||||
ackReq.Header.Set(transcodeproxy.GenerationHeader, "999")
|
||||
ackRR := httptest.NewRecorder()
|
||||
server.handleSegmentDownloaded(ackRR, ackReq)
|
||||
if ackRR.Code != http.StatusNoContent {
|
||||
t.Fatalf("ack status = %d, body = %q", ackRR.Code, ackRR.Body.String())
|
||||
}
|
||||
if got := session.LastRequestedSegment(); got != 0 {
|
||||
t.Fatalf("stale acknowledgement advanced segment to %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func withNodeRouteParams(req *http.Request, values map[string]string) *http.Request {
|
||||
routeCtx := chi.NewRouteContext()
|
||||
for key, value := range values {
|
||||
routeCtx.URLParams.Add(key, value)
|
||||
}
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
func TestHandleStartRequireReadyRejectsExitedFFmpeg(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
ffmpegPath := filepath.Join(t.TempDir(), "failing-ffmpeg.sh")
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// Package transcodeproxy owns the private HTTP completion contract shared by
|
||||
// Silo's integrated API proxy and its dedicated proxy node.
|
||||
package transcodeproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// RequestHeader marks a segment hop whose immediate consumer is another
|
||||
// Silo server, not the playback client. The transcode node defers download
|
||||
// accounting until that server confirms downstream completion.
|
||||
RequestHeader = "X-Silo-Transcode-Proxy"
|
||||
// GenerationHeader carries an opaque session-incarnation and FFmpeg-timeline
|
||||
// token. It is private to Silo hops and must not be forwarded to the client.
|
||||
GenerationHeader = "X-Silo-Transcode-Segment-Generation"
|
||||
)
|
||||
|
||||
var representationRequestHeaders = []string{
|
||||
"Range",
|
||||
"If-Range",
|
||||
"If-Match",
|
||||
"If-None-Match",
|
||||
"If-Modified-Since",
|
||||
"If-Unmodified-Since",
|
||||
}
|
||||
|
||||
// PrepareRequest makes the upstream node serve the same byte representation
|
||||
// requested by the downstream client and suppresses completion at this hop.
|
||||
func PrepareRequest(upstream, downstream *http.Request) {
|
||||
upstream.Header.Set(RequestHeader, "1")
|
||||
for _, name := range representationRequestHeaders {
|
||||
for _, value := range downstream.Header.Values(name) {
|
||||
upstream.Header.Add(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CopyResponseHeaders forwards public response metadata while retaining the
|
||||
// generation token inside the trusted Silo hop.
|
||||
func CopyResponseHeaders(dst, src http.Header) {
|
||||
for name, values := range src {
|
||||
if http.CanonicalHeaderKey(name) == GenerationHeader {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
dst.Add(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FullRepresentationSize resolves the total entity size needed to distinguish
|
||||
// a whole-file 206 response from an ordinary partial range.
|
||||
func FullRepresentationSize(resp *http.Response) int64 {
|
||||
if resp == nil {
|
||||
return -1
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return resp.ContentLength
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
return -1
|
||||
}
|
||||
_, total, ok := strings.Cut(resp.Header.Get("Content-Range"), "/")
|
||||
if !ok || total == "*" {
|
||||
return -1
|
||||
}
|
||||
size, err := strconv.ParseInt(total, 10, 64)
|
||||
if err != nil || size <= 0 {
|
||||
return -1
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// Acknowledge reports a completed downstream response to the transcode node.
|
||||
// The opaque generation token makes delayed acknowledgements harmless after a
|
||||
// restart or reconstruction.
|
||||
func Acknowledge(ctx context.Context, client *http.Client, targetURL, jwtSecret, generation string) error {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
// The downstream request is commonly canceled as soon as its response body
|
||||
// reaches the client, while the handler is still issuing this acknowledgement.
|
||||
// Preserve request-scoped values, but give the post-response hop its own
|
||||
// bounded lifetime so a successful delivery is not mistaken for a disconnect.
|
||||
ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ackCtx, http.MethodPost, targetURL+"/downloaded", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build acknowledgement: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+jwtSecret)
|
||||
req.Header.Set(GenerationHeader, generation)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send acknowledgement: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("acknowledgement status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package transcodeproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAcknowledgeSurvivesCompletedDownstreamRequest(t *testing.T) {
|
||||
const (
|
||||
jwtSecret = "test-secret"
|
||||
generation = "incarnation:generation"
|
||||
)
|
||||
|
||||
acknowledged := make(chan struct{}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+jwtSecret {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
}
|
||||
if got := r.Header.Get(GenerationHeader); got != generation {
|
||||
t.Errorf("generation = %q, want %q", got, generation)
|
||||
}
|
||||
acknowledged <- struct{}{}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
requestCtx, cancelRequest := context.WithCancel(context.Background())
|
||||
cancelRequest()
|
||||
if err := Acknowledge(requestCtx, server.Client(), server.URL+"/segment", jwtSecret, generation); err != nil {
|
||||
t.Fatalf("Acknowledge() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case <-acknowledged:
|
||||
default:
|
||||
t.Fatal("acknowledgement request was not delivered")
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ function isCapturedProfileAuthorityActive(snapshot: ProfileRequestContextSnapsho
|
||||
);
|
||||
}
|
||||
|
||||
function getOrCreateDeviceId(): string {
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const existing = storage.get(storage.KEYS.DEVICE_ID);
|
||||
if (existing) {
|
||||
return existing;
|
||||
|
||||
@@ -1714,6 +1714,19 @@
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
/* Panel chrome only once there is a sidebar to enclose. Below this the
|
||||
settings detail view is already a stack of surface-panel groups, and a
|
||||
panel around them would nest a card in a card. */
|
||||
@media (min-width: 1024px) {
|
||||
.surface-panel-lg {
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
border-radius: calc(var(--radius-2xl) + 0.35rem);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.04),
|
||||
0 24px 50px -34px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.caption-empty-state span {
|
||||
display: block;
|
||||
height: 0.35rem;
|
||||
|
||||
@@ -11,6 +11,10 @@ const mocks = vi.hoisted(() => ({
|
||||
prefetchQuery: vi.fn(),
|
||||
renderSurface: true,
|
||||
beginResult: undefined as boolean | undefined,
|
||||
profile: {
|
||||
name: "Admin",
|
||||
avatar_url: "https://example.com/admin-avatar.webp",
|
||||
} as { name: string; avatar_url?: string },
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
@@ -30,7 +34,7 @@ vi.mock("@tanstack/react-query", async () => {
|
||||
|
||||
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { username: "Admin" } }) }));
|
||||
vi.mock("@/hooks/useCurrentProfile", () => ({
|
||||
useCurrentProfile: () => ({ profile: { name: "Admin" } }),
|
||||
useCurrentProfile: () => ({ profile: mocks.profile }),
|
||||
}));
|
||||
vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => false }));
|
||||
vi.mock("@/playback/watchPlaybackContext", () => ({
|
||||
@@ -43,6 +47,11 @@ vi.mock("@/hooks/queries/catalogRead", () => ({ fetchCatalogItemDetail: vi.fn()
|
||||
vi.mock("@/components/GlobalSearch", () => ({ GlobalSearch: () => null }));
|
||||
vi.mock("@/components/ServerActivity", () => ({ default: () => null }));
|
||||
vi.mock("@/components/SiloBrand", () => ({ SiloBrand: () => <span>Silo</span> }));
|
||||
vi.mock("@/components/ui/avatar", () => ({
|
||||
Avatar: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AvatarImage: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
AvatarFallback: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
vi.mock("@/components/ViewTransitionLink", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <a href="/">{children}</a>,
|
||||
}));
|
||||
@@ -121,6 +130,10 @@ beforeEach(() => {
|
||||
mocks.prefetchQuery.mockReset();
|
||||
mocks.renderSurface = true;
|
||||
mocks.beginResult = undefined;
|
||||
mocks.profile = {
|
||||
name: "Admin",
|
||||
avatar_url: "https://example.com/admin-avatar.webp",
|
||||
};
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query === "(min-width: 64rem)",
|
||||
media: query,
|
||||
@@ -140,6 +153,18 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Layout mobile profile", () => {
|
||||
it("renders the current profile avatar in the settings link", () => {
|
||||
renderLayout();
|
||||
|
||||
const settingsLink = screen.getByRole("link", { name: "Admin settings" });
|
||||
const avatar = screen.getByRole("img", { name: "Admin" });
|
||||
|
||||
expect(settingsLink).toContainElement(avatar);
|
||||
expect(avatar).toHaveAttribute("src", "https://example.com/admin-avatar.webp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout item navigation", () => {
|
||||
it("declines interception on an item route, for a non-item href, and below lg", () => {
|
||||
const view = renderLayout();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Menu, Search } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
@@ -267,11 +268,19 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{showAdminActivity && <ServerActivity hideWhenEmpty />}
|
||||
<Link
|
||||
to="/settings"
|
||||
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
|
||||
aria-label={`${profile?.name ?? user?.username ?? "User"} settings`}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-transform active:scale-[0.98]"
|
||||
>
|
||||
{profile?.name?.charAt(0).toUpperCase() ??
|
||||
user?.username?.charAt(0).toUpperCase() ??
|
||||
"?"}
|
||||
<Avatar className="h-9 w-9 shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]">
|
||||
{profile?.avatar_url ? (
|
||||
<AvatarImage src={profile.avatar_url} alt={profile.name} />
|
||||
) : null}
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-bold">
|
||||
{profile?.name?.charAt(0).toUpperCase() ??
|
||||
user?.username?.charAt(0).toUpperCase() ??
|
||||
"?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
@@ -35,14 +34,7 @@ export function SettingsOverviewNav({
|
||||
idPrefix,
|
||||
variant = "grouped-list",
|
||||
}: SettingsOverviewNavProps) {
|
||||
const firstGroupId = groups[0] ? groupSectionId(idPrefix, groups[0].label) : "";
|
||||
const [activeGroupId, setActiveGroupId] = useState(firstGroupId);
|
||||
const isDirectory = variant === "directory";
|
||||
const resolvedActiveGroupId = groups.some(
|
||||
(group) => groupSectionId(idPrefix, group.label) === activeGroupId,
|
||||
)
|
||||
? activeGroupId
|
||||
: firstGroupId;
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
@@ -55,136 +47,96 @@ export function SettingsOverviewNav({
|
||||
);
|
||||
}
|
||||
|
||||
// No category jump bar: it listed the same group names, with the same
|
||||
// counts, directly above the headings that repeat them — and on an index
|
||||
// this short it saved about one scroll flick.
|
||||
return (
|
||||
<div className={cn(isDirectory && "lg:space-y-8")}>
|
||||
{isDirectory ? (
|
||||
<nav
|
||||
aria-label={`${ariaLabel} categories`}
|
||||
className="surface-panel-subtle hidden max-w-2xl overflow-hidden rounded-xl border lg:flex"
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const sectionId = groupSectionId(idPrefix, group.label);
|
||||
const isActive = sectionId === resolvedActiveGroupId;
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"grid items-start gap-6 lg:grid-cols-2",
|
||||
isDirectory && "block space-y-6 lg:space-y-10",
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const sectionId = groupSectionId(idPrefix, group.label);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={group.label}
|
||||
href={`#${sectionId}`}
|
||||
aria-label={`${group.label}, ${group.items.length} settings sections`}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
onClick={() => setActiveGroupId(sectionId)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-surface-hover/60 hover:text-foreground focus-visible:ring-ring/60 relative flex min-h-11 flex-1 items-center justify-center gap-2 px-4 text-sm font-medium transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:outline-none focus-visible:ring-inset",
|
||||
isActive &&
|
||||
"text-foreground after:bg-primary after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:rounded-full",
|
||||
)}
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"grid items-start gap-6 lg:grid-cols-2",
|
||||
isDirectory && "block space-y-6 lg:space-y-10",
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const sectionId = groupSectionId(idPrefix, group.label);
|
||||
|
||||
return (
|
||||
<section
|
||||
key={group.label}
|
||||
aria-labelledby={sectionId}
|
||||
className={cn(isDirectory && "scroll-mt-6")}
|
||||
return (
|
||||
<section key={group.label} aria-labelledby={sectionId}>
|
||||
<h2
|
||||
id={sectionId}
|
||||
className={cn(
|
||||
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
|
||||
isDirectory &&
|
||||
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
id={sectionId}
|
||||
className={cn(
|
||||
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
|
||||
isDirectory &&
|
||||
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
{isDirectory ? (
|
||||
<span className="text-muted-foreground ml-1.5 hidden font-medium lg:inline">
|
||||
({group.items.length})
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<ul
|
||||
className={cn(
|
||||
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
|
||||
isDirectory &&
|
||||
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
{group.label}
|
||||
</h2>
|
||||
<ul
|
||||
className={cn(
|
||||
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
|
||||
isDirectory &&
|
||||
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"border-border/60 border-b last:border-b-0",
|
||||
isDirectory && "lg:border-0",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"border-border/60 border-b last:border-b-0",
|
||||
isDirectory && "lg:border-0",
|
||||
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
|
||||
isDirectory &&
|
||||
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
<span
|
||||
className={cn(
|
||||
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
|
||||
isDirectory &&
|
||||
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
|
||||
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
|
||||
isDirectory && "lg:h-10 lg:w-10",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
|
||||
isDirectory && "lg:h-10 lg:w-10",
|
||||
"block text-sm font-semibold",
|
||||
isDirectory && "lg:text-[15px]",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-semibold",
|
||||
isDirectory && "lg:text-[15px]",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground mt-0.5 block text-xs leading-snug",
|
||||
isDirectory && "lg:line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground mt-0.5 block text-xs leading-snug",
|
||||
isDirectory && "lg:line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LibraryCollection } from "@/api/types";
|
||||
import { buildAllUserCollectionOptions } from "./useAllUserCollections";
|
||||
|
||||
function libraryCollection(id: string, title: string): LibraryCollection {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
collection_type: "smart",
|
||||
source_config: {},
|
||||
last_sync_status: "success",
|
||||
} as LibraryCollection;
|
||||
}
|
||||
|
||||
describe("buildAllUserCollectionOptions", () => {
|
||||
it("keeps personal collections first and identifies their source", () => {
|
||||
const options = buildAllUserCollectionOptions(
|
||||
[{ id: 1, name: "Movies" }],
|
||||
[{ id: "personal", name: "Weekend Picks" }],
|
||||
[[libraryCollection("library", "Staff Picks")]],
|
||||
);
|
||||
|
||||
expect(options.map(({ id, source, group }) => ({ id, source, group }))).toEqual([
|
||||
{ id: "personal", source: "user", group: "My Collections" },
|
||||
{ id: "library", source: "library", group: "Movies" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists a multi-library collection once with its combined scope", () => {
|
||||
const shared = libraryCollection("shared", "Network Originals");
|
||||
|
||||
const options = buildAllUserCollectionOptions(
|
||||
[
|
||||
{ id: 1, name: "Movies" },
|
||||
{ id: 2, name: "TV Shows" },
|
||||
],
|
||||
undefined,
|
||||
[[shared], [shared]],
|
||||
);
|
||||
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toMatchObject({
|
||||
id: "shared",
|
||||
title: "Network Originals",
|
||||
source: "library",
|
||||
group: "Movies, TV Shows",
|
||||
library_name: "Movies, TV Shows",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps separate same-titled collections distinct", () => {
|
||||
const options = buildAllUserCollectionOptions(
|
||||
[
|
||||
{ id: 1, name: "Movies" },
|
||||
{ id: 2, name: "TV Shows" },
|
||||
],
|
||||
undefined,
|
||||
[
|
||||
[libraryCollection("movies", "Network Originals")],
|
||||
[libraryCollection("shows", "Network Originals")],
|
||||
],
|
||||
);
|
||||
|
||||
expect(options.map(({ id, group }) => ({ id, group }))).toEqual([
|
||||
{ id: "movies", group: "Movies" },
|
||||
{ id: "shows", group: "TV Shows" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import type { LibraryCollection } from "@/api/types";
|
||||
import type { Collection, LibraryCollection } from "@/api/types";
|
||||
import { useUserLibraries } from "./libraries";
|
||||
import { useCollections } from "./collections";
|
||||
import { getLibraryCollectionList, libraryCollectionsQueryOptions } from "./libraryCollections";
|
||||
@@ -16,6 +16,60 @@ export interface CollectionOption {
|
||||
last_sync_status?: LibraryCollection["last_sync_status"];
|
||||
}
|
||||
|
||||
type LibrarySummary = { id: number; name: string };
|
||||
type UserCollectionSummary = Pick<Collection, "id" | "name">;
|
||||
|
||||
export function buildAllUserCollectionOptions(
|
||||
libraries: readonly LibrarySummary[],
|
||||
userCollections: readonly UserCollectionSummary[] | undefined,
|
||||
libraryCollectionsByLibrary: ReadonlyArray<readonly LibraryCollection[] | undefined>,
|
||||
): CollectionOption[] {
|
||||
const collections: CollectionOption[] = [];
|
||||
|
||||
for (const collection of userCollections ?? []) {
|
||||
collections.push({
|
||||
id: collection.id,
|
||||
title: collection.name,
|
||||
source: "user",
|
||||
group: "My Collections",
|
||||
});
|
||||
}
|
||||
|
||||
const libraryOptions = new Map<string, { option: CollectionOption; libraryNames: string[] }>();
|
||||
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const library = libraries[i]!;
|
||||
for (const collection of libraryCollectionsByLibrary[i] ?? []) {
|
||||
const existing = libraryOptions.get(collection.id);
|
||||
if (existing) {
|
||||
if (!existing.libraryNames.includes(library.name)) {
|
||||
existing.libraryNames.push(library.name);
|
||||
const group = existing.libraryNames.join(", ");
|
||||
existing.option.group = group;
|
||||
existing.option.library_name = group;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const option: CollectionOption = {
|
||||
id: collection.id,
|
||||
title: collection.title,
|
||||
source: "library",
|
||||
group: library.name,
|
||||
library_id: library.id,
|
||||
library_name: library.name,
|
||||
collection_type: collection.collection_type,
|
||||
source_config: collection.source_config,
|
||||
last_sync_status: collection.last_sync_status,
|
||||
};
|
||||
libraryOptions.set(collection.id, { option, libraryNames: [library.name] });
|
||||
collections.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
export function useAllUserCollections() {
|
||||
const { data: libraries } = useUserLibraries();
|
||||
const { data: userCollections, isLoading: userCollectionsLoading } = useCollections();
|
||||
@@ -29,41 +83,14 @@ export function useAllUserCollections() {
|
||||
|
||||
const isLoading = libraryQueries.some((q) => q.isLoading) || userCollectionsLoading;
|
||||
|
||||
const collections: CollectionOption[] = [];
|
||||
|
||||
// Add personal user collections first (grouped under "My Collections").
|
||||
if (userCollections) {
|
||||
for (const c of userCollections) {
|
||||
collections.push({
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
source: "user",
|
||||
group: "My Collections",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add library collections grouped by library name.
|
||||
if (libraries) {
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const lib = libraries[i]!;
|
||||
const result = libraryQueries[i];
|
||||
const libraryCollections = Array.isArray(result?.data) ? result.data : [];
|
||||
for (const c of libraryCollections) {
|
||||
collections.push({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
source: "library",
|
||||
group: lib.name,
|
||||
library_id: lib.id,
|
||||
library_name: lib.name,
|
||||
collection_type: c.collection_type,
|
||||
source_config: c.source_config,
|
||||
last_sync_status: c.last_sync_status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const libraryCollectionsByLibrary = libraryQueries.map((result) =>
|
||||
Array.isArray(result.data) ? result.data : undefined,
|
||||
);
|
||||
const collections = buildAllUserCollectionOptions(
|
||||
libraries ?? [],
|
||||
userCollections,
|
||||
libraryCollectionsByLibrary,
|
||||
);
|
||||
|
||||
return { collections, isLoading };
|
||||
}
|
||||
|
||||
@@ -49,16 +49,61 @@ describe("SettingsLayout", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Playback" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Library & Data" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Account" })).toBeInTheDocument();
|
||||
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
|
||||
expect(screen.getByRole("heading", { name: group })).toBeInTheDocument();
|
||||
}
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Playback.*Quality, language, and skipping/ }),
|
||||
screen.getByRole("link", { name: /Playback.*Quality, languages, skipping/ }),
|
||||
).toHaveAttribute("href", "/settings/playback");
|
||||
expect(screen.getByRole("link", { name: /Connect Apps.*Sign-in details/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names each settings group exactly once", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The category jump bar used to repeat every group name and count directly
|
||||
// above the headings that already carry them.
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Settings sections categories" }),
|
||||
).not.toBeInTheDocument();
|
||||
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
|
||||
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one desktop grid and card geometry for every settings group", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Five groups, seventeen sections — every card the same height so no group
|
||||
// is visually ranked above another.
|
||||
expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(5);
|
||||
expect(markup.match(/lg:h-28/g)).toHaveLength(17);
|
||||
expect(markup).not.toContain("max-w-5xl");
|
||||
});
|
||||
|
||||
it("keeps each settings section in exactly one group", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
for (const path of ["devices", "libraries", "watch-providers", "profiles"]) {
|
||||
expect(markup.match(new RegExp(`href="/settings/${path}"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a clear return to the settings index from detail pages", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/playback"]}>
|
||||
|
||||
+272
-257
@@ -61,6 +61,13 @@ const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }))
|
||||
*/
|
||||
const WIDE_SETTINGS_PAGES = new Set(["devices"]);
|
||||
|
||||
/**
|
||||
* Grouped by the question a person arrives with, not by which service stores
|
||||
* the value: "how does it play", "how does it look", "what do I see", "what is
|
||||
* it wired to", "who am I". Devices sits under Playback because every setting
|
||||
* on that screen is a playback override, and Personalize sits with Home Screen
|
||||
* because both shape what the app puts in front of you.
|
||||
*/
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: "Playback",
|
||||
@@ -69,7 +76,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
path: "playback",
|
||||
label: "Playback",
|
||||
icon: Play,
|
||||
description: "Quality, language, and skipping",
|
||||
description: "Quality, languages, skipping, and what plays next.",
|
||||
keywords: [
|
||||
"video quality",
|
||||
"bitrate",
|
||||
@@ -98,7 +105,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
path: "subtitle-appearance",
|
||||
label: "Subtitles",
|
||||
icon: Subtitles,
|
||||
description: "Language, behavior, and style",
|
||||
description: "Subtitle language, when they appear, and how they look.",
|
||||
keywords: [
|
||||
"subtitle language",
|
||||
"forced subtitles",
|
||||
@@ -124,215 +131,11 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
"Subtitle position",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Appearance",
|
||||
items: [
|
||||
{
|
||||
path: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Theme and interface tone",
|
||||
keywords: [
|
||||
"theme",
|
||||
"profile theme",
|
||||
"dark",
|
||||
"light",
|
||||
"custom theme",
|
||||
"date format",
|
||||
"time format",
|
||||
"clock",
|
||||
"24-hour",
|
||||
"12-hour",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Theme",
|
||||
"Date & time",
|
||||
"Date format",
|
||||
"Time format",
|
||||
"Current selection",
|
||||
"Reset to Cinema Dark",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "interface",
|
||||
label: "Navigation & Cards",
|
||||
icon: PanelTop,
|
||||
description: "Menus, poster size, and captions",
|
||||
keywords: [
|
||||
"navigation",
|
||||
"menu",
|
||||
"pin library",
|
||||
"poster size",
|
||||
"card size",
|
||||
"hide title",
|
||||
"hide year",
|
||||
"artwork only",
|
||||
"preset",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Card preset",
|
||||
"Poster size",
|
||||
"Caption",
|
||||
"Title & metadata",
|
||||
"Title only",
|
||||
"Artwork only",
|
||||
"Primary menu",
|
||||
"Choose destination or shortcut",
|
||||
"Add to menu",
|
||||
"Reset to default",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "theme-editor",
|
||||
label: "Theme Editor",
|
||||
icon: Wand2,
|
||||
description: "Customize colors and CSS",
|
||||
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
|
||||
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
|
||||
},
|
||||
{
|
||||
path: "accessibility",
|
||||
label: "Accessibility",
|
||||
icon: Eye,
|
||||
description: "Readability and contrast",
|
||||
keywords: ["contrast", "readability", "motion", "transparency", "text"],
|
||||
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
|
||||
},
|
||||
{
|
||||
path: "home-screen",
|
||||
label: "Home Screen",
|
||||
icon: LayoutDashboard,
|
||||
description: "Sections and layout",
|
||||
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
|
||||
settings: settingIndex(
|
||||
"Scope",
|
||||
"Sections",
|
||||
"Reset section customizations",
|
||||
"Continue Watching",
|
||||
"Next Up",
|
||||
"Recently Added",
|
||||
"Library order",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "card-overlays",
|
||||
label: "Card Overlays",
|
||||
icon: Layers,
|
||||
description: "Badges on poster cards",
|
||||
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
|
||||
settings: settingIndex(
|
||||
"Preview",
|
||||
"Preset",
|
||||
"Accent color",
|
||||
"Show icon",
|
||||
"Position",
|
||||
"How styling works",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "personalize",
|
||||
label: "Personalize",
|
||||
icon: Sparkles,
|
||||
description: "Re-tune your taste profile",
|
||||
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
|
||||
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Library & Data",
|
||||
items: [
|
||||
{
|
||||
path: "libraries",
|
||||
label: "Libraries",
|
||||
icon: Library,
|
||||
description: "Visibility and access",
|
||||
keywords: [
|
||||
"library visibility",
|
||||
"access",
|
||||
"disabled libraries",
|
||||
"library order",
|
||||
"playback preferences",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Remember library pages",
|
||||
"Library visibility",
|
||||
"Library order",
|
||||
"Spoken language",
|
||||
"Subtitle language",
|
||||
"Subtitle behavior",
|
||||
"Forced subtitles",
|
||||
"Playback preferences",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "history-import",
|
||||
label: "History Import",
|
||||
icon: Clock,
|
||||
description: "Emby watch history",
|
||||
keywords: ["emby", "watched history", "import", "mapping", "sync"],
|
||||
settings: settingIndex(
|
||||
"New import",
|
||||
"Import history",
|
||||
"Fetched",
|
||||
"Matched",
|
||||
"Unmatched",
|
||||
"Progress",
|
||||
"History",
|
||||
"Skipped",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "webhook-sync",
|
||||
label: "Webhook Sync",
|
||||
icon: Server,
|
||||
description: "Plex, Emby, and Jellyfin webhook intake",
|
||||
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
|
||||
settings: settingIndex(
|
||||
"Add a connection",
|
||||
"Connected servers",
|
||||
"Recent deliveries",
|
||||
"Plex",
|
||||
"Emby",
|
||||
"Jellyfin",
|
||||
"Server URL",
|
||||
"Token",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "watch-providers",
|
||||
label: "Watch Providers",
|
||||
icon: Cloud,
|
||||
description: "Trakt watch history and scrobbling",
|
||||
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
|
||||
settings: settingIndex(
|
||||
"Last imported",
|
||||
"Last exported",
|
||||
"Watched",
|
||||
"Progress",
|
||||
"Favorites",
|
||||
"Exported",
|
||||
"Import watched history",
|
||||
"Import paused progress",
|
||||
"Send watched changes",
|
||||
"Send unwatched changes",
|
||||
"Sync favorites",
|
||||
"Sync favorite removals",
|
||||
"Scrobble playback",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
items: [
|
||||
{
|
||||
path: "devices",
|
||||
label: "Your Devices",
|
||||
icon: MonitorSmartphone,
|
||||
description: "Settings for each device you watch on",
|
||||
description: "Per-device quality, HDR, and audio or subtitle sync.",
|
||||
keywords: [
|
||||
"devices",
|
||||
"tv",
|
||||
@@ -359,28 +162,159 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
"Forget this device",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Appearance",
|
||||
items: [
|
||||
{
|
||||
path: "notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
description: "New-episode alerts and webhooks",
|
||||
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
|
||||
path: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Theme, interface tone, and date and time formats.",
|
||||
keywords: [
|
||||
"theme",
|
||||
"profile theme",
|
||||
"dark",
|
||||
"light",
|
||||
"custom theme",
|
||||
"date format",
|
||||
"time format",
|
||||
"clock",
|
||||
"24-hour",
|
||||
"12-hour",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"New Episode Notifications",
|
||||
"Email Notifications",
|
||||
"Discord Notifications",
|
||||
"Browser Notifications",
|
||||
"Webhooks",
|
||||
"Per-episode alerts",
|
||||
"Digest",
|
||||
"Webhook URL",
|
||||
"Theme",
|
||||
"Date & time",
|
||||
"Date format",
|
||||
"Time format",
|
||||
"Current selection",
|
||||
"Reset to Cinema Dark",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "interface",
|
||||
label: "Navigation & Cards",
|
||||
icon: PanelTop,
|
||||
description: "Your primary menu, poster size, and card captions.",
|
||||
keywords: [
|
||||
"navigation",
|
||||
"menu",
|
||||
"pin library",
|
||||
"poster size",
|
||||
"card size",
|
||||
"hide title",
|
||||
"hide year",
|
||||
"artwork only",
|
||||
"preset",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Card preset",
|
||||
"Poster size",
|
||||
"Caption",
|
||||
"Title & metadata",
|
||||
"Title only",
|
||||
"Artwork only",
|
||||
"Primary menu",
|
||||
"Choose destination or shortcut",
|
||||
"Add to menu",
|
||||
"Reset to default",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "card-overlays",
|
||||
label: "Card Overlays",
|
||||
icon: Layers,
|
||||
description: "Badges drawn on poster cards, and where they sit.",
|
||||
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
|
||||
settings: settingIndex(
|
||||
"Preview",
|
||||
"Preset",
|
||||
"Accent color",
|
||||
"Show icon",
|
||||
"Position",
|
||||
"How styling works",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "accessibility",
|
||||
label: "Accessibility",
|
||||
icon: Eye,
|
||||
description: "Text size, weight, and contrast for easier reading.",
|
||||
keywords: ["contrast", "readability", "motion", "transparency", "text"],
|
||||
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
|
||||
},
|
||||
{
|
||||
path: "theme-editor",
|
||||
label: "Theme Editor",
|
||||
icon: Wand2,
|
||||
description: "Fine-tune theme colors and add your own CSS.",
|
||||
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
|
||||
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Home & Discovery",
|
||||
items: [
|
||||
{
|
||||
path: "home-screen",
|
||||
label: "Home Screen",
|
||||
icon: LayoutDashboard,
|
||||
description: "Which rows appear on Home, and in what order.",
|
||||
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
|
||||
settings: settingIndex(
|
||||
"Scope",
|
||||
"Sections",
|
||||
"Reset section customizations",
|
||||
"Continue Watching",
|
||||
"Next Up",
|
||||
"Recently Added",
|
||||
"Library order",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "personalize",
|
||||
label: "Personalize",
|
||||
icon: Sparkles,
|
||||
description: "Re-tune the taste profile behind your recommendations.",
|
||||
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
|
||||
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
|
||||
},
|
||||
{
|
||||
path: "libraries",
|
||||
label: "Libraries",
|
||||
icon: Library,
|
||||
description: "Which libraries you see, their order, and per-library audio.",
|
||||
keywords: [
|
||||
"library visibility",
|
||||
"access",
|
||||
"disabled libraries",
|
||||
"library order",
|
||||
"playback preferences",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Remember library pages",
|
||||
"Library visibility",
|
||||
"Library order",
|
||||
"Spoken language",
|
||||
"Subtitle language",
|
||||
"Subtitle behavior",
|
||||
"Forced subtitles",
|
||||
"Playback preferences",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Connections",
|
||||
items: [
|
||||
{
|
||||
path: "connect-apps",
|
||||
label: "Connect Apps",
|
||||
icon: Cast,
|
||||
description: "Sign-in details for other apps",
|
||||
description: "Sign-in details for Silo and Jellyfin-compatible apps.",
|
||||
keywords: [
|
||||
"jellyfin",
|
||||
"infuse",
|
||||
@@ -405,11 +339,72 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
"Every profile at a glance",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "watch-providers",
|
||||
label: "Watch Providers",
|
||||
icon: Cloud,
|
||||
description: "Trakt watch history, favorites, and scrobbling.",
|
||||
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
|
||||
settings: settingIndex(
|
||||
"Last imported",
|
||||
"Last exported",
|
||||
"Watched",
|
||||
"Progress",
|
||||
"Favorites",
|
||||
"Exported",
|
||||
"Import watched history",
|
||||
"Import paused progress",
|
||||
"Send watched changes",
|
||||
"Send unwatched changes",
|
||||
"Sync favorites",
|
||||
"Sync favorite removals",
|
||||
"Scrobble playback",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "webhook-sync",
|
||||
label: "Webhook Sync",
|
||||
icon: Server,
|
||||
description: "Take progress from Plex, Emby, and Jellyfin webhooks.",
|
||||
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
|
||||
settings: settingIndex(
|
||||
"Add a connection",
|
||||
"Connected servers",
|
||||
"Recent deliveries",
|
||||
"Plex",
|
||||
"Emby",
|
||||
"Jellyfin",
|
||||
"Server URL",
|
||||
"Token",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "history-import",
|
||||
label: "History Import",
|
||||
icon: Clock,
|
||||
description: "Bring an existing Emby watch history into Silo.",
|
||||
keywords: ["emby", "watched history", "import", "mapping", "sync"],
|
||||
settings: settingIndex(
|
||||
"New import",
|
||||
"Import history",
|
||||
"Fetched",
|
||||
"Matched",
|
||||
"Unmatched",
|
||||
"Progress",
|
||||
"History",
|
||||
"Skipped",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
items: [
|
||||
{
|
||||
path: "profiles",
|
||||
label: "Profiles",
|
||||
icon: Users,
|
||||
description: "Names, PINs, and access rules",
|
||||
description: "Household profile names, PINs, and library access.",
|
||||
keywords: ["profile name", "pin", "access", "primary profile", "household"],
|
||||
settings: settingIndex(
|
||||
"Profile name",
|
||||
@@ -421,6 +416,23 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
),
|
||||
primaryOrAdmin: true,
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
description: "New-episode alerts by email, Discord, push, or webhook.",
|
||||
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
|
||||
settings: settingIndex(
|
||||
"New Episode Notifications",
|
||||
"Email Notifications",
|
||||
"Discord Notifications",
|
||||
"Browser Notifications",
|
||||
"Webhooks",
|
||||
"Per-episode alerts",
|
||||
"Digest",
|
||||
"Webhook URL",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -428,24 +440,21 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
interface SettingsOverviewProps {
|
||||
sections: readonly { label: string; items: readonly NavItem[] }[];
|
||||
profile: { name: string; avatar_url?: string } | null;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
function SettingsOverview({
|
||||
sections,
|
||||
profile,
|
||||
search,
|
||||
onSearchChange,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}: SettingsOverviewProps) {
|
||||
/**
|
||||
* The index is the same directory the admin settings index uses: category jump
|
||||
* links, then one uniform card grid per group across the full page width.
|
||||
*
|
||||
* The two-column list it replaced paired groups side by side, so a short group
|
||||
* next to a long one left a column of dead space taller than the short group
|
||||
* itself — the exact shape of the desktop complaint.
|
||||
*/
|
||||
function SettingsOverview({ sections, profile }: SettingsOverviewProps) {
|
||||
const profileName = profile?.name ?? "Your profile";
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 w-full max-w-5xl space-y-6 sm:mt-8">
|
||||
<div className="w-full space-y-6">
|
||||
<Link
|
||||
to="/profiles"
|
||||
aria-label={`Current profile: ${profileName}`}
|
||||
@@ -464,14 +473,6 @@ function SettingsOverview({
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
|
||||
<SettingsSearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
className="w-full sm:max-w-md"
|
||||
/>
|
||||
|
||||
<SettingsOverviewNav
|
||||
groups={sections.map((section) => ({
|
||||
...section,
|
||||
@@ -485,6 +486,7 @@ function SettingsOverview({
|
||||
}))}
|
||||
ariaLabel="Settings sections"
|
||||
idPrefix="settings-index"
|
||||
variant="directory"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -555,9 +557,21 @@ export default function SettingsLayout() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 min-w-0 flex-1 lg:mt-10 lg:flex lg:gap-10">
|
||||
<aside className="hidden lg:block lg:w-[220px] lg:shrink-0">
|
||||
<nav aria-label="Settings sections" className="sticky top-6 space-y-5 pl-3">
|
||||
{/* From lg up, the rail and the active page share one panel, the
|
||||
same shell the admin settings use. Below lg the rail is hidden
|
||||
and the page is already a stack of its own panels, so the extra
|
||||
chrome would only nest a card inside a card — mobile keeps the
|
||||
bare layout.
|
||||
|
||||
Admin scrolls its detail pane inside the panel; here the page
|
||||
scrolls and the rail sticks, because Your Devices owns a
|
||||
viewport-height scroller that a nested one would strand. */}
|
||||
<div className="surface-panel-lg mt-5 flex min-w-0 flex-1 flex-col lg:mt-10 lg:min-h-[500px] lg:flex-row lg:overflow-hidden">
|
||||
<aside className="border-border hidden lg:block lg:w-60 lg:flex-shrink-0 lg:border-r">
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="sticky top-6 space-y-5 py-5 pr-3 pl-5"
|
||||
>
|
||||
{filteredSections.map((section) => (
|
||||
<SideNavSection
|
||||
key={section.label}
|
||||
@@ -581,8 +595,8 @@ export default function SettingsLayout() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-8 lg:pt-0">
|
||||
<div className={cn("mx-auto w-full", wideSetting ? "max-w-6xl" : "max-w-3xl")}>
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-6">
|
||||
<div className={cn("w-full", wideSetting ? "max-w-none" : "max-w-3xl")}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,22 +605,23 @@ export default function SettingsLayout() {
|
||||
) : (
|
||||
<>
|
||||
<PageBack to="/" preferHistory={false} floating />
|
||||
<div className="page-header mt-10 gap-5 sm:mt-12">
|
||||
<div className="page-header mt-10 mb-6 gap-5 sm:mt-12 sm:mb-8">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
|
||||
<p className="page-subtitle text-sm sm:text-base">
|
||||
Make Silo work the way you like.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSearchInput
|
||||
value={settingsSearch}
|
||||
onChange={setSettingsSearch}
|
||||
resultCount={filteredSettingsCount}
|
||||
totalCount={flatItems.length}
|
||||
className="w-full sm:max-w-sm lg:w-[26rem] lg:max-w-none"
|
||||
showShortcutHint
|
||||
/>
|
||||
</div>
|
||||
<SettingsOverview
|
||||
sections={filteredSections}
|
||||
profile={profile}
|
||||
search={settingsSearch}
|
||||
onSearchChange={setSettingsSearch}
|
||||
resultCount={filteredSettingsCount}
|
||||
totalCount={flatItems.length}
|
||||
/>
|
||||
<SettingsOverview sections={filteredSections} profile={profile} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -69,28 +69,20 @@ describe("AdminSettingsLayout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders desktop category jump links with section counts", () => {
|
||||
it("names each settings group exactly once", () => {
|
||||
renderInteractiveLayout();
|
||||
|
||||
const categoryNavigation = screen.getByRole("navigation", {
|
||||
name: "Admin settings sections categories",
|
||||
});
|
||||
|
||||
expect(categoryNavigation).toContainElement(
|
||||
screen.getByRole("link", { name: "Server, 4 settings sections" }),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Media, 7 settings sections" })).toHaveAttribute(
|
||||
"href",
|
||||
"#admin-settings-index-media",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Connections, 6 settings sections" })).toHaveAttribute(
|
||||
"href",
|
||||
"#admin-settings-index-connections",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Data, 3 settings sections" })).toHaveAttribute(
|
||||
"href",
|
||||
"#admin-settings-index-data",
|
||||
);
|
||||
// The category jump bar used to repeat every group name and count directly
|
||||
// above the headings that already carry them.
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Admin settings sections categories" }),
|
||||
).not.toBeInTheDocument();
|
||||
for (const group of ["Server", "Media", "Connections", "Data"]) {
|
||||
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one desktop grid and card geometry for every settings group", () => {
|
||||
@@ -102,19 +94,6 @@ describe("AdminSettingsLayout", () => {
|
||||
expect(markup.match(/lg:line-clamp-3/g)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("marks a selected category jump link as the current location", async () => {
|
||||
renderInteractiveLayout();
|
||||
|
||||
const server = screen.getByRole("link", { name: "Server, 4 settings sections" });
|
||||
const data = screen.getByRole("link", { name: "Data, 3 settings sections" });
|
||||
|
||||
expect(server).toHaveAttribute("aria-current", "location");
|
||||
await userEvent.click(data);
|
||||
|
||||
expect(server).not.toHaveAttribute("aria-current");
|
||||
expect(data).toHaveAttribute("aria-current", "location");
|
||||
});
|
||||
|
||||
it("renders every settings tab", () => {
|
||||
const markup = renderLayout();
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
const KEYS = [
|
||||
"playback.ffmpeg_path",
|
||||
"playback.transcode_dir",
|
||||
"playback.segment_retention_seconds",
|
||||
"playback.hw_accel",
|
||||
"playback.hw_device",
|
||||
"playback.transcode_enabled",
|
||||
@@ -198,13 +197,6 @@ export default function PlaybackSettings() {
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Segments">
|
||||
<SettingField
|
||||
label="Transcode Back Buffer (seconds)"
|
||||
type="number"
|
||||
hint="Keeps this much already-downloaded media for instant backward seeking, then reclaims older transcode segments. Use 0 to disable cleanup; enabled values must be at least 120 seconds. Pair with transcode throttling to bound both behind- and ahead-of-client disk usage."
|
||||
value={form.getValue("playback.segment_retention_seconds")}
|
||||
onChange={(v) => form.setValue("playback.segment_retention_seconds", v)}
|
||||
/>
|
||||
<SettingField
|
||||
label="Chapter Thumbnail Workers"
|
||||
type="number"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { getAccessToken, getProfileToken } from "@/api/client";
|
||||
import { getAccessToken, getOrCreateDeviceId, getProfileToken } from "@/api/client";
|
||||
import type { AudiobookFile } from "@/lib/audiobooks/types";
|
||||
import { PlayerConfigProvider, type PlayerConfig } from "@/player";
|
||||
import { storage } from "@/utils/storage";
|
||||
@@ -50,6 +50,7 @@ export function AudiobookPlaybackProvider({ children }: { children: ReactNode })
|
||||
getAccessToken: () => getAccessToken(),
|
||||
getProfileId: () => storage.get(storage.KEYS.PROFILE_ID),
|
||||
getProfileToken: () => getProfileToken(),
|
||||
getDeviceId: () => getOrCreateDeviceId(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -68,6 +68,7 @@ const playerConfig: PlayerConfig = {
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => "token",
|
||||
getProfileId: () => "profile-1",
|
||||
getDeviceId: () => "test-device",
|
||||
getProfileToken: () => null,
|
||||
};
|
||||
|
||||
@@ -100,6 +101,7 @@ function audioOnlyDecision(
|
||||
startPosition: number,
|
||||
timeline: Partial<{
|
||||
stream_origin_seconds: number;
|
||||
timeline_offset_seconds: number;
|
||||
player_start_seconds: number;
|
||||
can_seek_anywhere: boolean;
|
||||
}> = {},
|
||||
@@ -442,6 +444,7 @@ describe("useAudiobookPlayback", () => {
|
||||
return jsonResponse(
|
||||
audioOnlyDecision(`anchored-${sessionCount}`, body.start_position, {
|
||||
stream_origin_seconds: body.start_position,
|
||||
timeline_offset_seconds: body.start_position,
|
||||
player_start_seconds: 0,
|
||||
can_seek_anywhere: false,
|
||||
}),
|
||||
@@ -476,6 +479,108 @@ describe("useAudiobookPlayback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps native seeks with the contract timeline offset", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/playback/start")) {
|
||||
const body = JSON.parse(String(init?.body)) as { start_position: number };
|
||||
return jsonResponse(
|
||||
audioOnlyDecision("offset-contract", body.start_position, {
|
||||
stream_origin_seconds: 300,
|
||||
timeline_offset_seconds: 290,
|
||||
player_start_seconds: 10,
|
||||
can_seek_anywhere: true,
|
||||
}),
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/playback/route-events")) return new Response(null, { status: 202 });
|
||||
if (url.includes("/progress") || init?.method === "DELETE") {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderAudiobookPlayback({ initialPositionSeconds: 300 });
|
||||
const audio = makeAudio();
|
||||
act(() => {
|
||||
(result.current.audioRef as MutableRefObject<HTMLAudioElement>).current = audio;
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
act(() => result.current.seekTo(360));
|
||||
expect(audio.currentTime).toBe(70);
|
||||
});
|
||||
|
||||
it("preserves negative timeline offsets and clamps absolute buffered ranges", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/playback/start")) {
|
||||
const body = JSON.parse(String(init?.body)) as { start_position: number };
|
||||
return jsonResponse(
|
||||
audioOnlyDecision("negative-offset", body.start_position, {
|
||||
timeline_offset_seconds: -5,
|
||||
player_start_seconds: 5,
|
||||
can_seek_anywhere: true,
|
||||
}),
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/playback/route-events")) return new Response(null, { status: 202 });
|
||||
if (url.includes("/progress") || init?.method === "DELETE") {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
}),
|
||||
);
|
||||
|
||||
const onPlayback = vi.fn<(playback: AudiobookPlayback) => void>();
|
||||
|
||||
function Harness() {
|
||||
const playback = useAudiobookPlayback({
|
||||
contentId: "c",
|
||||
files,
|
||||
initialPositionSeconds: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
onPlayback(playback);
|
||||
}, [playback]);
|
||||
return createElement("audio", {
|
||||
ref: playback.audioRef,
|
||||
src: playback.streamUrl || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const { container } = render(createElement(Harness), { wrapper });
|
||||
await flushAsyncWork();
|
||||
const audio = container.querySelector("audio");
|
||||
if (!audio) throw new Error("expected audio element");
|
||||
Object.defineProperty(audio, "buffered", {
|
||||
configurable: true,
|
||||
value: {
|
||||
length: 1,
|
||||
start: () => 0,
|
||||
end: () => 8,
|
||||
} as TimeRanges,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
audio.currentTime = 7;
|
||||
fireEvent.timeUpdate(audio);
|
||||
fireEvent.progress(audio);
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(onPlayback.mock.lastCall?.[0].currentTime).toBe(2);
|
||||
expect(onPlayback.mock.lastCall?.[0].buffered?.start(0)).toBe(0);
|
||||
expect(onPlayback.mock.lastCall?.[0].buffered?.end(0)).toBe(3);
|
||||
});
|
||||
|
||||
it("togglePlay invokes audio.play when paused, audio.pause otherwise", () => {
|
||||
const { result } = renderAudiobookPlayback();
|
||||
const audio = makeAudio();
|
||||
|
||||
@@ -86,10 +86,11 @@ function safeNumber(value: number): number {
|
||||
|
||||
export function audiobookAbsoluteTime(
|
||||
partStartSeconds: number,
|
||||
streamOriginSeconds: number,
|
||||
timelineOffsetSeconds: number,
|
||||
playerSeconds: number,
|
||||
): number {
|
||||
return safeNumber(partStartSeconds) + safeNumber(streamOriginSeconds) + safeNumber(playerSeconds);
|
||||
const timelineOffset = Number.isFinite(timelineOffsetSeconds) ? timelineOffsetSeconds : 0;
|
||||
return Math.max(0, safeNumber(partStartSeconds) + timelineOffset + safeNumber(playerSeconds));
|
||||
}
|
||||
|
||||
function buildParts(files: AudiobookFile[]): AudiobookPart[] {
|
||||
@@ -145,20 +146,21 @@ function absoluteBufferedRanges(
|
||||
ranges: TimeRanges,
|
||||
part: AudiobookPart | undefined,
|
||||
bookDuration: number,
|
||||
streamOriginSeconds = 0,
|
||||
timelineOffsetSeconds = 0,
|
||||
): TimeRanges {
|
||||
if (!part) {
|
||||
return { length: 0, start: () => 0, end: () => 0 } as TimeRanges;
|
||||
}
|
||||
const timelineOffset = Number.isFinite(timelineOffsetSeconds) ? timelineOffsetSeconds : 0;
|
||||
const out: Array<{ start: number; end: number }> = [];
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const start = Math.min(
|
||||
bookDuration,
|
||||
part.start + streamOriginSeconds + safeNumber(ranges.start(i)),
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(bookDuration, part.start + timelineOffset + safeNumber(ranges.start(i))),
|
||||
);
|
||||
const end = Math.min(
|
||||
bookDuration,
|
||||
part.start + streamOriginSeconds + safeNumber(ranges.end(i)),
|
||||
const end = Math.max(
|
||||
0,
|
||||
Math.min(bookDuration, part.start + timelineOffset + safeNumber(ranges.end(i))),
|
||||
);
|
||||
if (end > start) {
|
||||
out.push({ start, end });
|
||||
@@ -258,7 +260,7 @@ export function useAudiobookPlayback({
|
||||
const attemptCountRef = useRef(1);
|
||||
const replanInFlightPlanKeyRef = useRef<string | null>(null);
|
||||
const failedPlanKeyRef = useRef<string | null>(null);
|
||||
const streamOriginSecondsRef = useRef(0);
|
||||
const timelineOffsetSecondsRef = useRef(0);
|
||||
const canSeekAnywhereRef = useRef(true);
|
||||
const reportRef = useRef<(pos: number) => void>(() => {});
|
||||
const reportSessionRef = useRef<(pos: number, isPaused: boolean, keepalive?: boolean) => void>(
|
||||
@@ -326,7 +328,7 @@ export function useAudiobookPlayback({
|
||||
planAttemptIdRef.current = planAttemptId;
|
||||
sessionIdRef.current = sessionId ?? null;
|
||||
failedPlanKeyRef.current = null;
|
||||
streamOriginSecondsRef.current = plan.timeline.stream_origin_seconds;
|
||||
timelineOffsetSecondsRef.current = plan.timeline.timeline_offset_seconds;
|
||||
canSeekAnywhereRef.current = plan.timeline.can_seek_anywhere;
|
||||
// The plan owns where the stream is anchored. On a converted part the
|
||||
// server anchors the stream at the seek position and restarts the player
|
||||
@@ -511,7 +513,7 @@ export function useAudiobookPlayback({
|
||||
const target = clampedBookTime(initialPositionSeconds, duration);
|
||||
const index = findPartIndex(parts, target);
|
||||
pendingLocalSeekRef.current = localTimeForPart(parts[index], target);
|
||||
streamOriginSecondsRef.current = 0;
|
||||
timelineOffsetSecondsRef.current = 0;
|
||||
canSeekAnywhereRef.current = true;
|
||||
autoPlayPendingRef.current = autoPlay;
|
||||
setBuffered(null);
|
||||
@@ -643,7 +645,7 @@ export function useAudiobookPlayback({
|
||||
if (!audio || !fileId || !activePart) return;
|
||||
|
||||
const absoluteFromAudio = () =>
|
||||
audiobookAbsoluteTime(activePart.start, streamOriginSecondsRef.current, audio.currentTime);
|
||||
audiobookAbsoluteTime(activePart.start, timelineOffsetSecondsRef.current, audio.currentTime);
|
||||
|
||||
const onTimeUpdate = () => setAbsoluteTime(absoluteFromAudio());
|
||||
const onProgress = () =>
|
||||
@@ -652,7 +654,7 @@ export function useAudiobookPlayback({
|
||||
audio.buffered,
|
||||
activePart,
|
||||
duration,
|
||||
streamOriginSecondsRef.current,
|
||||
timelineOffsetSecondsRef.current,
|
||||
),
|
||||
);
|
||||
const onDurationChange = () =>
|
||||
@@ -661,7 +663,7 @@ export function useAudiobookPlayback({
|
||||
audio.buffered,
|
||||
activePart,
|
||||
duration,
|
||||
streamOriginSecondsRef.current,
|
||||
timelineOffsetSecondsRef.current,
|
||||
),
|
||||
);
|
||||
const onLoadedMetadata = () => {
|
||||
@@ -805,7 +807,7 @@ export function useAudiobookPlayback({
|
||||
setBuffered(null);
|
||||
setActiveFileIndex(nextIndex);
|
||||
} else if (audio && canSeekAnywhereRef.current) {
|
||||
audio.currentTime = Math.max(0, local - streamOriginSecondsRef.current);
|
||||
audio.currentTime = Math.max(0, local - timelineOffsetSecondsRef.current);
|
||||
} else {
|
||||
pendingLocalSeekRef.current = local;
|
||||
playAfterSourceSwitchRef.current = shouldContinuePlaying;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Pause, PictureInPicture2, Play, SkipBack, SkipForward, Tv, X } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import type { WatchDetail } from "@/api/types";
|
||||
import { getAccessToken, getProfileToken } from "@/api/client";
|
||||
import { getAccessToken, getOrCreateDeviceId, getProfileToken } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
@@ -452,6 +452,7 @@ export function WatchPlaybackHost() {
|
||||
getAccessToken: () => getAccessToken(),
|
||||
getProfileId: () => storage.get(storage.KEYS.PROFILE_ID),
|
||||
getProfileToken: () => getProfileToken(),
|
||||
getDeviceId: () => getOrCreateDeviceId(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { detectHLSSupport } from "./client-context-v3";
|
||||
import { buildDeliveriesV3, detectHLSSupport } from "./client-context-v3";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -25,3 +25,21 @@ describe("detectHLSSupport", () => {
|
||||
expect(detectHLSSupport()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDeliveriesV3", () => {
|
||||
it("advertises the embedded text artifacts rendered by the web player", () => {
|
||||
const deliveries = buildDeliveriesV3({
|
||||
containers: ["mp4"],
|
||||
codecsVideo: ["h264"],
|
||||
codecsAudio: ["aac"],
|
||||
maxResolution: "1080p",
|
||||
hdr: false,
|
||||
hls: true,
|
||||
});
|
||||
|
||||
for (const delivery of Object.values(deliveries)) {
|
||||
expect(delivery.subtitles.embedded_text).toBe(true);
|
||||
expect(delivery.subtitles.sidecar_text).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,12 +26,13 @@ const WEB_APP_VERSION = "web";
|
||||
|
||||
/**
|
||||
* Subtitle capabilities of the web player, identical for every delivery class:
|
||||
* it renders sidecar text (WebVTT and ASS via JASSUB, with container font
|
||||
* attachments) but has no bitmap subtitle renderer, so PGS/VOBSUB tracks must
|
||||
* be burned in server-side.
|
||||
* the server exposes external and embedded text as session-scoped sidecars,
|
||||
* which it renders as WebVTT or ASS via JASSUB (with container font
|
||||
* attachments). It has no bitmap subtitle renderer, so PGS/VOBSUB tracks must
|
||||
* still be burned in server-side.
|
||||
*/
|
||||
const WEB_SUBTITLE_CAPABILITIES: DeliverySubtitleCapabilitiesV3 = {
|
||||
embedded_text: false,
|
||||
embedded_text: true,
|
||||
sidecar_text: true,
|
||||
ass_styling: true,
|
||||
embedded_bitmap: false,
|
||||
|
||||
@@ -17,6 +17,10 @@ const controls = vi.hoisted(() => ({
|
||||
subtitleTracks: PlayerSubtitleInfo[];
|
||||
},
|
||||
}));
|
||||
const subtitleTimeline = vi.hoisted(() => ({
|
||||
textOffsetSeconds: null as number | null,
|
||||
assOffsetSeconds: null as number | null,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/usePlaybackRealtime", () => ({
|
||||
usePlaybackRealtime: vi.fn((options) => {
|
||||
@@ -31,9 +35,17 @@ vi.mock("../hooks/useKeyboardShortcuts", () => ({ useKeyboardShortcuts: vi.fn()
|
||||
vi.mock("../hooks/useRemuxSeeking", () => ({
|
||||
useRemuxSeeking: () => ({ handleSeek: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../hooks/useSubtitleTracks", () => ({ useSubtitleTracks: () => [] }));
|
||||
vi.mock("../hooks/useSubtitleTracks", () => ({
|
||||
useSubtitleTracks: (...args: unknown[]) => {
|
||||
subtitleTimeline.textOffsetSeconds = args[3] as number;
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
vi.mock("../hooks/useASSSubtitles", () => ({
|
||||
useASSSubtitles: () => ({ isActive: false }),
|
||||
useASSSubtitles: (...args: unknown[]) => {
|
||||
subtitleTimeline.assOffsetSeconds = args[4] as number;
|
||||
return { isActive: false };
|
||||
},
|
||||
}));
|
||||
vi.mock("../hooks/useSubtitleAppearance", () => ({
|
||||
useSubtitleAppearance: () => ({
|
||||
@@ -59,6 +71,7 @@ const playerConfig: PlayerConfig = {
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => "token",
|
||||
getProfileId: () => "profile-1",
|
||||
getDeviceId: () => "test-device",
|
||||
getProfileToken: () => null,
|
||||
};
|
||||
|
||||
@@ -115,6 +128,8 @@ describe("VideoPlayer plan failure recovery", () => {
|
||||
beforeEach(() => {
|
||||
realtimeOptions.current = null;
|
||||
controls.current = null;
|
||||
subtitleTimeline.textOffsetSeconds = null;
|
||||
subtitleTimeline.assOffsetSeconds = null;
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
@@ -169,12 +184,54 @@ describe("VideoPlayer plan failure recovery", () => {
|
||||
fireEvent.error(video);
|
||||
expect(onPlanFailure).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry an auto-selected subtitle after its replan is refused", async () => {
|
||||
const onSubtitleTrackChange = vi.fn();
|
||||
const sidecarTrack: PlayerSubtitleInfo = {
|
||||
index: 2,
|
||||
media_file_id: 7,
|
||||
track_id: "file:7:subtitle:2",
|
||||
language: "en",
|
||||
codec: "srt",
|
||||
label: "English",
|
||||
source: "external",
|
||||
url: "/stream/session-1/subtitles/2.vtt",
|
||||
};
|
||||
const { rerenderPlayer } = renderPlayer({
|
||||
subtitleUrls: [sidecarTrack],
|
||||
subtitleMode: "always",
|
||||
preferredSubtitleLanguage: "en",
|
||||
onSubtitleTrackChange,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onSubtitleTrackChange).toHaveBeenCalledOnce());
|
||||
expect(onSubtitleTrackChange).toHaveBeenCalledWith(2, 0);
|
||||
|
||||
rerenderPlayer({ replanError: "Silo could not apply the subtitle selection." });
|
||||
|
||||
await waitFor(() => expect(controls.current?.activeSubtitleIndex).toBeNull());
|
||||
expect(onSubtitleTrackChange).toHaveBeenCalledOnce();
|
||||
|
||||
const nextPlan = fixturePlanV3({
|
||||
...directPlan,
|
||||
plan_id: "plan:next-session",
|
||||
plan_attempt_key: "v3:next-session",
|
||||
session_id: "session-2",
|
||||
});
|
||||
rerenderPlayer({ sessionId: "session-2", plan: nextPlan, replanError: null });
|
||||
|
||||
await waitFor(() => expect(controls.current?.activeSubtitleIndex).toBe(2));
|
||||
expect(onSubtitleTrackChange).toHaveBeenCalledTimes(2);
|
||||
expect(onSubtitleTrackChange).toHaveBeenLastCalledWith(2, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoPlayer native HLS timeline", () => {
|
||||
beforeEach(() => {
|
||||
realtimeOptions.current = null;
|
||||
controls.current = null;
|
||||
subtitleTimeline.textOffsetSeconds = null;
|
||||
subtitleTimeline.assOffsetSeconds = null;
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
@@ -214,6 +271,8 @@ describe("VideoPlayer native HLS timeline", () => {
|
||||
fireEvent.loadedMetadata(video);
|
||||
|
||||
expect(video.currentTime).toBe(7);
|
||||
expect(subtitleTimeline.textOffsetSeconds).toBe(0);
|
||||
expect(subtitleTimeline.assOffsetSeconds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export function VideoPlayer({
|
||||
const lastRecoveryRef = useRef(0);
|
||||
const reportedPlanFailureKeyRef = useRef<string | null>(null);
|
||||
const transportFailedForPlanRevisionRef = useRef<number | null>(null);
|
||||
const streamOriginRef = useRef(0);
|
||||
const timelineOffsetRef = useRef(0);
|
||||
const subtitleFetchAnchorRef = useRef(initialPosition);
|
||||
const backendDurationRef = useRef(propDuration ?? 0);
|
||||
const autoEnterPictureInPictureAttemptedRef = useRef(false);
|
||||
@@ -490,7 +490,7 @@ export function VideoPlayer({
|
||||
roomConnection: watchTogether,
|
||||
sessionId,
|
||||
videoRef,
|
||||
streamOriginRef,
|
||||
streamOriginRef: timelineOffsetRef,
|
||||
});
|
||||
const roomPlaybackActive = !!watchTogetherRoomId && !watchTogether.closedReason;
|
||||
const roomSyncWaiting = watchTogether.room?.playback_state === "waiting";
|
||||
@@ -531,14 +531,14 @@ export function VideoPlayer({
|
||||
const isCopyOriginalHLS = plan.delivery === "server_remux_hls";
|
||||
|
||||
// Keep the player-local clock mapped onto the canonical media timeline.
|
||||
const streamOriginSeconds = plan.timeline.stream_origin_seconds;
|
||||
streamOriginRef.current = streamOriginSeconds;
|
||||
const timelineOffsetSeconds = plan.timeline.timeline_offset_seconds;
|
||||
timelineOffsetRef.current = timelineOffsetSeconds;
|
||||
|
||||
// Media-time position playback is heading to, for consumers that need a
|
||||
// position before the element has media loaded (when currentTime still
|
||||
// reads 0): an in-flight seek target, else the session's start position.
|
||||
subtitleFetchAnchorRef.current =
|
||||
pendingSeekTime ?? toMediaTime(effectiveInitialPosition, streamOriginSeconds);
|
||||
pendingSeekTime ?? toMediaTime(effectiveInitialPosition, timelineOffsetSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (backendDuration > 0) {
|
||||
@@ -701,7 +701,7 @@ export function VideoPlayer({
|
||||
setPendingSeekTime(seconds);
|
||||
setCurrentTime(seconds);
|
||||
|
||||
const nativeSeconds = toPlayerTime(seconds, streamOriginRef.current);
|
||||
const nativeSeconds = toPlayerTime(seconds, timelineOffsetRef.current);
|
||||
if (canSeekAnywhere) {
|
||||
if (isHlsStream) video.currentTime = nativeSeconds;
|
||||
else handleSeek(nativeSeconds);
|
||||
@@ -773,17 +773,20 @@ export function VideoPlayer({
|
||||
// before dispatching the seek request.
|
||||
const handleKeyboardSeek = useCallback(
|
||||
(seconds: number) => {
|
||||
handlePlayerSeek(toMediaTime(seconds, streamOriginRef.current));
|
||||
handlePlayerSeek(toMediaTime(seconds, timelineOffsetRef.current));
|
||||
},
|
||||
[handlePlayerSeek],
|
||||
);
|
||||
|
||||
// -- Watch progress reporting --
|
||||
const flushWatchProgress = useWatchProgress(sessionId, videoRef, streamOriginRef);
|
||||
const flushWatchProgress = useWatchProgress(sessionId, videoRef, timelineOffsetRef);
|
||||
|
||||
const buildExitState = useCallback((): PlaybackExitState => {
|
||||
const video = videoRef.current;
|
||||
const positionSeconds = toMediaTime(video?.currentTime ?? currentTime, streamOriginRef.current);
|
||||
const positionSeconds = toMediaTime(
|
||||
video?.currentTime ?? currentTime,
|
||||
timelineOffsetRef.current,
|
||||
);
|
||||
// positionSeconds is media time, so the runtime paired with it must be too.
|
||||
const durationSeconds = mediaDurationSeconds(backendDurationRef.current, duration);
|
||||
|
||||
@@ -1588,7 +1591,7 @@ export function VideoPlayer({
|
||||
setAwaitingFirstFrame(false);
|
||||
};
|
||||
const onTimeUpdate = () => {
|
||||
const nextTime = toMediaTime(video.currentTime, streamOriginRef.current);
|
||||
const nextTime = toMediaTime(video.currentTime, timelineOffsetRef.current);
|
||||
const resolved = resolvePendingSeekTime(nextTime, pendingSeekTime);
|
||||
setCurrentTime(resolved.currentTime);
|
||||
if (resolved.pendingSeekTime !== pendingSeekTime) {
|
||||
@@ -1602,7 +1605,7 @@ export function VideoPlayer({
|
||||
};
|
||||
const onSeeked = () => {
|
||||
setPendingSeekTime(null);
|
||||
setCurrentTime(toMediaTime(video.currentTime, streamOriginRef.current));
|
||||
setCurrentTime(toMediaTime(video.currentTime, timelineOffsetRef.current));
|
||||
markPlaybackStarted();
|
||||
clearBuffering();
|
||||
if (roomSyncWaiting && watchTogetherSync.attachedSessionId === sessionId) {
|
||||
@@ -1864,7 +1867,7 @@ export function VideoPlayer({
|
||||
videoRef,
|
||||
effectiveSubtitleTracks,
|
||||
activeSubtitleIndex,
|
||||
streamOriginSeconds,
|
||||
timelineOffsetSeconds,
|
||||
subtitleDelayMs,
|
||||
durationRef,
|
||||
subtitleFetchAnchorRef,
|
||||
@@ -1879,7 +1882,7 @@ export function VideoPlayer({
|
||||
subtitleUrls,
|
||||
activeSubtitleIndex,
|
||||
isDetached,
|
||||
streamOriginSeconds,
|
||||
timelineOffsetSeconds,
|
||||
subtitleDelayMs,
|
||||
);
|
||||
|
||||
@@ -1932,14 +1935,24 @@ export function VideoPlayer({
|
||||
// A refused replan leaves the previous stream playing, so the selection has
|
||||
// to roll back too: without this the menu would keep claiming a track is on
|
||||
// that the server never rendered, and re-picking it would be suppressed as an
|
||||
// unchanged selection instead of retrying.
|
||||
// unchanged selection instead of retrying. Pin the accepted selection just
|
||||
// like a manual choice so auto-selection does not immediately request the
|
||||
// rejected track again; a later user choice can still retry it explicitly.
|
||||
useEffect(() => {
|
||||
if (requestedSubtitleTrackChangeRef.current && replanError && !replanning) {
|
||||
subtitleSelectionWasManualRef.current = true;
|
||||
setActiveSubtitleIndex(plan.selected_tracks.subtitle?.index ?? null);
|
||||
requestedSubtitleTrackChangeRef.current = null;
|
||||
}
|
||||
}, [plan.selected_tracks.subtitle?.index, replanError, replanning]);
|
||||
|
||||
// A refusal pin belongs only to the session that rejected the automatic
|
||||
// selection. Clear it before the auto-selection effect evaluates a new
|
||||
// session so the viewer's persisted subtitle mode applies to the next title.
|
||||
useEffect(() => {
|
||||
subtitleSelectionWasManualRef.current = false;
|
||||
}, [sessionId]);
|
||||
|
||||
// -- Auto-select subtitle track based on mode --
|
||||
useEffect(() => {
|
||||
if (subtitleSelectionWasManualRef.current) {
|
||||
@@ -1991,12 +2004,9 @@ export function VideoPlayer({
|
||||
audioTracks,
|
||||
activeAudioIndex,
|
||||
selectedVersion,
|
||||
sessionId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
subtitleSelectionWasManualRef.current = false;
|
||||
}, [sessionId]);
|
||||
|
||||
// -- Control callbacks --
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("../context/PlayerConfigContext", () => ({
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => "token",
|
||||
getProfileId: () => "profile-1",
|
||||
getDeviceId: () => "test-device",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PlayerConfig {
|
||||
getProfileId: () => string | null;
|
||||
/** Sync getter for the current verified profile token, when one exists. */
|
||||
getProfileToken?: () => string | null;
|
||||
/** Stable device identity used for device-scoped playback settings. */
|
||||
getDeviceId: () => string;
|
||||
}
|
||||
|
||||
const PlayerConfigCtx = createContext<PlayerConfig | null>(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ const playerConfig: PlayerConfig = {
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => "token",
|
||||
getProfileId: () => "profile-1",
|
||||
getDeviceId: () => "test-device",
|
||||
getProfileToken: () => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const config: PlayerConfig = {
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => null,
|
||||
getProfileId: () => null,
|
||||
getDeviceId: () => "web-player-device",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -41,4 +42,18 @@ describe("playerFetch", () => {
|
||||
|
||||
await expect(playerFetch<void>(config, "/playback/route-events")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the host application's stable device identity", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await playerFetch<void>(config, "/playback/start", { method: "POST" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/v1/playback/start",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ "X-Silo-Device-Id": "web-player-device" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ export async function playerFetch<T>(
|
||||
headers["X-Profile-Token"] = profileToken;
|
||||
}
|
||||
|
||||
headers["X-Silo-Device-Id"] = config.getDeviceId();
|
||||
|
||||
const res = await fetch(`${config.apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export function toMediaTime(playerTimeSeconds: number, streamOriginSeconds = 0): number {
|
||||
return Math.max(0, playerTimeSeconds + streamOriginSeconds);
|
||||
export function toMediaTime(playerTimeSeconds: number, timelineOffsetSeconds = 0): number {
|
||||
return Math.max(0, playerTimeSeconds + timelineOffsetSeconds);
|
||||
}
|
||||
|
||||
export function toPlayerTime(mediaTimeSeconds: number, streamOriginSeconds = 0): number {
|
||||
export function toPlayerTime(mediaTimeSeconds: number, timelineOffsetSeconds = 0): number {
|
||||
// Preserve negative offsets so callers can distinguish a target before the
|
||||
// current HLS window from the locally seekable position at zero.
|
||||
return mediaTimeSeconds - streamOriginSeconds;
|
||||
return mediaTimeSeconds - timelineOffsetSeconds;
|
||||
}
|
||||
|
||||
export function subtitleStartPositionSeconds(
|
||||
|
||||
Reference in New Issue
Block a user