Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e006553335 | ||
|
|
2947d58836 |
@@ -21,6 +21,7 @@ type testCompatSessionManager struct {
|
||||
sessions map[string]*playback.Session
|
||||
audioTrackCalls []compatAudioTrackCall
|
||||
progressCalls int
|
||||
stopCalls []string
|
||||
}
|
||||
|
||||
type compatAudioTrackCall struct {
|
||||
@@ -67,7 +68,10 @@ func (m *testCompatSessionManager) UpdateAudioTrack(sessionID string, audioTrack
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testCompatSessionManager) StopSession(string) error { return nil }
|
||||
func (m *testCompatSessionManager) StopSession(sessionID string) error {
|
||||
m.stopCalls = append(m.stopCalls, sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testCompatSessionManager) GetSession(sessionID string) (*playback.Session, error) {
|
||||
if session, ok := m.sessions[sessionID]; ok {
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
type compatEpisodeTarget struct {
|
||||
Item upstreamListItem
|
||||
SeriesImages seriesImageSet
|
||||
SeasonID string
|
||||
SeasonName string
|
||||
}
|
||||
|
||||
type libraryMembershipChecker interface {
|
||||
@@ -166,7 +168,11 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
}
|
||||
|
||||
access := h.resolveAccessFilter(ctx, session)
|
||||
fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id"
|
||||
fromClause := strings.Join([]string{
|
||||
"episodes e",
|
||||
"JOIN media_items si ON e.series_id = si.content_id",
|
||||
"LEFT JOIN seasons se ON se.content_id = e.season_id",
|
||||
}, " ")
|
||||
conditions := []string{"e.content_id = ANY($1)"}
|
||||
args := []any{normalized}
|
||||
argIdx := 2
|
||||
@@ -208,6 +214,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
e.updated_at,
|
||||
e.season_number,
|
||||
e.episode_number,
|
||||
COALESCE(e.season_id, ''),
|
||||
COALESCE(se.title, ''),
|
||||
si.content_id,
|
||||
si.title,
|
||||
si.genres,
|
||||
@@ -249,6 +257,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
updatedAt time.Time
|
||||
seasonNumber int
|
||||
episodeNumber int
|
||||
seasonID string
|
||||
seasonName string
|
||||
seriesID string
|
||||
seriesTitle string
|
||||
genres []string
|
||||
@@ -275,6 +285,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
&updatedAt,
|
||||
&seasonNumber,
|
||||
&episodeNumber,
|
||||
&seasonID,
|
||||
&seasonName,
|
||||
&seriesID,
|
||||
&seriesTitle,
|
||||
&genres,
|
||||
@@ -335,6 +347,8 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDs(ctx context.Context
|
||||
BackdropThumbhash: seriesBackdropTH,
|
||||
UpdatedAt: seriesUpdatedAt,
|
||||
},
|
||||
SeasonID: seasonID,
|
||||
SeasonName: seasonName,
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -446,6 +460,7 @@ func (h *ItemsHandler) fetchCompatEpisodeTargetsByContentIDsFallback(ctx context
|
||||
BackdropThumbhash: series.BackdropThumbhash,
|
||||
UpdatedAt: series.UpdatedAt,
|
||||
},
|
||||
SeasonID: episode.SeasonID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2170,6 +2170,18 @@ func (h *ItemsHandler) rememberCompatEpisodeImages(dto baseItemDTO, stillURL str
|
||||
}
|
||||
|
||||
func (h *ItemsHandler) applyCompatEpisodeTarget(dto *baseItemDTO, target compatEpisodeTarget) {
|
||||
if target.SeasonID != "" && h.codec != nil {
|
||||
encodedSeasonID := h.codec.EncodeStringID(EncodedIDSeason, target.SeasonID)
|
||||
if dto.SeasonID == "" {
|
||||
dto.SeasonID = encodedSeasonID
|
||||
}
|
||||
if dto.ParentID == "" {
|
||||
dto.ParentID = encodedSeasonID
|
||||
}
|
||||
}
|
||||
if dto.SeasonName == "" && target.SeasonName != "" {
|
||||
dto.SeasonName = target.SeasonName
|
||||
}
|
||||
h.mapper.applySeriesImages(dto, target.SeriesImages)
|
||||
h.rememberCompatEpisodeImages(*dto, firstNonEmpty(target.Item.StillURL, target.Item.PosterURL), target.SeriesImages)
|
||||
}
|
||||
|
||||
@@ -152,3 +152,65 @@ func TestHydrateProgressItems_NoFieldsLeavesDetailFieldsNil(t *testing.T) {
|
||||
t.Errorf("MediaSources should be nil with no Fields; got %v", got[0].dto.MediaSources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateProgressItems_EpisodeIncludesSeasonParentMetadata(t *testing.T) {
|
||||
codec := NewResourceIDCodec()
|
||||
episodeID := "episode-1"
|
||||
seasonID := "season-1"
|
||||
seriesID := "series-1"
|
||||
|
||||
itemRepo := &countingItemRepo{
|
||||
itemsByID: map[string]*models.MediaItem{
|
||||
seriesID: {ContentID: seriesID, Type: "series", Title: "Test Series"},
|
||||
},
|
||||
}
|
||||
episodeRepo := &countingEpisodeRepo{
|
||||
episodesByID: map[string]*models.Episode{
|
||||
episodeID: {
|
||||
ContentID: episodeID,
|
||||
SeriesID: seriesID,
|
||||
SeasonID: seasonID,
|
||||
Title: "Episode Four",
|
||||
SeasonNumber: 1,
|
||||
EpisodeNumber: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h := &ItemsHandler{
|
||||
content: &panicOnGetItemDetailContent{},
|
||||
userData: &mockUserDataService{},
|
||||
itemRepo: itemRepo,
|
||||
episodeRepo: episodeRepo,
|
||||
codec: codec,
|
||||
mapper: newMapper(codec, &config.Config{}),
|
||||
}
|
||||
|
||||
got, err := h.hydrateProgressItems(
|
||||
context.Background(),
|
||||
&Session{StreamAppUserID: 1, ProfileID: "profile-1"},
|
||||
[]upstreamProgress{{MediaItemID: episodeID, PositionSeconds: 120, DurationSeconds: 1800}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("hydrateProgressItems returned error: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 hydrated item; got %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
wantSeasonID := codec.EncodeStringID(EncodedIDSeason, seasonID)
|
||||
if got[0].dto.SeasonID != wantSeasonID {
|
||||
t.Fatalf("SeasonId = %q, want %q", got[0].dto.SeasonID, wantSeasonID)
|
||||
}
|
||||
if got[0].dto.ParentID != wantSeasonID {
|
||||
t.Fatalf("ParentId = %q, want season id %q", got[0].dto.ParentID, wantSeasonID)
|
||||
}
|
||||
if got[0].dto.IndexNumber == nil || *got[0].dto.IndexNumber != 4 {
|
||||
t.Fatalf("IndexNumber = %v, want 4", got[0].dto.IndexNumber)
|
||||
}
|
||||
if got[0].dto.ParentIndexNumber == nil || *got[0].dto.ParentIndexNumber != 1 {
|
||||
t.Fatalf("ParentIndexNumber = %v, want 1", got[0].dto.ParentIndexNumber)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Post("/Sessions/Playing", playbackHandler.HandleSessionPlaying)
|
||||
r.Post("/Sessions/Playing/Progress", playbackHandler.HandleSessionPlayingProgress)
|
||||
r.Post("/Sessions/Playing/Stopped", playbackHandler.HandleSessionPlayingStopped)
|
||||
r.Delete("/Videos/ActiveEncodings", playbackHandler.HandleDeleteActiveEncodings)
|
||||
r.Post("/Sessions/Logout", authHandler.HandleLogout)
|
||||
r.Get("/socket", HandleSocket)
|
||||
})
|
||||
|
||||
@@ -672,6 +672,62 @@ func (h *PlaybackHandler) HandleSessionPlayingStopped(w http.ResponseWriter, r *
|
||||
h.handlePlaybackReport(w, r, true)
|
||||
}
|
||||
|
||||
// HandleDeleteActiveEncodings handles DELETE /Videos/ActiveEncodings.
|
||||
//
|
||||
// Jellyfin clients (e.g. JellyCon) call this endpoint when playback stops to
|
||||
// signal the server to tear down any running HLS transcode for the session.
|
||||
// Without it, the transcode process keeps running until the playback session
|
||||
// TTL expires (default 6 h). We honour the request by stopping the transcode
|
||||
// identified by the playSessionId query parameter.
|
||||
func (h *PlaybackHandler) HandleDeleteActiveEncodings(w http.ResponseWriter, r *http.Request) {
|
||||
session := SessionFromContext(r.Context())
|
||||
if session == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
q := newCaseInsensitiveQuery(r.URL.Query())
|
||||
// DeviceId is intentionally ignored: Silo's playback store is keyed by
|
||||
// PlaySessionId, clients always send it, and Jellyfin's own teardown matches
|
||||
// by playSessionId (ignoring deviceId) whenever playSessionId is non-empty.
|
||||
playSessionID := q.Get("PlaySessionId")
|
||||
if playSessionID == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership guard (mirrors the ownership check of the Stopped report path):
|
||||
// only the session's own caller may tear it down. An unknown OR not-owned
|
||||
// PlaySessionId is an idempotent 204 no-op, so the response never reveals
|
||||
// which case it was (no cross-session teardown, no ownership oracle).
|
||||
playSession, ok := h.playbackStore.Get(playSessionID)
|
||||
if !ok || playSession.CompatToken != session.Token {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
h.teardownPlaySession(playSession)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// teardownPlaySession stops the upstream playback session and removes the compat
|
||||
// play session from the store. Every step is idempotent, so it is safe to call
|
||||
// from both the explicit ActiveEncodings teardown and a Stopped playback report
|
||||
// (which may race). It is a no-op-safe teardown for an already-gone session.
|
||||
func (h *PlaybackHandler) teardownPlaySession(playSession *PlaybackSession) {
|
||||
transcodeNodeURL := ""
|
||||
if h.sessionMgr != nil {
|
||||
if upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil {
|
||||
transcodeNodeURL = upstreamSession.TranscodeNodeURL
|
||||
}
|
||||
}
|
||||
h.closeTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL)
|
||||
if h.sessionMgr != nil {
|
||||
_ = h.sessionMgr.StopSession(playSession.UpstreamSessionID)
|
||||
}
|
||||
h.playbackStore.Delete(playSession.ID)
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Request, stop bool) {
|
||||
session := SessionFromContext(r.Context())
|
||||
if session == nil {
|
||||
@@ -755,17 +811,7 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
}
|
||||
if stop {
|
||||
transcodeNodeURL := ""
|
||||
if h.sessionMgr != nil {
|
||||
if upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil {
|
||||
transcodeNodeURL = upstreamSession.TranscodeNodeURL
|
||||
}
|
||||
}
|
||||
h.closeTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL)
|
||||
if h.sessionMgr != nil {
|
||||
_ = h.sessionMgr.StopSession(playSession.UpstreamSessionID)
|
||||
}
|
||||
h.playbackStore.Delete(playSession.ID)
|
||||
h.teardownPlaySession(playSession)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
package jellycompat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
// withCompatSession attaches a compat session carrying tok to req, so the
|
||||
// ActiveEncodings ownership guard (CompatToken == session.Token) is satisfied.
|
||||
func withCompatSession(req *http.Request, tok string) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: tok}))
|
||||
}
|
||||
|
||||
func TestAudioSelectionChanged(t *testing.T) {
|
||||
selected := 2
|
||||
session := &PlaybackSession{
|
||||
@@ -90,3 +102,147 @@ func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) {
|
||||
t.Fatalf("expected nested manifest to include media and playback session ids, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newActiveEncodingsHandler builds a PlaybackHandler literal directly (not
|
||||
// NewPlaybackHandler, which touches the filesystem) with the transcodes map
|
||||
// initialized — closeTranscodeSession writes/deletes it and would nil-map-panic
|
||||
// otherwise.
|
||||
func newActiveEncodingsHandler(mgr *testCompatSessionManager) (*PlaybackHandler, *PlaybackSessionStore) {
|
||||
store := NewPlaybackSessionStore(time.Hour, nil)
|
||||
h := &PlaybackHandler{
|
||||
playbackStore: store,
|
||||
sessionMgr: mgr,
|
||||
transcodes: make(map[string]*playback.TranscodeSession),
|
||||
}
|
||||
return h, store
|
||||
}
|
||||
|
||||
// TestHandleDeleteActiveEncodings_StopsTranscodeAndDeletesSession verifies the
|
||||
// happy path: the upstream session is stopped and the compat play session is
|
||||
// removed from the store, returning 204.
|
||||
func TestHandleDeleteActiveEncodings_StopsTranscodeAndDeletesSession(t *testing.T) {
|
||||
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||
h, store := newActiveEncodingsHandler(mgr)
|
||||
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||
|
||||
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?PlaySessionId=ps-1", nil), "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleDeleteActiveEncodings(rec, req)
|
||||
|
||||
if rec.Code != 204 {
|
||||
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := store.Get("ps-1"); ok {
|
||||
t.Fatal("play session should be deleted")
|
||||
}
|
||||
if len(mgr.stopCalls) != 1 || mgr.stopCalls[0] != "upstream-1" {
|
||||
t.Fatalf("expected StopSession(upstream-1); got %v", mgr.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDeleteActiveEncodings_MissingPlaySessionIdReturns204 verifies a
|
||||
// request with no PlaySessionId is a 204 no-op (no teardown).
|
||||
func TestHandleDeleteActiveEncodings_MissingPlaySessionIdReturns204(t *testing.T) {
|
||||
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||
h, store := newActiveEncodingsHandler(mgr)
|
||||
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||
|
||||
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings", nil), "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleDeleteActiveEncodings(rec, req)
|
||||
|
||||
if rec.Code != 204 {
|
||||
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := store.Get("ps-1"); !ok {
|
||||
t.Fatal("unrelated play session must not be torn down")
|
||||
}
|
||||
if len(mgr.stopCalls) != 0 {
|
||||
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDeleteActiveEncodings_UnknownPlaySessionReturns204 verifies an
|
||||
// unknown PlaySessionId is a 204 no-op (idempotent "already gone" semantics).
|
||||
func TestHandleDeleteActiveEncodings_UnknownPlaySessionReturns204(t *testing.T) {
|
||||
mgr := &testCompatSessionManager{}
|
||||
h, _ := newActiveEncodingsHandler(mgr)
|
||||
|
||||
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?PlaySessionId=does-not-exist", nil), "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleDeleteActiveEncodings(rec, req)
|
||||
|
||||
if rec.Code != 204 {
|
||||
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(mgr.stopCalls) != 0 {
|
||||
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDeleteActiveEncodings_CaseInsensitivePlaySessionId verifies a
|
||||
// lowercase playSessionId key (as Wholphin sends) still resolves and tears down
|
||||
// the session — the reason newCaseInsensitiveQuery is used.
|
||||
func TestHandleDeleteActiveEncodings_CaseInsensitivePlaySessionId(t *testing.T) {
|
||||
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||
h, store := newActiveEncodingsHandler(mgr)
|
||||
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||
|
||||
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?playSessionId=ps-1", nil), "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleDeleteActiveEncodings(rec, req)
|
||||
|
||||
if rec.Code != 204 {
|
||||
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := store.Get("ps-1"); ok {
|
||||
t.Fatal("lowercase playSessionId should still resolve and delete the session")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDeleteActiveEncodings_ForeignPlaySessionNotTornDown proves the
|
||||
// ownership guard: a caller whose token differs from the play session's
|
||||
// CompatToken gets a uniform 204 no-op and does NOT tear down the foreign
|
||||
// session (no cross-session IDOR teardown).
|
||||
func TestHandleDeleteActiveEncodings_ForeignPlaySessionNotTornDown(t *testing.T) {
|
||||
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||
h, store := newActiveEncodingsHandler(mgr)
|
||||
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "owner"})
|
||||
|
||||
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?PlaySessionId=ps-1", nil), "attacker")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleDeleteActiveEncodings(rec, req)
|
||||
|
||||
if rec.Code != 204 {
|
||||
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := store.Get("ps-1"); !ok {
|
||||
t.Fatal("foreign play session must not be torn down")
|
||||
}
|
||||
if len(mgr.stopCalls) != 0 {
|
||||
t.Fatalf("expected no StopSession calls; got %v", mgr.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDeleteActiveEncodings_RealClientShape exercises the dominant real
|
||||
// JellyCon call shape (DeviceId present alongside PlaySessionId): with a
|
||||
// matching-token session the session is still torn down (DeviceId ignored).
|
||||
func TestHandleDeleteActiveEncodings_RealClientShape(t *testing.T) {
|
||||
mgr := &testCompatSessionManager{sessions: map[string]*playback.Session{"upstream-1": {ID: "upstream-1"}}}
|
||||
h, store := newActiveEncodingsHandler(mgr)
|
||||
store.Put(PlaybackSession{ID: "ps-1", UpstreamSessionID: "upstream-1", CompatToken: "tok"})
|
||||
|
||||
req := withCompatSession(httptest.NewRequest("DELETE", "/Videos/ActiveEncodings?DeviceId=dev1&PlaySessionId=ps-1", nil), "tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleDeleteActiveEncodings(rec, req)
|
||||
|
||||
if rec.Code != 204 {
|
||||
t.Fatalf("status = %d, body = %s; want 204", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := store.Get("ps-1"); ok {
|
||||
t.Fatal("play session should be torn down when DeviceId accompanies a matching PlaySessionId")
|
||||
}
|
||||
if len(mgr.stopCalls) != 1 || mgr.stopCalls[0] != "upstream-1" {
|
||||
t.Fatalf("expected StopSession(upstream-1); got %v", mgr.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user