* 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>
153 lines
3.7 KiB
Go
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
|
|
}
|