Files

284 lines
9.9 KiB
Go
Raw Permalink Normal View History

package jellycompat
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
// stubRecipeNodeStore is an in-memory stand-in for the control-plane recipe
// store (*noderecipe.Store) so the round-trip tests can assert what central
// wrote and let the "node" read it back without Redis.
type stubRecipeNodeStore struct {
cards map[string]playback.RecipeCard
}
func (s *stubRecipeNodeStore) Put(_ context.Context, sessionID string, card playback.RecipeCard) error {
if s.cards == nil {
s.cards = make(map[string]playback.RecipeCard)
}
s.cards[sessionID] = card
return nil
}
func (s *stubRecipeNodeStore) Get(sessionID string) (playback.RecipeCard, bool) {
card, ok := s.cards[sessionID]
return card, ok
}
func (s *stubRecipeNodeStore) Delete(_ context.Context, sessionID string) error {
delete(s.cards, sessionID)
return nil
}
// localSessionRegistry is a GetSession + RegisterReconstructed double for
// exercising TranscodeManager reconstruction from the jellycompat package
// (the playback package's own fake is not exported across packages).
type localSessionRegistry struct {
sessions map[string]*playback.Session
}
func (r *localSessionRegistry) GetSession(id string) (*playback.Session, error) {
if s, ok := r.sessions[id]; ok {
return s, nil
}
return nil, playback.ErrSessionNotFound
}
func (r *localSessionRegistry) RegisterReconstructed(s *playback.Session) *playback.Session {
if r.sessions == nil {
r.sessions = map[string]*playback.Session{}
}
if existing, ok := r.sessions[s.ID]; ok {
return existing
}
r.sessions[s.ID] = s
return s
}
func (r *localSessionRegistry) RegisterReconstructedWithLimits(_ context.Context, s *playback.Session) (*playback.Session, error) {
return r.RegisterReconstructed(s), nil
}
// newRemoteTranscodeHandler builds a handler wired for the remote (offloaded)
// transcode path: a fake node that accepts /transcode/start, an upstream
// session carrying the native identity used to build the recipe, and a stub
// recipe store standing in for the control-plane Redis store.
func newRemoteTranscodeHandler(t *testing.T, nodeURL string, recipeStore *stubRecipeNodeStore) (*PlaybackHandler, *testCompatSessionManager, *PlaybackSessionStore) {
t.Helper()
playbackStore := NewPlaybackSessionStore(time.Hour, nil)
sessionMgr := &testCompatSessionManager{
sessions: map[string]*playback.Session{
"upstream-1": {
ID: "upstream-1",
UserID: 7,
ProfileID: "profile-1",
MediaFileID: 42,
PlayMethod: playback.PlayTranscode,
BasePlayMethod: playback.PlayTranscode,
TranscodeNodeURL: nodeURL,
},
},
}
handler := &PlaybackHandler{
playbackStore: playbackStore,
sessionMgr: sessionMgr,
fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"}},
tm: playback.NewTranscodeManager(),
JWTSecret: "test-secret",
RecipeNodeStore: recipeStore,
}
return handler, sessionMgr, playbackStore
}
// fakeTranscodeNode returns an httptest server that accepts /transcode/start
// with 202 Accepted (the only response the start path inspects) and records
// the request body it received.
func fakeTranscodeNode(t *testing.T, received *transcodenode.TranscodeStartRequest) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/transcode/start" {
w.WriteHeader(http.StatusNotFound)
return
}
if received != nil {
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, received)
}
w.WriteHeader(http.StatusAccepted)
}))
t.Cleanup(srv.Close)
return srv
}
func testRemoteTranscodeSource() PlaybackMediaSource {
codec := NewResourceIDCodec()
version := testCompatVersion()
source := testCompatSource(codec, version)
return source
}
// TestStartRemoteTranscode_NodeRestartReconstruct covers the node-restart leg:
// central persists the recipe to the control-plane store at start, the node
// loses all in-memory state, and a reconstruct fetch via the store's Get
// rebuilds the same recipe the node would re-spawn ffmpeg from (rather than a
// Redis miss → 404).
func TestStartRemoteTranscode_NodeRestartReconstruct(t *testing.T) {
recipeStore := &stubRecipeNodeStore{}
node := fakeTranscodeNode(t, nil)
handler, _, playbackStore := newRemoteTranscodeHandler(t, node.URL, recipeStore)
playbackStore.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
UpstreamSessionID: "upstream-1",
UpstreamPlayMethod: "transcode",
MediaSources: []PlaybackMediaSource{testRemoteTranscodeSource()},
})
source := testRemoteTranscodeSource()
err := handler.startRemoteTranscode(
context.Background(),
"play-1",
"upstream-1",
source,
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"},
0,
node.URL,
)
if err != nil {
t.Fatalf("startRemoteTranscode: %v", err)
}
// Node side: it lost its session map; reconstruct reads the recipe central
// wrote keyed by the upstream session id.
card, ok := recipeStore.Get("upstream-1")
if !ok {
t.Fatal("expected recipe in control-plane store after remote start")
}
if card.SessionID != "upstream-1" {
t.Fatalf("recipe SessionID = %q, want upstream-1", card.SessionID)
}
if card.TranscodeNodeURL != node.URL {
t.Fatalf("recipe TranscodeNodeURL = %q, want %q", card.TranscodeNodeURL, node.URL)
}
// The recipe must be reconstruct-complete: the node refuses to rebuild from a
// recipe lacking encode parameters (server.go reconstructFromToken).
if card.SegmentDuration <= 0 || card.TargetCodecVideo == "" {
t.Fatalf("recipe not reconstruct-complete: seg=%d video=%q", card.SegmentDuration, card.TargetCodecVideo)
}
if card.UserID != 7 || card.ProfileID != "profile-1" || card.MediaFileID != 42 {
t.Fatalf("recipe identity wrong: user=%d profile=%q file=%d", card.UserID, card.ProfileID, card.MediaFileID)
}
if card.InputPath != "/media/movie.mkv" {
t.Fatalf("recipe InputPath = %q, want /media/movie.mkv", card.InputPath)
}
}
// TestStartRemoteTranscode_CentralRestartReconstruct covers the central-restart
// leg: the recipe lives in the compat store (PlaybackSession.Recipe), the
// in-memory native session is gone, and LoadOrReconstructSession rebuilds the
// session from the stored recipe rather than returning SessionMissing (which a
// remote segment serve renders as a 404).
func TestStartRemoteTranscode_CentralRestartReconstruct(t *testing.T) {
recipeStore := &stubRecipeNodeStore{}
node := fakeTranscodeNode(t, nil)
handler, _, playbackStore := newRemoteTranscodeHandler(t, node.URL, recipeStore)
playbackStore.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
UpstreamSessionID: "upstream-1",
UpstreamPlayMethod: "transcode",
MediaSources: []PlaybackMediaSource{testRemoteTranscodeSource()},
})
source := testRemoteTranscodeSource()
if err := handler.startRemoteTranscode(context.Background(), "play-1", "upstream-1", source, &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"}, 0, node.URL); err != nil {
t.Fatalf("startRemoteTranscode: %v", err)
}
// The compat store must now carry the recipe so central can reconstruct.
stored, ok := playbackStore.Get("play-1")
if !ok {
t.Fatal("expected compat session")
}
if !stored.TranscodeStarted {
t.Fatal("expected TranscodeStarted=true after remote start")
}
if stored.Recipe == nil {
t.Fatal("expected PlaybackSession.Recipe persisted after remote start")
}
// Simulate a central restart: the in-memory native session map is empty, so
// GetSession misses. The recipe from the compat store must let
// LoadOrReconstructSession rebuild the session rather than 404.
reg := &localSessionRegistry{}
tm := playback.NewTranscodeManager()
tm.Sessions = reg
tm.JWTSecretFn = func() string { return "test-secret" }
session, status := tm.LoadOrReconstructSession(
context.Background(),
reg.GetSession,
"upstream-1",
stored.Recipe.UserID,
stored.Recipe,
)
if status != playback.SessionLoaded || session == nil {
t.Fatalf("LoadOrReconstructSession status=%v session=%v, want reconstructed", status, session)
}
if session.ID != "upstream-1" || session.UserID != 7 || session.MediaFileID != 42 {
t.Fatalf("reconstructed session wrong: %+v", session)
}
if session.TranscodeNodeURL != node.URL {
t.Fatalf("reconstructed TranscodeNodeURL = %q, want %q", session.TranscodeNodeURL, node.URL)
}
}
// TestStartRemoteTranscode_UpdateFailureRollsBackNode asserts the local-path
// rollback is mirrored: a compat-store Update failure closes the already-started
// node ffmpeg (DELETE /transcode/{id}) so it isn't leaked.
func TestStartRemoteTranscode_UpdateFailureRollsBackNode(t *testing.T) {
recipeStore := &stubRecipeNodeStore{}
deleted := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
w.WriteHeader(http.StatusAccepted)
case r.Method == http.MethodDelete:
deleted <- r.URL.Path
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
handler, _, _ := newRemoteTranscodeHandler(t, srv.URL, recipeStore)
// No play session put → Update("missing") fails, triggering rollback.
source := testRemoteTranscodeSource()
err := handler.startRemoteTranscode(context.Background(), "missing", "upstream-1", source, &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"}, 0, srv.URL)
if err == nil {
t.Fatal("expected error when compat Update fails")
}
select {
case path := <-deleted:
if path != "/transcode/upstream-1" {
t.Fatalf("rollback DELETE path = %q, want /transcode/upstream-1", path)
}
case <-time.After(2 * time.Second):
t.Fatal("expected rollback DELETE to the node after Update failure")
}
}