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