Compare commits

...
Author SHA1 Message Date
Quick e006553335 Populate Jellyfin episode season parent metadata
- Include season IDs and names when hydrating Jellyfin episode progress
- Preserve season ParentId metadata for fallback episode targets
2026-06-15 18:03:40 -04:00
dusankulainovi 2947d58836 feat(jellycompat): support DELETE /Videos/ActiveEncodings transcode teardown 2026-06-15 14:42:49 -07:00
7 changed files with 309 additions and 13 deletions
+5 -1
View File
@@ -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 -1
View File
@@ -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,
}
}
+12
View File
@@ -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)
}
}
+1
View File
@@ -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)
})
+57 -11
View File
@@ -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)
+156
View File
@@ -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)
}
}