Files
silo-server/internal/jellycompat/playback_sessions.go
T
185a90b048 fix(jellycompat): honor client subtitle selection in PlaybackInfo (#222)
* fix(jellycompat): honor client subtitle selection in PlaybackInfo

Jellyfin clients send the chosen subtitle as SubtitleStreamIndex on
PlaybackInfo, but the compat layer only ever advertised the media's
embedded default subtitle (or none). Picking a subtitle before playback
had no effect and the video started without it — and an explicit
"subtitles off" was ignored when the media had a default subtitle.

Mirror the existing audio-selection plumbing for subtitles:
- parse SubtitleStreamIndex from the PlaybackInfo body and query
- persist it as SelectedSubtitleStreamIndex on the play-session source
- advertise it via DefaultSubtitleStreamIndex and flip IsDefault onto the
  chosen stream (embedded, external, or Silo-downloaded)
- honor a negative index as an explicit "subtitles off"

The selection is validated against streamable tracks (bitmap subs that
require burn-in are excluded, matching delivery) and the downloaded
subtitle range, falling back to the media default when invalid.

Out of scope (follow-ups): bitmap-subtitle burn-in transcode wiring and
mid-playback subtitle changes on progress reports.

Part of #217

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(jellycompat): handle downloaded-subtitle lookup failure in selection

Don't let a transient ListDownloadedSubtitles error masquerade as "no
downloaded subtitles", which silently downgraded a valid subtitle
selection to the media default. The lookup error is now logged, and
resolution honors a requested index it cannot validate (it may be a
downloaded subtitle) instead of discarding the user's choice, while
embedded/external selections continue to resolve normally.

Addresses CodeRabbit review feedback on #222.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:26:27 -04:00

154 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
SelectedSubtitleStreamIndex *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
}