Files
silo-server/internal/jellycompat/playback_sessions.go
T
cf4e080bf4 fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100)
* fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed)

* fix(jellycompat): honor ImageTypes=Backdrop as a filter on /Items

Wholphin genre cards request /Items?imageTypes=Backdrop&limit=1&sortBy=Random
and assume every returned item has a backdrop. Silo ignored ImageTypes, so a
random pick could lack a backdrop (BackdropImageTags: null), crashing Wholphin.

Push the filter down to the catalog browse SQL
(NULLIF(BTRIM(backdrop_path),'') IS NOT NULL) so random/limited selections only
ever consider backdrop-having items; empty genres correctly return [].

* fix(jellycompat): case-insensitive PlaySessionId + api_key in stream auth

Wholphin's jellyfin-sdk-kotlin builds its own direct-play URL
(/Videos/{id}/stream?static=true&playSessionId=...&mediaSourceId=...) with a
lowercase 'playSessionId', no api_key, and no auth header (ExoPlayer's data
source drops it). PlaybackSessionAuth read 'PlaySessionId'/'PlaySessionID'
case-sensitively, so the fallback never matched -> 401 on every direct-play
stream -> forced (often failing) transcode fallback. Resolve PlaySessionId via
newCaseInsensitiveQuery, and likewise accept case-variant api_key in
ExtractToken.

* fix(jellycompat): support Wholphin season item queries

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:05:33 -04:00

153 lines
3.7 KiB
Go

package jellycompat
import (
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
)
// PlaybackSession stores compat-owned playback negotiation state before the
// native Silo playback session starts.
type PlaybackSession struct {
ID string
CompatToken string
ItemID string
RouteItemID string
UserID string
InitialSeekSeconds float64
MediaSources []PlaybackMediaSource
UpstreamSessionID string
UpstreamPlayMethod string
TranscodeStarted bool
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
// PlaybackMediaSource stores one negotiated stream source within a compat play session.
type PlaybackMediaSource struct {
ID string
FileID int
Version catalog.FileVersion
SupportsDirectPlay bool
SupportsDirectStream bool
SupportsTranscoding bool
TranscodeAudio bool
DefaultAudioStreamIndex *int
SelectedAudioStreamIndex *int
DefaultSubtitleStreamIndex *int
ETag string
}
// PlaybackSessionStore keeps compat playback sessions in memory.
type PlaybackSessionStore struct {
mu sync.RWMutex
sessions map[string]PlaybackSession
ttl time.Duration
now func() time.Time
}
// NewPlaybackSessionStore creates a new playback session store.
func NewPlaybackSessionStore(ttl time.Duration, now func() time.Time) *PlaybackSessionStore {
if now == nil {
now = time.Now
}
if ttl <= 0 {
ttl = 6 * time.Hour
}
return &PlaybackSessionStore{
sessions: make(map[string]PlaybackSession),
ttl: ttl,
now: now,
}
}
// Put stores or replaces a compat playback session.
func (s *PlaybackSessionStore) Put(session PlaybackSession) {
s.mu.Lock()
defer s.mu.Unlock()
if session.CreatedAt.IsZero() {
session.CreatedAt = s.now()
}
session.UpdatedAt = s.now()
if session.ExpiresAt.IsZero() {
session.ExpiresAt = session.CreatedAt.Add(s.ttl)
}
s.sessions[session.ID] = session
}
// Get returns a playback session when it exists and is not expired.
func (s *PlaybackSessionStore) Get(id string) (*PlaybackSession, bool) {
s.mu.RLock()
session, ok := s.sessions[id]
s.mu.RUnlock()
if !ok {
return nil, false
}
if !session.ExpiresAt.After(s.now()) {
s.Delete(id)
return nil, false
}
cp := session
return &cp, true
}
// Delete removes a playback session.
func (s *PlaybackSessionStore) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
// Update modifies a playback session in place.
func (s *PlaybackSessionStore) Update(id string, fn func(*PlaybackSession) error) error {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return ErrSessionNotFound
}
if !session.ExpiresAt.After(s.now()) {
delete(s.sessions, id)
return ErrSessionNotFound
}
if err := fn(&session); err != nil {
return err
}
session.UpdatedAt = s.now()
s.sessions[id] = session
return nil
}
// FindByRoute resolves a route item/media-source identifier to a compat playback session.
func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*PlaybackSession, *PlaybackMediaSource, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
now := s.now()
for _, session := range s.sessions {
if !session.ExpiresAt.After(now) {
continue
}
if compatToken != "" && session.CompatToken != compatToken {
continue
}
if session.RouteItemID == routeID {
cp := session
return &cp, nil, true
}
for _, source := range session.MediaSources {
if mediaSourceIDsEqual(source.ID, routeID) {
cp := session
sourceCopy := source
return &cp, &sourceCopy, true
}
}
}
return nil, nil, false
}