Files
silo-server/internal/jellycompat/audio_selection_test.go
Quick104 166c5ef32f Add reliable Jellycompat watch scrobbling
- Forward start, pause, resume, and stop events with stable media identities
- Persist and retry terminal scrobbles across teardown and restart paths
- Reject ambiguous playback-report route matches
2026-07-22 21:41:05 -04:00

357 lines
10 KiB
Go

package jellycompat
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
type testCompatSessionManager struct {
sessions map[string]*playback.Session
audioTrackCalls []compatAudioTrackCall
progressCalls int
progressUpdates []compatProgressCall
stopCalls []string
startCalls int
beginTransportCalls []string
endTransportCalls []string
}
type compatAudioTrackCall struct {
sessionID string
audioTrackIndex int
method playback.PlayMethod
}
type compatProgressCall struct {
sessionID string
position float64
isPaused bool
}
func (m *testCompatSessionManager) StartSession(userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error) {
m.startCalls++
session := &playback.Session{
ID: "upstream-started",
UserID: userID,
ProfileID: profileID,
MediaFileID: fileID,
PlayMethod: method,
BasePlayMethod: method,
TranscodeAudio: transcodeAudio,
}
if m.sessions == nil {
m.sessions = make(map[string]*playback.Session)
}
m.sessions[session.ID] = session
return session, nil
}
func (m *testCompatSessionManager) UpdateProgress(sessionID string, position float64, isPaused bool) error {
m.progressCalls++
m.progressUpdates = append(m.progressUpdates, compatProgressCall{
sessionID: sessionID,
position: position,
isPaused: isPaused,
})
if m.sessions != nil {
session, ok := m.sessions[sessionID]
if !ok {
return playback.ErrSessionNotFound
}
session.Position = position
session.IsPaused = isPaused
}
return nil
}
func (m *testCompatSessionManager) BeginTransport(sessionID string) error {
m.beginTransportCalls = append(m.beginTransportCalls, sessionID)
if m.sessions != nil {
if _, ok := m.sessions[sessionID]; !ok {
return playback.ErrSessionNotFound
}
}
return nil
}
func (m *testCompatSessionManager) EndTransport(sessionID string) error {
m.endTransportCalls = append(m.endTransportCalls, sessionID)
if m.sessions != nil {
if _, ok := m.sessions[sessionID]; !ok {
return playback.ErrSessionNotFound
}
}
return nil
}
func (m *testCompatSessionManager) UpdateAudioTrack(sessionID string, audioTrackIndex int, method playback.PlayMethod) error {
m.audioTrackCalls = append(m.audioTrackCalls, compatAudioTrackCall{
sessionID: sessionID,
audioTrackIndex: audioTrackIndex,
method: method,
})
if session, ok := m.sessions[sessionID]; ok {
session.AudioTrackIndex = audioTrackIndex
session.BasePlayMethod = method
if session.PlayMethod != playback.PlayTranscode || method == playback.PlayTranscode {
session.PlayMethod = method
}
}
return nil
}
func (m *testCompatSessionManager) StopSession(sessionID string) error {
m.stopCalls = append(m.stopCalls, sessionID)
delete(m.sessions, sessionID)
return nil
}
func (m *testCompatSessionManager) GetSession(sessionID string) (*playback.Session, error) {
if session, ok := m.sessions[sessionID]; ok {
return session, nil
}
return nil, playback.ErrSessionNotFound
}
func (m *testCompatSessionManager) SetTranscodeNodeURL(sessionID, url string) error {
if session, ok := m.sessions[sessionID]; ok {
session.TranscodeNodeURL = url
}
return nil
}
type testCompatFileResolver struct {
file *models.MediaFile
}
func (r testCompatFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) {
return r.file, nil
}
func writeCompatTestFFmpeg(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-ffmpeg.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
return path
}
func testCompatVersion() catalog.FileVersion {
return catalog.FileVersion{
FileID: 42,
Duration: 3600,
Container: "mkv",
Bitrate: 8000,
VideoTracks: []models.VideoTrack{
{Codec: "h264", Width: 1920, Height: 1080},
},
AudioTracks: []models.AudioTrack{
{Codec: "ac3", Default: true, Title: "Main"},
{Codec: "aac", Title: "Commentary"},
},
}
}
func testCompatSource(codec *ResourceIDCodec, version catalog.FileVersion) PlaybackMediaSource {
return PlaybackMediaSource{
ID: codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID)),
FileID: version.FileID,
Version: version,
SupportsDirectPlay: true,
SupportsDirectStream: true,
SupportsTranscoding: true,
DefaultAudioStreamIndex: defaultAudioStreamIndex(version),
SelectedAudioStreamIndex: intPtr(len(version.VideoTracks) + 1),
ETag: mediaSourceETag(version),
}
}
func TestBuildPlaybackSource_SeedsRequestedAudioStreamIndex(t *testing.T) {
handler := &PlaybackHandler{codec: NewResourceIDCodec()}
version := testCompatVersion()
requestedAudioStreamIndex := len(version.VideoTracks) + 1
source := handler.buildPlaybackSource(
"route-1",
"play-1",
version,
DeviceProfile{},
playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(requestedAudioStreamIndex)},
true,
)
if source.SelectedAudioStreamIndex == nil {
t.Fatal("expected selected audio stream index")
}
if got := *source.SelectedAudioStreamIndex; got != requestedAudioStreamIndex {
t.Fatalf("SelectedAudioStreamIndex = %d, want %d", got, requestedAudioStreamIndex)
}
}
func TestPlaybackInfoRequest_AcceptsStringAudioStreamIndex(t *testing.T) {
var req playbackInfoRequest
if err := json.Unmarshal([]byte(`{"AudioStreamIndex":"1"}`), &req); err != nil {
t.Fatalf("unmarshal playback request: %v", err)
}
if req.AudioStreamIndex == nil {
t.Fatal("expected audio stream index")
}
if got := int(*req.AudioStreamIndex); got != 1 {
t.Fatalf("AudioStreamIndex = %d, want 1", got)
}
}
func TestHandlePlaybackReport_UpdatesSelectedAudioStreamAndUpstreamTrack(t *testing.T) {
codec := NewResourceIDCodec()
version := testCompatVersion()
source := testCompatSource(codec, version)
source.SelectedAudioStreamIndex = defaultAudioStreamIndex(version)
playbackStore := NewPlaybackSessionStore(time.Hour, nil)
playbackStore.Put(PlaybackSession{
ID: "play-1",
CompatToken: "token-1",
ItemID: "movie-1",
UpstreamSessionID: "upstream-1",
UpstreamPlayMethod: "remux",
MediaSources: []PlaybackMediaSource{source},
})
sessionMgr := &testCompatSessionManager{
sessions: map[string]*playback.Session{
"upstream-1": {
ID: "upstream-1",
PlayMethod: playback.PlayRemux,
BasePlayMethod: playback.PlayRemux,
},
},
}
handler := &PlaybackHandler{
playbackStore: playbackStore,
sessionMgr: sessionMgr,
tm: playback.NewTranscodeManager(),
}
req := httptest.NewRequest(http.MethodPost, "/Sessions/Playing/Progress", strings.NewReader(`{"PlaySessionId":"play-1","MediaSourceId":"`+source.ID+`","AudioStreamIndex":2,"PositionTicks":30000000}`))
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{Token: "token-1"}))
rr := httptest.NewRecorder()
handler.HandleSessionPlayingProgress(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
updated, ok := playbackStore.Get("play-1")
if !ok {
t.Fatal("expected playback session")
}
if updated.MediaSources[0].SelectedAudioStreamIndex == nil {
t.Fatal("expected selected audio stream index to be stored")
}
if got := *updated.MediaSources[0].SelectedAudioStreamIndex; got != 2 {
t.Fatalf("SelectedAudioStreamIndex = %d, want 2", got)
}
if len(sessionMgr.audioTrackCalls) != 1 {
t.Fatalf("audio track update calls = %d, want 1", len(sessionMgr.audioTrackCalls))
}
if got := sessionMgr.audioTrackCalls[0].audioTrackIndex; got != 1 {
t.Fatalf("upstream audio track index = %d, want 1", got)
}
if got := sessionMgr.audioTrackCalls[0].method; got != playback.PlayRemux {
t.Fatalf("upstream play method = %q, want %q", got, playback.PlayRemux)
}
}
func TestEnsureTranscodeSession_UsesSelectedAudioTrack(t *testing.T) {
version := testCompatVersion()
codec := NewResourceIDCodec()
source := testCompatSource(codec, version)
filePath := filepath.Join(t.TempDir(), "movie.mkv")
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
t.Fatalf("write media file: %v", err)
}
playbackStore := NewPlaybackSessionStore(time.Hour, nil)
playbackStore.Put(PlaybackSession{
ID: "play-1",
MediaSources: []PlaybackMediaSource{source},
})
handler := &PlaybackHandler{
playbackStore: playbackStore,
fileResolver: testCompatFileResolver{file: &models.MediaFile{ID: version.FileID, FilePath: filePath}},
TranscodeDir: t.TempDir(),
FFmpegPath: writeCompatTestFFmpeg(t),
tm: playback.NewTranscodeManager(),
}
transcodeSession, err := handler.ensureTranscodeSession(context.Background(), "play-1", "upstream-1", source)
if err != nil {
t.Fatalf("ensureTranscodeSession: %v", err)
}
t.Cleanup(func() {
_ = transcodeSession.Close()
})
if got := transcodeSession.Opts().AudioTrackIndex; got != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1", got)
}
}
func TestStartRemoteTranscode_IncludesSelectedAudioTrack(t *testing.T) {
version := testCompatVersion()
codec := NewResourceIDCodec()
source := testCompatSource(codec, version)
filePath := filepath.Join(t.TempDir(), "movie.mkv")
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
t.Fatalf("write media file: %v", err)
}
var remoteReq transcodenode.TranscodeStartRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&remoteReq); err != nil {
t.Fatalf("decode remote request: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer server.Close()
playbackStore := NewPlaybackSessionStore(time.Hour, nil)
playbackStore.Put(PlaybackSession{ID: "play-1", UpstreamSessionID: "upstream-1"})
handler := &PlaybackHandler{
JWTSecret: "secret",
playbackStore: playbackStore,
tm: playback.NewTranscodeManager(),
}
if err := handler.startRemoteTranscode(
context.Background(),
"play-1",
"upstream-1",
source,
&models.MediaFile{ID: version.FileID, FilePath: filePath},
12,
server.URL,
); err != nil {
t.Fatalf("startRemoteTranscode: %v", err)
}
if got := remoteReq.AudioTrackIndex; got != 1 {
t.Fatalf("remote AudioTrackIndex = %d, want 1", got)
}
}