From 0c8dac06e113e0ea6f6d73df7902f36c4197e634 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 13 Mar 2026 18:51:35 +0800 Subject: [PATCH 1/3] feat(kodi): add playback controls and thread context through ControlFunc Add stop, play/pause, fast forward, rewind, next, and previous controls to all Kodi launchers via shared kodiControls factory. Thread context.Context through ControlFunc signature and Kodi client methods for proper cancellation support. Add input validation for GoTo direction parameter. --- pkg/api/methods/media_control.go | 2 +- pkg/api/methods/media_control_test.go | 8 +- pkg/platforms/batocera/platform.go | 2 +- pkg/platforms/libreelec/platform.go | 2 +- pkg/platforms/platforms.go | 9 +- pkg/platforms/shared/kodi/client.go | 74 ++++- pkg/platforms/shared/kodi/client_test.go | 326 +++++++++++++++++++- pkg/platforms/shared/kodi/interface.go | 15 +- pkg/platforms/shared/kodi/interface_test.go | 100 +++++- pkg/platforms/shared/kodi/launchers.go | 58 ++++ pkg/platforms/shared/kodi/launchers_test.go | 42 +++ pkg/platforms/shared/kodi/scanner_test.go | 36 ++- pkg/platforms/shared/kodi/types.go | 22 ++ pkg/testing/mocks/kodi_client.go | 46 ++- 14 files changed, 717 insertions(+), 25 deletions(-) diff --git a/pkg/api/methods/media_control.go b/pkg/api/methods/media_control.go index 6fe8e18a..6280b44a 100644 --- a/pkg/api/methods/media_control.go +++ b/pkg/api/methods/media_control.go @@ -65,7 +65,7 @@ func HandleMediaControl(env requests.RequestEnv) (any, error) { //nolint:gocriti var err error switch { case control.Func != nil: - err = control.Func(env.Config, platforms.ControlParams{Args: params.Args}) + err = control.Func(env.Context, env.Config, platforms.ControlParams{Args: params.Args}) case control.Script != "": err = zapscript.RunControlScript(env.Platform, env.Config, env.Database, env.State, control.Script) default: diff --git a/pkg/api/methods/media_control_test.go b/pkg/api/methods/media_control_test.go index dbd3bd93..e8331c28 100644 --- a/pkg/api/methods/media_control_test.go +++ b/pkg/api/methods/media_control_test.go @@ -72,7 +72,7 @@ func TestHandleMediaControl_UnknownAction(t *testing.T) { ID: "test-launcher", SystemID: "NES", Controls: map[string]platforms.Control{ - "save_state": {Func: func(_ *config.Instance, _ platforms.ControlParams) error { + "save_state": {Func: func(_ context.Context, _ *config.Instance, _ platforms.ControlParams) error { return nil }}, }, @@ -140,7 +140,7 @@ func TestHandleMediaControl_Success(t *testing.T) { ID: "test-launcher", SystemID: "NES", Controls: map[string]platforms.Control{ - "save_state": {Func: func(_ *config.Instance, _ platforms.ControlParams) error { + "save_state": {Func: func(_ context.Context, _ *config.Instance, _ platforms.ControlParams) error { called = true return nil }}, @@ -191,7 +191,7 @@ func TestHandleMediaControl_ArgsPassThrough(t *testing.T) { ID: "test-launcher", SystemID: "NES", Controls: map[string]platforms.Control{ - "save_state": {Func: func(_ *config.Instance, cp platforms.ControlParams) error { + "save_state": {Func: func(_ context.Context, _ *config.Instance, cp platforms.ControlParams) error { receivedArgs = cp.Args return nil }}, @@ -230,7 +230,7 @@ func TestHandleMediaControl_ArgsNilWhenOmitted(t *testing.T) { ID: "test-launcher", SystemID: "NES", Controls: map[string]platforms.Control{ - "save_state": {Func: func(_ *config.Instance, cp platforms.ControlParams) error { + "save_state": {Func: func(_ context.Context, _ *config.Instance, cp platforms.ControlParams) error { receivedArgs = cp.Args return nil }}, diff --git a/pkg/platforms/batocera/platform.go b/pkg/platforms/batocera/platform.go index d41fda1e..3ae75eaa 100644 --- a/pkg/platforms/batocera/platform.go +++ b/pkg/platforms/batocera/platform.go @@ -461,7 +461,7 @@ func (p *Platform) stopKodi(cfg *config.Instance, reason platforms.StopIntent) e // just stop playback but keep Kodi running ("Kodi mode" stays active) if reason == platforms.StopForMenu { log.Info().Msg("stopping Kodi playback (Kodi mode stays active)") - if err := client.Stop(); err != nil { + if err := client.Stop(context.Background()); err != nil { return fmt.Errorf("failed to stop Kodi playback: %w", err) } // Don't clear activeMedia - Kodi is still running and we're still in "Kodi mode" diff --git a/pkg/platforms/libreelec/platform.go b/pkg/platforms/libreelec/platform.go index 2113915c..d09f2942 100644 --- a/pkg/platforms/libreelec/platform.go +++ b/pkg/platforms/libreelec/platform.go @@ -152,7 +152,7 @@ func (p *Platform) StopActiveLauncher(_ platforms.StopIntent) error { p.setActiveMedia(nil) client := kodi.NewClient(p.cfg) - if err := client.Stop(); err != nil { + if err := client.Stop(context.Background()); err != nil { return fmt.Errorf("failed to stop Kodi client: %w", err) } return nil diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go index 48af6a07..f7188920 100644 --- a/pkg/platforms/platforms.go +++ b/pkg/platforms/platforms.go @@ -88,6 +88,11 @@ const ( ControlLoad = "load" ControlReset = "reset" ControlTogglePause = "toggle_pause" + ControlStop = "stop" + ControlFastForward = "fast_forward" + ControlRewind = "rewind" + ControlNext = "next" + ControlPrevious = "previous" ) // ControlParams contains parameters for a control action. @@ -96,7 +101,7 @@ type ControlParams struct { } // ControlFunc is a function that executes a control action on active media. -type ControlFunc func(*config.Instance, ControlParams) error +type ControlFunc func(context.Context, *config.Instance, ControlParams) error // Control represents a single control action. Either Func (native Go for // built-in launchers) or Script (zapscript string for custom launchers) @@ -351,7 +356,7 @@ func KeyboardControls(pl Platform, actions map[string]string) map[string]Control controls := make(map[string]Control, len(actions)) for action, key := range actions { controls[action] = Control{ - Func: func(_ *config.Instance, _ ControlParams) error { + Func: func(_ context.Context, _ *config.Instance, _ ControlParams) error { return pl.KeyboardPress(key) }, } diff --git a/pkg/platforms/shared/kodi/client.go b/pkg/platforms/shared/kodi/client.go index 54a50819..a1e1681c 100644 --- a/pkg/platforms/shared/kodi/client.go +++ b/pkg/platforms/shared/kodi/client.go @@ -24,6 +24,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -140,15 +141,82 @@ func (c *Client) LaunchTVEpisode(path string) error { return err } +// getFirstActivePlayer returns the first active player, or an error if none are active. +func (c *Client) getFirstActivePlayer(ctx context.Context) (*Player, error) { + players, err := c.GetActivePlayers(ctx) + if err != nil { + return nil, err + } + if len(players) == 0 { + return nil, errors.New("no active players") + } + return &players[0], nil +} + +// PlayPause toggles play/pause on the first active player +func (c *Client) PlayPause(ctx context.Context) error { + player, err := c.getFirstActivePlayer(ctx) + if err != nil { + return err + } + _, err = c.APIRequest(ctx, APIMethodPlayerPlayPause, PlayerPlayPauseParams{ + PlayerID: player.ID, + }) + return err +} + +// FastForward increases playback speed of the first active player +func (c *Client) FastForward(ctx context.Context) error { + player, err := c.getFirstActivePlayer(ctx) + if err != nil { + return err + } + _, err = c.APIRequest(ctx, APIMethodPlayerSetSpeed, PlayerSetSpeedParams{ + PlayerID: player.ID, + Speed: "increment", + }) + return err +} + +// Rewind decreases playback speed of the first active player +func (c *Client) Rewind(ctx context.Context) error { + player, err := c.getFirstActivePlayer(ctx) + if err != nil { + return err + } + _, err = c.APIRequest(ctx, APIMethodPlayerSetSpeed, PlayerSetSpeedParams{ + PlayerID: player.ID, + Speed: "decrement", + }) + return err +} + +// GoTo navigates to next or previous item in playlist. +// Direction must be "next" or "previous". +func (c *Client) GoTo(ctx context.Context, direction string) error { + if direction != "next" && direction != "previous" { + return fmt.Errorf("invalid GoTo direction: %q (must be \"next\" or \"previous\")", direction) + } + player, err := c.getFirstActivePlayer(ctx) + if err != nil { + return err + } + _, err = c.APIRequest(ctx, APIMethodPlayerGoTo, PlayerGoToParams{ + PlayerID: player.ID, + To: direction, + }) + return err +} + // Stop stops all active players in Kodi -func (c *Client) Stop() error { - players, err := c.GetActivePlayers(context.Background()) +func (c *Client) Stop(ctx context.Context) error { + players, err := c.GetActivePlayers(ctx) if err != nil { return err } for _, player := range players { - _, err := c.APIRequest(context.Background(), APIMethodPlayerStop, PlayerStopParams{ + _, err := c.APIRequest(ctx, APIMethodPlayerStop, PlayerStopParams{ PlayerID: player.ID, }) if err != nil { diff --git a/pkg/platforms/shared/kodi/client_test.go b/pkg/platforms/shared/kodi/client_test.go index cca94bd8..f03a3dcb 100644 --- a/pkg/platforms/shared/kodi/client_test.go +++ b/pkg/platforms/shared/kodi/client_test.go @@ -267,7 +267,7 @@ func TestClient_Stop_NoActivePlayers(t *testing.T) { client.SetURL(server.URL) // Execute - err := client.Stop() + err := client.Stop(context.Background()) // Verify require.NoError(t, err) @@ -329,7 +329,7 @@ func TestClient_Stop_SingleActivePlayer(t *testing.T) { client.SetURL(server.URL) // Execute - err := client.Stop() + err := client.Stop(context.Background()) // Verify require.NoError(t, err) @@ -390,7 +390,7 @@ func TestClient_Stop_MultipleActivePlayers(t *testing.T) { client.SetURL(server.URL) // Execute - err := client.Stop() + err := client.Stop(context.Background()) // Verify require.NoError(t, err) @@ -1460,3 +1460,323 @@ func TestClient_APIRequest_UsesAuthenticationWhenConfigured(t *testing.T) { assert.Equal(t, "Bearer test-bearer-token-12345", authHeader) }) } + +func TestClient_PlayPause_MakesCorrectAPICall(t *testing.T) { + t.Parallel() + + var receivedPayloads []map[string]any + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + receivedPayloads = append(receivedPayloads, payload) + method, ok := payload["method"].(string) + if !ok { + t.Fatalf("expected method to be string, got %T", payload["method"]) + } + + switch method { + case "Player.GetActivePlayers": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{ + map[string]any{"playerid": 1, "type": "video"}, + }, + } + case "Player.PlayPause": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": map[string]any{"speed": 0}, + } + default: + t.Errorf("Unexpected API method called: %s", method) + return map[string]any{} + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.PlayPause(context.Background()) + require.NoError(t, err) + + require.Len(t, receivedPayloads, 2) + assert.Equal(t, "Player.GetActivePlayers", receivedPayloads[0]["method"]) + assert.Equal(t, "Player.PlayPause", receivedPayloads[1]["method"]) + + params, ok := receivedPayloads[1]["params"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, int(params["playerid"].(float64))) +} + +func TestClient_PlayPause_NoActivePlayers(t *testing.T) { + t.Parallel() + + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{}, + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.PlayPause(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no active players") +} + +func TestClient_FastForward_NoActivePlayers(t *testing.T) { + t.Parallel() + + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{}, + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.FastForward(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no active players") +} + +func TestClient_FastForward_MakesCorrectAPICall(t *testing.T) { + t.Parallel() + + var receivedPayloads []map[string]any + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + receivedPayloads = append(receivedPayloads, payload) + method, ok := payload["method"].(string) + if !ok { + t.Fatalf("expected method to be string, got %T", payload["method"]) + } + + switch method { + case "Player.GetActivePlayers": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{ + map[string]any{"playerid": 1, "type": "video"}, + }, + } + case "Player.SetSpeed": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": map[string]any{"speed": 2}, + } + default: + t.Errorf("Unexpected API method called: %s", method) + return map[string]any{} + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.FastForward(context.Background()) + require.NoError(t, err) + + require.Len(t, receivedPayloads, 2) + assert.Equal(t, "Player.SetSpeed", receivedPayloads[1]["method"]) + + params, ok := receivedPayloads[1]["params"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, int(params["playerid"].(float64))) + assert.Equal(t, "increment", params["speed"]) +} + +func TestClient_Rewind_NoActivePlayers(t *testing.T) { + t.Parallel() + + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{}, + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.Rewind(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no active players") +} + +func TestClient_Rewind_MakesCorrectAPICall(t *testing.T) { + t.Parallel() + + var receivedPayloads []map[string]any + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + receivedPayloads = append(receivedPayloads, payload) + method, ok := payload["method"].(string) + if !ok { + t.Fatalf("expected method to be string, got %T", payload["method"]) + } + + switch method { + case "Player.GetActivePlayers": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{ + map[string]any{"playerid": 1, "type": "video"}, + }, + } + case "Player.SetSpeed": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": map[string]any{"speed": -2}, + } + default: + t.Errorf("Unexpected API method called: %s", method) + return map[string]any{} + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.Rewind(context.Background()) + require.NoError(t, err) + + require.Len(t, receivedPayloads, 2) + assert.Equal(t, "Player.SetSpeed", receivedPayloads[1]["method"]) + + params, ok := receivedPayloads[1]["params"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, int(params["playerid"].(float64))) + assert.Equal(t, "decrement", params["speed"]) +} + +func TestClient_GoTo_NoActivePlayers(t *testing.T) { + t.Parallel() + + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{}, + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.GoTo(context.Background(), "next") + require.Error(t, err) + assert.Contains(t, err.Error(), "no active players") +} + +func TestClient_GoTo_Next_MakesCorrectAPICall(t *testing.T) { + t.Parallel() + + var receivedPayloads []map[string]any + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + receivedPayloads = append(receivedPayloads, payload) + method, ok := payload["method"].(string) + if !ok { + t.Fatalf("expected method to be string, got %T", payload["method"]) + } + + switch method { + case "Player.GetActivePlayers": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{ + map[string]any{"playerid": 1, "type": "video"}, + }, + } + case "Player.GoTo": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": "OK", + } + default: + t.Errorf("Unexpected API method called: %s", method) + return map[string]any{} + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.GoTo(context.Background(), "next") + require.NoError(t, err) + + require.Len(t, receivedPayloads, 2) + assert.Equal(t, "Player.GoTo", receivedPayloads[1]["method"]) + + params, ok := receivedPayloads[1]["params"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, int(params["playerid"].(float64))) + assert.Equal(t, "next", params["to"]) +} + +func TestClient_GoTo_Previous_MakesCorrectAPICall(t *testing.T) { + t.Parallel() + + var receivedPayloads []map[string]any + server := createMockKodiServer(t, func(payload map[string]any) map[string]any { + receivedPayloads = append(receivedPayloads, payload) + method, ok := payload["method"].(string) + if !ok { + t.Fatalf("expected method to be string, got %T", payload["method"]) + } + + switch method { + case "Player.GetActivePlayers": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": []any{ + map[string]any{"playerid": 1, "type": "video"}, + }, + } + case "Player.GoTo": + return map[string]any{ + "jsonrpc": "2.0", + "id": payload["id"], + "result": "OK", + } + default: + t.Errorf("Unexpected API method called: %s", method) + return map[string]any{} + } + }) + defer server.Close() + + client := kodi.NewClient(nil) + client.SetURL(server.URL) + + err := client.GoTo(context.Background(), "previous") + require.NoError(t, err) + + require.Len(t, receivedPayloads, 2) + assert.Equal(t, "Player.GoTo", receivedPayloads[1]["method"]) + + params, ok := receivedPayloads[1]["params"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, int(params["playerid"].(float64))) + assert.Equal(t, "previous", params["to"]) +} diff --git a/pkg/platforms/shared/kodi/interface.go b/pkg/platforms/shared/kodi/interface.go index 7b8f24a8..4c25ddf9 100644 --- a/pkg/platforms/shared/kodi/interface.go +++ b/pkg/platforms/shared/kodi/interface.go @@ -39,7 +39,20 @@ type KodiClient interface { LaunchTVEpisode(path string) error // Stop stops all active players in Kodi - Stop() error + Stop(ctx context.Context) error + + // PlayPause toggles play/pause on the first active player + PlayPause(ctx context.Context) error + + // FastForward increases playback speed of the first active player + FastForward(ctx context.Context) error + + // Rewind decreases playback speed of the first active player + Rewind(ctx context.Context) error + + // GoTo navigates to next or previous item in playlist. + // Direction should be "next" or "previous". + GoTo(ctx context.Context, direction string) error // Quit gracefully exits Kodi application Quit(ctx context.Context) error diff --git a/pkg/platforms/shared/kodi/interface_test.go b/pkg/platforms/shared/kodi/interface_test.go index bcd46de7..f98267c5 100644 --- a/pkg/platforms/shared/kodi/interface_test.go +++ b/pkg/platforms/shared/kodi/interface_test.go @@ -61,8 +61,8 @@ func (m *MockKodiClient) LaunchTVEpisode(path string) error { return nil } -func (m *MockKodiClient) Stop() error { - args := m.Called() +func (m *MockKodiClient) Stop(ctx context.Context) error { + args := m.Called(ctx) if err := args.Error(0); err != nil { return fmt.Errorf("mock Stop error: %w", err) } @@ -171,6 +171,38 @@ func (m *MockKodiClient) APIRequest(ctx context.Context, method kodi.APIMethod, return nil, nil } +func (m *MockKodiClient) PlayPause(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock PlayPause error: %w", err) + } + return nil +} + +func (m *MockKodiClient) FastForward(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock FastForward error: %w", err) + } + return nil +} + +func (m *MockKodiClient) Rewind(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock Rewind error: %w", err) + } + return nil +} + +func (m *MockKodiClient) GoTo(ctx context.Context, direction string) error { + args := m.Called(ctx, direction) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock GoTo error: %w", err) + } + return nil +} + func (m *MockKodiClient) LaunchSong(path string) error { args := m.Called(path) return args.Error(0) //nolint:wrapcheck // Mock implementation, error wrapping not needed @@ -306,13 +338,13 @@ func TestKodiClient_Stop(t *testing.T) { // This test drives the addition of Stop method - critical for fixing the broken implementation mockClient := new(MockKodiClient) - mockClient.On("Stop").Return(nil) + mockClient.On("Stop", mock.Anything).Return(nil) // Use as KodiClient interface var client kodi.KodiClient = mockClient // Test Stop method exists and can be called - err := client.Stop() + err := client.Stop(context.Background()) require.NoError(t, err) mockClient.AssertExpectations(t) @@ -478,6 +510,66 @@ func TestKodiClient_SetURL(t *testing.T) { mockClient.AssertExpectations(t) } +func TestKodiClient_PlayPause(t *testing.T) { + t.Parallel() + + mockClient := new(MockKodiClient) + mockClient.On("PlayPause", mock.Anything).Return(nil) + + var client kodi.KodiClient = mockClient + + err := client.PlayPause(context.Background()) + require.NoError(t, err) + + mockClient.AssertExpectations(t) +} + +func TestKodiClient_FastForward(t *testing.T) { + t.Parallel() + + mockClient := new(MockKodiClient) + mockClient.On("FastForward", mock.Anything).Return(nil) + + var client kodi.KodiClient = mockClient + + err := client.FastForward(context.Background()) + require.NoError(t, err) + + mockClient.AssertExpectations(t) +} + +func TestKodiClient_Rewind(t *testing.T) { + t.Parallel() + + mockClient := new(MockKodiClient) + mockClient.On("Rewind", mock.Anything).Return(nil) + + var client kodi.KodiClient = mockClient + + err := client.Rewind(context.Background()) + require.NoError(t, err) + + mockClient.AssertExpectations(t) +} + +func TestKodiClient_GoTo(t *testing.T) { + t.Parallel() + + mockClient := new(MockKodiClient) + mockClient.On("GoTo", mock.Anything, "next").Return(nil) + mockClient.On("GoTo", mock.Anything, "previous").Return(nil) + + var client kodi.KodiClient = mockClient + + err := client.GoTo(context.Background(), "next") + require.NoError(t, err) + + err = client.GoTo(context.Background(), "previous") + require.NoError(t, err) + + mockClient.AssertExpectations(t) +} + func TestKodiClient_CollectionLaunchMethods(t *testing.T) { t.Parallel() diff --git a/pkg/platforms/shared/kodi/launchers.go b/pkg/platforms/shared/kodi/launchers.go index a69a863d..3cc7f096 100644 --- a/pkg/platforms/shared/kodi/launchers.go +++ b/pkg/platforms/shared/kodi/launchers.go @@ -29,11 +29,62 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared" ) +// kodiControls builds a Controls map for Kodi launchers with standard +// playback controls. The launcherID and groups are captured by closures +// so each control creates the correct Kodi client at invocation time. +func kodiControls( + id string, groups []string, +) map[string]platforms.Control { + cf := func( + fn func(context.Context, KodiClient) error, + ) platforms.ControlFunc { + return func( + ctx context.Context, cfg *config.Instance, + _ platforms.ControlParams, + ) error { + return fn(ctx, NewClientWithLauncherID(cfg, id, groups)) + } + } + return map[string]platforms.Control{ + platforms.ControlTogglePause: { + Func: cf(func(ctx context.Context, c KodiClient) error { + return c.PlayPause(ctx) + }), + }, + platforms.ControlStop: { + Func: cf(func(ctx context.Context, c KodiClient) error { + return c.Stop(ctx) + }), + }, + platforms.ControlFastForward: { + Func: cf(func(ctx context.Context, c KodiClient) error { + return c.FastForward(ctx) + }), + }, + platforms.ControlRewind: { + Func: cf(func(ctx context.Context, c KodiClient) error { + return c.Rewind(ctx) + }), + }, + platforms.ControlNext: { + Func: cf(func(ctx context.Context, c KodiClient) error { + return c.GoTo(ctx, "next") + }), + }, + platforms.ControlPrevious: { + Func: cf(func(ctx context.Context, c KodiClient) error { + return c.GoTo(ctx, "previous") + }), + }, + } +} + // NewKodiLocalLauncher creates a standard KodiLocalVideo launcher for direct video file playback func NewKodiLocalLauncher() platforms.Launcher { id := shared.LauncherKodiLocalVideo groups := []string{shared.GroupKodi} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemVideo, @@ -56,6 +107,7 @@ func NewKodiMovieLauncher() platforms.Launcher { id := shared.LauncherKodiMovie groups := []string{shared.GroupKodi} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemMovie, @@ -83,6 +135,7 @@ func NewKodiTVLauncher() platforms.Launcher { id := shared.LauncherKodiTVEpisode groups := []string{shared.GroupKodi, shared.GroupKodiTV} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemTVEpisode, @@ -110,6 +163,7 @@ func NewKodiMusicLauncher() platforms.Launcher { id := shared.LauncherKodiLocalAudio groups := []string{shared.GroupKodi, shared.GroupKodiMusic} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemMusicTrack, @@ -130,6 +184,7 @@ func NewKodiAlbumLauncher() platforms.Launcher { id := shared.LauncherKodiAlbum groups := []string{shared.GroupKodi, shared.GroupKodiMusic} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemMusicAlbum, @@ -157,6 +212,7 @@ func NewKodiArtistLauncher() platforms.Launcher { id := shared.LauncherKodiArtist groups := []string{shared.GroupKodi, shared.GroupKodiMusic} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemMusicArtist, @@ -184,6 +240,7 @@ func NewKodiTVShowLauncher() platforms.Launcher { id := shared.LauncherKodiTVShow groups := []string{shared.GroupKodi, shared.GroupKodiTV} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemTVShow, @@ -211,6 +268,7 @@ func NewKodiSongLauncher() platforms.Launcher { id := shared.LauncherKodiSong groups := []string{shared.GroupKodi, shared.GroupKodiMusic} return platforms.Launcher{ + Controls: kodiControls(id, groups), ID: id, Groups: groups, SystemID: systemdefs.SystemMusicTrack, diff --git a/pkg/platforms/shared/kodi/launchers_test.go b/pkg/platforms/shared/kodi/launchers_test.go index 99d50dae..92a8372a 100644 --- a/pkg/platforms/shared/kodi/launchers_test.go +++ b/pkg/platforms/shared/kodi/launchers_test.go @@ -23,10 +23,20 @@ import ( "testing" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared" "github.com/stretchr/testify/assert" ) +var expectedControls = []string{ + platforms.ControlTogglePause, + platforms.ControlStop, + platforms.ControlFastForward, + platforms.ControlRewind, + platforms.ControlNext, + platforms.ControlPrevious, +} + // TestNewKodiLocalLauncher tests the creation of standard KodiLocal launcher func TestNewKodiLocalLauncher(t *testing.T) { t.Parallel() @@ -45,6 +55,10 @@ func TestNewKodiLocalLauncher(t *testing.T) { } assert.Equal(t, expectedExtensions, launcher.Extensions) assert.NotNil(t, launcher.Launch, "Launch function should be set") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiMovieLauncher tests the creation of standard KodiMovie launcher @@ -58,6 +72,10 @@ func TestNewKodiMovieLauncher(t *testing.T) { assert.Equal(t, []string{shared.SchemeKodiMovie}, launcher.Schemes) assert.NotNil(t, launcher.Launch, "Launch function should be set") assert.NotNil(t, launcher.Scanner, "Scanner function should be set") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiTVLauncher tests the creation of standard KodiTV launcher @@ -71,6 +89,10 @@ func TestNewKodiTVLauncher(t *testing.T) { assert.Equal(t, []string{shared.SchemeKodiEpisode}, launcher.Schemes) assert.NotNil(t, launcher.Launch, "Launch function should be set") assert.NotNil(t, launcher.Scanner, "Scanner function should be set") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiSongLauncher tests the creation of KodiSong launcher for individual songs @@ -85,6 +107,10 @@ func TestNewKodiSongLauncher(t *testing.T) { assert.NotNil(t, launcher.Launch, "Launch function should be set") // Scanner will be tested when scanners are implemented // assert.NotNil(t, launcher.Scanner, "Scanner function should be set") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiMusicLauncher tests the creation of KodiMusic launcher for local music files @@ -102,6 +128,10 @@ func TestNewKodiMusicLauncher(t *testing.T) { assert.Contains(t, launcher.Extensions, ".m4a") assert.NotNil(t, launcher.Launch, "Launch function should be set") assert.Nil(t, launcher.Scanner, "Scanner function should not be set for local files") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiAlbumLauncher tests the creation of KodiAlbum launcher for album collection playback @@ -115,6 +145,10 @@ func TestNewKodiAlbumLauncher(t *testing.T) { assert.Equal(t, []string{shared.SchemeKodiAlbum}, launcher.Schemes) assert.NotNil(t, launcher.Launch, "Launch function should be set") assert.NotNil(t, launcher.Scanner, "Scanner function should be set for collection") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiArtistLauncher tests the creation of KodiArtist launcher for artist collection playback @@ -128,6 +162,10 @@ func TestNewKodiArtistLauncher(t *testing.T) { assert.Equal(t, []string{shared.SchemeKodiArtist}, launcher.Schemes) assert.NotNil(t, launcher.Launch, "Launch function should be set") assert.NotNil(t, launcher.Scanner, "Scanner function should be set for collection") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } // TestNewKodiTVShowLauncher tests the creation of KodiTVShow launcher for TV show collection playback @@ -141,4 +179,8 @@ func TestNewKodiTVShowLauncher(t *testing.T) { assert.Equal(t, []string{shared.SchemeKodiShow}, launcher.Schemes) assert.NotNil(t, launcher.Launch, "Launch function should be set") assert.NotNil(t, launcher.Scanner, "Scanner function should be set for collection") + assert.Len(t, launcher.Controls, 6) + for _, action := range expectedControls { + assert.Contains(t, launcher.Controls, action) + } } diff --git a/pkg/platforms/shared/kodi/scanner_test.go b/pkg/platforms/shared/kodi/scanner_test.go index e83b9eab..b30529ba 100644 --- a/pkg/platforms/shared/kodi/scanner_test.go +++ b/pkg/platforms/shared/kodi/scanner_test.go @@ -62,8 +62,8 @@ func (m *MockKodiClient) LaunchTVEpisode(path string) error { return nil } -func (m *MockKodiClient) Stop() error { - args := m.Called() +func (m *MockKodiClient) Stop(ctx context.Context) error { + args := m.Called(ctx) if err := args.Error(0); err != nil { return fmt.Errorf("mock Stop error: %w", err) } @@ -172,6 +172,38 @@ func (m *MockKodiClient) APIRequest(ctx context.Context, method APIMethod, param return nil, nil } +func (m *MockKodiClient) PlayPause(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock PlayPause error: %w", err) + } + return nil +} + +func (m *MockKodiClient) FastForward(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock FastForward error: %w", err) + } + return nil +} + +func (m *MockKodiClient) Rewind(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock Rewind error: %w", err) + } + return nil +} + +func (m *MockKodiClient) GoTo(ctx context.Context, direction string) error { + args := m.Called(ctx, direction) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock GoTo error: %w", err) + } + return nil +} + func (m *MockKodiClient) LaunchSong(path string) error { args := m.Called(path) if err := args.Error(0); err != nil { diff --git a/pkg/platforms/shared/kodi/types.go b/pkg/platforms/shared/kodi/types.go index cad0e5e3..9d074762 100644 --- a/pkg/platforms/shared/kodi/types.go +++ b/pkg/platforms/shared/kodi/types.go @@ -99,6 +99,11 @@ const ( APIMethodPlaylistClear APIMethod = "Playlist.Clear" APIMethodPlaylistAdd APIMethod = "Playlist.Add" + // Player Controls + APIMethodPlayerPlayPause APIMethod = "Player.PlayPause" + APIMethodPlayerSetSpeed APIMethod = "Player.SetSpeed" + APIMethodPlayerGoTo APIMethod = "Player.GoTo" + // Application Control APIMethodApplicationQuit APIMethod = "Application.Quit" ) @@ -154,6 +159,23 @@ type PlayerStopParams struct { PlayerID int `json:"playerid"` } +// PlayerPlayPauseParams represents parameters for Player.PlayPause API method +type PlayerPlayPauseParams struct { + PlayerID int `json:"playerid"` +} + +// PlayerSetSpeedParams represents parameters for Player.SetSpeed API method +type PlayerSetSpeedParams struct { + Speed string `json:"speed"` + PlayerID int `json:"playerid"` +} + +// PlayerGoToParams represents parameters for Player.GoTo API method +type PlayerGoToParams struct { + To string `json:"to"` + PlayerID int `json:"playerid"` +} + // VideoLibraryGetMoviesResponse represents the response from VideoLibrary.GetMovies type VideoLibraryGetMoviesResponse struct { Movies []Movie `json:"movies"` diff --git a/pkg/testing/mocks/kodi_client.go b/pkg/testing/mocks/kodi_client.go index dbcb7c46..9033dafd 100644 --- a/pkg/testing/mocks/kodi_client.go +++ b/pkg/testing/mocks/kodi_client.go @@ -101,9 +101,45 @@ func (m *MockKodiClient) LaunchTVShow(path string) error { return nil } +// PlayPause mocks toggling play/pause on the first active player +func (m *MockKodiClient) PlayPause(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock PlayPause error: %w", err) + } + return nil +} + +// FastForward mocks increasing playback speed of the first active player +func (m *MockKodiClient) FastForward(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock FastForward error: %w", err) + } + return nil +} + +// Rewind mocks decreasing playback speed of the first active player +func (m *MockKodiClient) Rewind(ctx context.Context) error { + args := m.Called(ctx) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock Rewind error: %w", err) + } + return nil +} + +// GoTo mocks navigating to next or previous item in playlist +func (m *MockKodiClient) GoTo(ctx context.Context, direction string) error { + args := m.Called(ctx, direction) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock GoTo error: %w", err) + } + return nil +} + // Stop mocks stopping all active players in Kodi -func (m *MockKodiClient) Stop() error { - args := m.Called() +func (m *MockKodiClient) Stop(ctx context.Context) error { + args := m.Called(ctx) if err := args.Error(0); err != nil { return fmt.Errorf("mock Stop error: %w", err) } @@ -273,7 +309,11 @@ func (m *MockKodiClient) SetupBasicMock() { m.On("LaunchFile", mock.AnythingOfType("string")).Return(nil).Maybe() m.On("LaunchMovie", mock.AnythingOfType("string")).Return(nil).Maybe() m.On("LaunchTVEpisode", mock.AnythingOfType("string")).Return(nil).Maybe() - m.On("Stop").Return(nil).Maybe() + m.On("Stop", mock.Anything).Return(nil).Maybe() + m.On("PlayPause", mock.Anything).Return(nil).Maybe() + m.On("FastForward", mock.Anything).Return(nil).Maybe() + m.On("Rewind", mock.Anything).Return(nil).Maybe() + m.On("GoTo", mock.Anything, mock.AnythingOfType("string")).Return(nil).Maybe() m.On("Quit", mock.Anything).Return(nil).Maybe() m.On("GetActivePlayers", mock.Anything).Return([]kodi.Player{}, nil).Maybe() m.On("GetPlayerItem", mock.Anything, mock.AnythingOfType("int")).Return((*kodi.PlayerItem)(nil), nil).Maybe() From fd5ce964ec9a2f2c4326c80e38499d3f96811afc Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 15 Mar 2026 17:04:45 +0800 Subject: [PATCH 2/3] refactor(service): precommit review improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread LauncherCtx from LauncherManager to all commands, not just media-launching ones, so control commands get a cancellable context - Use sync.Once for ZapScript command map initialization instead of rebuilding on every lookup - Rename ServiceContext fields for clarity: Cfg→Config, LSQ→LaunchSoftwareQueue, PLQ→PlaylistQueue - Remove local variable aliases in readerManager, use svc.* consistently - Add skipped test for expression propagation in control scripts, blocked by go-zapscript#2 --- go.mod | 6 +- go.sum | 4 + pkg/api/methods/media_control.go | 3 +- pkg/service/context.go | 40 +++ pkg/service/hooks.go | 30 +- pkg/service/hooks_test.go | 129 ++++----- pkg/service/queues.go | 122 ++++---- pkg/service/queues_helpers.go | 21 +- pkg/service/queues_helpers_test.go | 186 ++++++------ pkg/service/queues_playlist_test.go | 37 +-- pkg/service/reader_manager_test.go | 11 +- pkg/service/readers.go | 122 ++++---- pkg/service/scan_behavior_test.go | 13 +- pkg/service/service.go | 15 +- pkg/zapscript/commands.go | 164 ++++++----- pkg/zapscript/commands_test.go | 79 +++--- pkg/zapscript/control.go | 22 +- pkg/zapscript/control_cmd.go | 113 ++++++++ pkg/zapscript/control_cmd_test.go | 420 ++++++++++++++++++++++++++++ pkg/zapscript/control_test.go | 98 +++---- 20 files changed, 1083 insertions(+), 552 deletions(-) create mode 100644 pkg/service/context.go create mode 100644 pkg/zapscript/control_cmd.go create mode 100644 pkg/zapscript/control_cmd_test.go diff --git a/go.mod b/go.mod index fe4389ac..b1712f51 100755 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/ZaparooProject/zaparoo-core/v2 -go 1.25.7 +go 1.26.1 require ( fyne.io/systray v1.11.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Microsoft/go-winio v0.6.2 github.com/ZaparooProject/go-pn532 v0.20.3 - github.com/ZaparooProject/go-zapscript v0.1.0 + github.com/ZaparooProject/go-zapscript v0.2.0 github.com/adrg/xdg v0.5.3 github.com/andygrunwald/vdf v1.1.0 github.com/bendahl/uinput v1.7.0 @@ -76,7 +76,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/ebitengine/purego v0.9.1 // indirect - github.com/expr-lang/expr v1.17.7 // indirect + github.com/expr-lang/expr v1.17.8 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/geoffgarside/ber v1.1.0 // indirect diff --git a/go.sum b/go.sum index a1814924..519778f3 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/ZaparooProject/go-pn532 v0.20.3 h1:okjV/BVttJZvjXDZ0wuNlI165iT3N9g1B+ github.com/ZaparooProject/go-pn532 v0.20.3/go.mod h1:38r+NvAyNsUa/McJC4FEk2+uxOzrN1AUgrh/fpc0oLk= github.com/ZaparooProject/go-zapscript v0.1.0 h1:IlDzx4WsYOy17cADbWyegIjB7wprAy6dNr6WsXyx/Ts= github.com/ZaparooProject/go-zapscript v0.1.0/go.mod h1:mXkIhNUoWCdsoUkjFPmbVba9PTkzD5H4ntmIxnNNSD0= +github.com/ZaparooProject/go-zapscript v0.2.0 h1:MrZ1923LgLCcybX7kO5uJEHM8tZn995tXzJsSkK0Ep4= +github.com/ZaparooProject/go-zapscript v0.2.0/go.mod h1:P5qov6iCMzYiSF3tqr8BAanvNNh/YjPuUENfmPo6Yj4= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andygrunwald/vdf v1.1.0 h1:gmstp0R7DOepIZvWoSJY97ix7QOrsxpGPU6KusKXqvw= @@ -57,6 +59,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= diff --git a/pkg/api/methods/media_control.go b/pkg/api/methods/media_control.go index 6280b44a..c3a14971 100644 --- a/pkg/api/methods/media_control.go +++ b/pkg/api/methods/media_control.go @@ -67,7 +67,8 @@ func HandleMediaControl(env requests.RequestEnv) (any, error) { //nolint:gocriti case control.Func != nil: err = control.Func(env.Context, env.Config, platforms.ControlParams{Args: params.Args}) case control.Script != "": - err = zapscript.RunControlScript(env.Platform, env.Config, env.Database, env.State, control.Script) + exprEnv := zapscript.GetExprEnv(env.Platform, env.Config, env.State, nil, nil) + err = zapscript.RunControlScript(env.Platform, env.Config, env.Database, control.Script, &exprEnv) default: err = fmt.Errorf("control %q has no implementation", params.Action) } diff --git a/pkg/service/context.go b/pkg/service/context.go new file mode 100644 index 00000000..993bc20d --- /dev/null +++ b/pkg/service/context.go @@ -0,0 +1,40 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package service + +import ( + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" +) + +// ServiceContext holds the shared dependencies threaded through all +// service-layer functions. Created once in Start() and passed by pointer. +type ServiceContext struct { + Platform platforms.Platform + Config *config.Instance + State *state.State + DB *database.Database + LaunchSoftwareQueue chan *tokens.Token + PlaylistQueue chan *playlists.Playlist +} diff --git a/pkg/service/hooks.go b/pkg/service/hooks.go index 2fea3c12..5c82e04a 100644 --- a/pkg/service/hooks.go +++ b/pkg/service/hooks.go @@ -22,11 +22,8 @@ package service import ( "time" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + gozapscript "github.com/ZaparooProject/go-zapscript" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/rs/zerolog/log" @@ -34,22 +31,19 @@ import ( // runHook executes a hook script with the standard playlist from state. // Returns error if the script fails (for blocking hooks) or nil on success. +// The scanned/launching params provide optional context for the expression env. func runHook( - pl platforms.Platform, - cfg *config.Instance, - st *state.State, - db *database.Database, - lsq chan<- *tokens.Token, - plq chan *playlists.Playlist, + svc *ServiceContext, hookName string, script string, - exprOpts *zapscript.ExprEnvOptions, + scanned *gozapscript.ExprEnvScanned, + launching *gozapscript.ExprEnvLaunching, ) error { log.Info().Msgf("running %s: %s", hookName, script) plsc := playlists.PlaylistController{ - Active: st.GetActivePlaylist(), - Queue: plq, + Active: svc.State.GetActivePlaylist(), + Queue: svc.PlaylistQueue, } t := tokens.Token{ @@ -58,12 +52,6 @@ func runHook( Source: tokens.SourceHook, } - // Ensure InHookContext is set to prevent recursive hook execution - hookOpts := &zapscript.ExprEnvOptions{InHookContext: true} - if exprOpts != nil { - hookOpts.Scanned = exprOpts.Scanned - hookOpts.Launching = exprOpts.Launching - } - - return runTokenZapScript(pl, cfg, st, t, db, lsq, plsc, hookOpts) + hookEnv := zapscript.GetExprEnv(svc.Platform, svc.Config, svc.State, scanned, launching) + return runTokenZapScript(svc, t, plsc, &hookEnv, true) } diff --git a/pkg/service/hooks_test.go b/pkg/service/hooks_test.go index d9ef5270..614068ab 100644 --- a/pkg/service/hooks_test.go +++ b/pkg/service/hooks_test.go @@ -23,29 +23,18 @@ import ( "testing" gozapscript "github.com/ZaparooProject/go-zapscript" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -type hookTestEnv struct { - platform *mocks.MockPlatform - cfg *config.Instance - st *state.State - db *database.Database - lsq chan *tokens.Token - plq chan *playlists.Playlist -} - -func setupHookTest(t *testing.T) *hookTestEnv { +func setupHookTest(t *testing.T) *ServiceContext { t.Helper() fs := testhelpers.NewMemoryFS() @@ -61,135 +50,119 @@ func setupHookTest(t *testing.T) *hookTestEnv { mockMediaDB := &testhelpers.MockMediaDBI{} - db := &database.Database{ - UserDB: mockUserDB, - MediaDB: mockMediaDB, - } - st, _ := state.NewState(mockPlatform, "test-boot-uuid") - return &hookTestEnv{ - platform: mockPlatform, - cfg: cfg, - st: st, - db: db, - lsq: make(chan *tokens.Token, 1), - plq: make(chan *playlists.Playlist, 1), + return &ServiceContext{ + Platform: mockPlatform, + Config: cfg, + State: st, + DB: &database.Database{ + UserDB: mockUserDB, + MediaDB: mockMediaDB, + }, + LaunchSoftwareQueue: make(chan *tokens.Token, 1), + PlaylistQueue: make(chan *playlists.Playlist, 1), } } -func (e *hookTestEnv) runHook(hookName, script string, opts *zapscript.ExprEnvOptions) error { - return runHook(e.platform, e.cfg, e.st, e.db, e.lsq, e.plq, hookName, script, opts) -} - func TestRunHook_BasicExecution(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - err := env.runHook("test_hook", "**echo:test message", nil) + err := runHook(svc, "test_hook", "**echo:test message", nil, nil) assert.NoError(t, err, "echo hook should succeed") } func TestRunHook_WithScannedContext(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - scannedOpts := &zapscript.ExprEnvOptions{ - Scanned: &gozapscript.ExprEnvScanned{ - ID: "test-token-id", - Value: "**launch:/games/sonic.bin", - Data: "raw-ndef-data", - }, + scanned := &gozapscript.ExprEnvScanned{ + ID: "test-token-id", + Value: "**launch:/games/sonic.bin", + Data: "raw-ndef-data", } - err := env.runHook("on_scan", "**echo:scanned", scannedOpts) + err := runHook(svc, "on_scan", "**echo:scanned", scanned, nil) assert.NoError(t, err, "hook with scanned context should succeed") } func TestRunHook_WithLaunchingContext(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - launchingOpts := &zapscript.ExprEnvOptions{ - Launching: &gozapscript.ExprEnvLaunching{ - Path: "/games/genesis/sonic.bin", - SystemID: "genesis", - LauncherID: "retroarch", - }, + launching := &gozapscript.ExprEnvLaunching{ + Path: "/games/genesis/sonic.bin", + SystemID: "genesis", + LauncherID: "retroarch", } - err := env.runHook("before_media_start", "**echo:launching", launchingOpts) + err := runHook(svc, "before_media_start", "**echo:launching", nil, launching) assert.NoError(t, err, "hook with launching context should succeed") } -func TestRunHook_SetsInHookContext(t *testing.T) { +func TestRunHook_AlwaysInHookContext(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - // Pass opts without InHookContext set - runHook should set it internally - opts := &zapscript.ExprEnvOptions{ - Scanned: &gozapscript.ExprEnvScanned{ - ID: "test-id", - }, - InHookContext: false, // Should be overridden to true by runHook + // Hooks always run in hook context (inHookContext=true), which means + // before_media_start hooks inside hooks are blocked (no recursion). + scanned := &gozapscript.ExprEnvScanned{ + ID: "test-id", } - err := env.runHook("test_hook", "**echo:test", opts) + err := runHook(svc, "test_hook", "**echo:test", scanned, nil) assert.NoError(t, err) - // The function always creates new opts with InHookContext=true, preserving other fields } func TestRunHook_PreservesScannedAndLaunching(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - // Provide both Scanned and Launching contexts - opts := &zapscript.ExprEnvOptions{ - Scanned: &gozapscript.ExprEnvScanned{ - ID: "scanned-id", - Value: "scanned-value", - Data: "scanned-data", - }, - Launching: &gozapscript.ExprEnvLaunching{ - Path: "/path/to/game", - SystemID: "snes", - LauncherID: "mister", - }, + scanned := &gozapscript.ExprEnvScanned{ + ID: "scanned-id", + Value: "scanned-value", + Data: "scanned-data", + } + launching := &gozapscript.ExprEnvLaunching{ + Path: "/path/to/game", + SystemID: "snes", + LauncherID: "mister", } - err := env.runHook("combined_hook", "**echo:both contexts", opts) + err := runHook(svc, "combined_hook", "**echo:both contexts", scanned, launching) assert.NoError(t, err, "hook with both contexts should succeed") } func TestRunHook_InvalidScript(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - err := env.runHook("test_hook", "**unknown_command:arg", nil) + err := runHook(svc, "test_hook", "**unknown_command:arg", nil, nil) assert.Error(t, err, "unknown command should return error") } func TestRunHook_EmptyScript(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - err := env.runHook("test_hook", "", nil) + err := runHook(svc, "test_hook", "", nil, nil) require.Error(t, err, "empty script should return error") assert.Contains(t, err.Error(), "script is empty") } -func TestRunHook_NilExprOpts(t *testing.T) { +func TestRunHook_NilContextParams(t *testing.T) { t.Parallel() - env := setupHookTest(t) + svc := setupHookTest(t) - err := env.runHook("test_hook", "**echo:nil opts test", nil) - assert.NoError(t, err, "nil exprOpts should work") + err := runHook(svc, "test_hook", "**echo:nil opts test", nil, nil) + assert.NoError(t, err, "nil scanned/launching should work") } diff --git a/pkg/service/queues.go b/pkg/service/queues.go index baf9b2f0..b723a303 100644 --- a/pkg/service/queues.go +++ b/pkg/service/queues.go @@ -29,14 +29,11 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/notifications" "github.com/ZaparooProject/zaparoo-core/v2/pkg/assets" "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/google/uuid" @@ -45,21 +42,18 @@ import ( ) func runTokenZapScript( - platform platforms.Platform, - cfg *config.Instance, - st *state.State, + svc *ServiceContext, token tokens.Token, //nolint:gocritic // single-use parameter in service function - db *database.Database, - lsq chan<- *tokens.Token, plsc playlists.PlaylistController, - exprOpts *zapscript.ExprEnvOptions, + exprEnv *gozapscript.ArgExprEnv, + inHookContext bool, ) error { - if !st.RunZapScriptEnabled() { + if !svc.State.RunZapScriptEnabled() { log.Warn().Msg("ignoring ZapScript, run ZapScript is disabled") return nil } - mappedValue, hasMapping := getMapping(cfg, db, platform, token) + mappedValue, hasMapping := getMapping(svc.Config, svc.DB, svc.Platform, token) if hasMapping { log.Info().Msgf("found mapping: %s", mappedValue) token.Text = mappedValue @@ -80,8 +74,8 @@ func runTokenZapScript( cmd := cmds[i] // Run before_media_start hook; errors block the launch. - beforeMediaStartScript := cfg.LaunchersBeforeMediaStart() - if shouldRunBeforeMediaStartHook(exprOpts, beforeMediaStartScript, cmd.Name) { + beforeMediaStartScript := svc.Config.LaunchersBeforeMediaStart() + if shouldRunBeforeMediaStartHook(inHookContext, beforeMediaStartScript, cmd.Name) { log.Info().Msgf("running before_media_start hook: %s", beforeMediaStartScript) hookPlsc := playlists.PlaylistController{ Active: pls, @@ -91,15 +85,23 @@ func runTokenZapScript( ScanTime: time.Now(), Text: beforeMediaStartScript, } - launchingOpts := buildLaunchingExprOpts(cmd) - hookErr := runTokenZapScript(platform, cfg, st, hookToken, db, lsq, hookPlsc, launchingOpts) + launching := buildLaunchingContext(cmd) + hookEnv := zapscript.GetExprEnv(svc.Platform, svc.Config, svc.State, nil, launching) + hookErr := runTokenZapScript(svc, hookToken, hookPlsc, &hookEnv, true) if hookErr != nil { return fmt.Errorf("before_media_start hook blocked launch: %w", hookErr) } } + var cmdEnv gozapscript.ArgExprEnv + if exprEnv != nil { + cmdEnv = *exprEnv + } else { + cmdEnv = zapscript.GetExprEnv(svc.Platform, svc.Config, svc.State, nil, nil) + } + result, err := zapscript.RunCommand( - platform, cfg, + svc.Platform, svc.Config, playlists.PlaylistController{ Active: pls, Queue: plsc.Queue, @@ -108,9 +110,9 @@ func runTokenZapScript( cmd, len(script.Cmds), i, - db, - st, - exprOpts, + svc.DB, + svc.State.LauncherManager(), + &cmdEnv, ) if err != nil { return fmt.Errorf("failed to run zapscript command: %w", err) @@ -120,17 +122,18 @@ func runTokenZapScript( log.Debug().Any("token", token).Msg("cmd launch: clearing current playlist") select { case plsc.Queue <- nil: - case <-st.GetContext().Done(): + case <-svc.State.GetContext().Done(): return errors.New("service shutting down") } } if result.MediaChanged && token.ReaderID != "" { - if r, ok := st.GetReader(token.ReaderID); ok && readers.HasCapability(r, readers.CapabilityRemovable) { + r, ok := svc.State.GetReader(token.ReaderID) + if ok && readers.HasCapability(r, readers.CapabilityRemovable) { log.Debug().Any("token", token).Msg("media changed, updating software token") select { - case lsq <- &token: - case <-st.GetContext().Done(): + case svc.LaunchSoftwareQueue <- &token: + case <-svc.State.GetContext().Done(): return errors.New("service shutting down") } } @@ -157,13 +160,8 @@ func runTokenZapScript( } func launchPlaylistMedia( - platform platforms.Platform, - cfg *config.Instance, - st *state.State, - db *database.Database, - lsq chan<- *tokens.Token, + svc *ServiceContext, pls *playlists.Playlist, - plq chan<- *playlists.Playlist, activePlaylist *playlists.Playlist, player audio.Player, ) { @@ -174,13 +172,13 @@ func launchPlaylistMedia( } plsc := playlists.PlaylistController{ Active: activePlaylist, - Queue: plq, + Queue: svc.PlaylistQueue, } - err := runTokenZapScript(platform, cfg, st, t, db, lsq, plsc, nil) + err := runTokenZapScript(svc, t, plsc, nil, false) if err != nil { log.Error().Err(err).Msgf("error launching token") - path, enabled := cfg.FailSoundPath(helpers.DataDir(platform)) + path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail") } @@ -200,28 +198,23 @@ func launchPlaylistMedia( TokenValue: t.Text, TokenData: t.Data, ClockReliable: helpers.IsClockReliable(now), - BootUUID: st.BootUUID(), + BootUUID: svc.State.BootUUID(), MonotonicStart: monotonicStart, CreatedAt: now, } he.Success = err == nil - err = db.UserDB.AddHistory(&he) + err = svc.DB.UserDB.AddHistory(&he) if err != nil { log.Error().Err(err).Msgf("error adding history") } } func handlePlaylist( - cfg *config.Instance, - pl platforms.Platform, - db *database.Database, - st *state.State, + svc *ServiceContext, pls *playlists.Playlist, - lsq chan<- *tokens.Token, - plq chan<- *playlists.Playlist, player audio.Player, ) { - activePlaylist := st.GetActivePlaylist() + activePlaylist := svc.State.GetActivePlaylist() switch { case pls == nil: @@ -229,14 +222,14 @@ func handlePlaylist( if activePlaylist != nil { log.Info().Msg("clearing playlist") } - st.SetActivePlaylist(nil) + svc.State.SetActivePlaylist(nil) return case activePlaylist == nil: // new playlist loaded - st.SetActivePlaylist(pls) + svc.State.SetActivePlaylist(pls) if pls.Playing { log.Info().Any("pls", pls).Msg("setting new playlist, launching token") - go launchPlaylistMedia(pl, cfg, st, db, lsq, pls, plq, activePlaylist, player) + go launchPlaylistMedia(svc, pls, activePlaylist, player) } else { log.Info().Any("pls", pls).Msg("setting new playlist") } @@ -248,10 +241,10 @@ func handlePlaylist( return } - st.SetActivePlaylist(pls) + svc.State.SetActivePlaylist(pls) if pls.Playing { log.Info().Any("pls", pls).Msg("updating playlist, launching token") - go launchPlaylistMedia(pl, cfg, st, db, lsq, pls, plq, activePlaylist, player) + go launchPlaylistMedia(svc, pls, activePlaylist, player) } else { log.Info().Any("pls", pls).Msg("updating playlist") } @@ -260,20 +253,15 @@ func handlePlaylist( } func processTokenQueue( - platform platforms.Platform, - cfg *config.Instance, - st *state.State, + svc *ServiceContext, itq <-chan tokens.Token, - db *database.Database, - lsq chan<- *tokens.Token, - plq chan *playlists.Playlist, limitsManager *playtime.LimitsManager, player audio.Player, ) { for { select { - case pls := <-plq: - handlePlaylist(cfg, platform, db, st, pls, lsq, plq, player) + case pls := <-svc.PlaylistQueue: + handlePlaylist(svc, pls, player) continue case t := <-itq: // TODO: change this channel to send a token pointer or something @@ -284,10 +272,10 @@ func processTokenQueue( log.Info().Msgf("processing token: %v", t) - path, enabled := cfg.SuccessSoundPath(helpers.DataDir(platform)) + path, enabled := svc.Config.SuccessSoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.SuccessSound, "success") - err := platform.ScanHook(&t) + err := svc.Platform.ScanHook(&t) if err != nil { log.Error().Err(err).Msgf("error writing tmp scan result") } @@ -308,14 +296,14 @@ func processTokenQueue( TokenValue: t.Text, TokenData: t.Data, ClockReliable: helpers.IsClockReliable(now), - BootUUID: st.BootUUID(), + BootUUID: svc.State.BootUUID(), MonotonicStart: monotonicStart, CreatedAt: now, } // Parse script early to check if playtime limits apply // Only block media-launching commands, not utility commands (execute, delay, echo, etc.) - mappedValue, hasMapping := getMapping(cfg, db, platform, t) + mappedValue, hasMapping := getMapping(svc.Config, svc.DB, svc.Platform, t) scriptText := t.Text if hasMapping { scriptText = mappedValue @@ -337,15 +325,15 @@ func processTokenQueue( log.Warn().Err(limitErr).Msg("playtime: launch blocked by daily limit") // Send playtime limit notification - notifications.PlaytimeLimitReached(st.Notifications, models.PlaytimeLimitReachedParams{ + notifications.PlaytimeLimitReached(svc.State.Notifications, models.PlaytimeLimitReachedParams{ Reason: models.PlaytimeLimitReasonDaily, }) - path, enabled := cfg.LimitSoundPath(helpers.DataDir(platform)) + path, enabled := svc.Config.LimitSoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.LimitSound, "limit") he.Success = false - if histErr := db.UserDB.AddHistory(&he); histErr != nil { + if histErr := svc.DB.UserDB.AddHistory(&he); histErr != nil { log.Error().Err(histErr).Msgf("error adding history") } @@ -359,11 +347,11 @@ func processTokenQueue( // launch tokens in a separate thread go func() { plsc := playlists.PlaylistController{ - Active: st.GetActivePlaylist(), - Queue: plq, + Active: svc.State.GetActivePlaylist(), + Queue: svc.PlaylistQueue, } - err = runTokenZapScript(platform, cfg, st, t, db, lsq, plsc, nil) + err = runTokenZapScript(svc, t, plsc, nil, false) if err != nil { if errors.Is(err, zapscript.ErrFileNotFound) { log.Warn().Err(err).Msgf("error launching token") @@ -373,17 +361,17 @@ func processTokenQueue( } if err != nil { - path, enabled := cfg.FailSoundPath(helpers.DataDir(platform)) + path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail") } he.Success = err == nil - err = db.UserDB.AddHistory(&he) + err = svc.DB.UserDB.AddHistory(&he) if err != nil { log.Error().Err(err).Msgf("error adding history") } }() - case <-st.GetContext().Done(): + case <-svc.State.GetContext().Done(): log.Debug().Msg("exiting service worker via context cancellation") return } diff --git a/pkg/service/queues_helpers.go b/pkg/service/queues_helpers.go index 290f943f..dfc2105a 100644 --- a/pkg/service/queues_helpers.go +++ b/pkg/service/queues_helpers.go @@ -31,35 +31,30 @@ import ( // - A hook script is configured (non-empty) // - The command is a media-launching command func shouldRunBeforeMediaStartHook( - exprOpts *zscript.ExprEnvOptions, + inHookContext bool, hookScript string, cmdName string, ) bool { - inHookContext := exprOpts != nil && exprOpts.InHookContext return !inHookContext && hookScript != "" && zscript.IsMediaLaunchingCommand(cmdName) } -// buildLaunchingExprOpts creates ExprEnvOptions for the before_media_start hook. -// Extracts path, system ID, and launcher ID from the command being launched. -func buildLaunchingExprOpts(cmd zapscript.Command) *zscript.ExprEnvOptions { - opts := &zscript.ExprEnvOptions{ - Launching: &zapscript.ExprEnvLaunching{}, - InHookContext: true, - } +// buildLaunchingContext extracts launching context from a command being launched. +func buildLaunchingContext(cmd zapscript.Command) *zapscript.ExprEnvLaunching { + launching := &zapscript.ExprEnvLaunching{} if len(cmd.Args) > 0 { - opts.Launching.Path = cmd.Args[0] + launching.Path = cmd.Args[0] } if sysID := cmd.AdvArgs.Get(zapscript.KeySystem); sysID != "" { - opts.Launching.SystemID = sysID + launching.SystemID = sysID } if launcherID := cmd.AdvArgs.Get(zapscript.KeyLauncher); launcherID != "" { - opts.Launching.LauncherID = launcherID + launching.LauncherID = launcherID } - return opts + return launching } // scriptHasMediaLaunchingCommand checks if any command in the script launches media. diff --git a/pkg/service/queues_helpers_test.go b/pkg/service/queues_helpers_test.go index b34ba487..ef42b43d 100644 --- a/pkg/service/queues_helpers_test.go +++ b/pkg/service/queues_helpers_test.go @@ -24,7 +24,6 @@ import ( gozapscript "github.com/ZaparooProject/go-zapscript" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/stretchr/testify/assert" ) @@ -32,108 +31,108 @@ func TestShouldRunBeforeMediaStartHook(t *testing.T) { t.Parallel() tests := []struct { - name string - exprOpts *zapscript.ExprEnvOptions - hookScript string - cmdName string - expected bool + name string + hookScript string + cmdName string + inHookContext bool + expected bool }{ { - name: "runs when all conditions met", - exprOpts: nil, - hookScript: "**echo:before launch", - cmdName: gozapscript.ZapScriptCmdLaunch, - expected: true, + name: "runs when all conditions met", + inHookContext: false, + hookScript: "**echo:before launch", + cmdName: gozapscript.ZapScriptCmdLaunch, + expected: true, }, { - name: "runs with non-hook exprOpts", - exprOpts: &zapscript.ExprEnvOptions{InHookContext: false}, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdLaunch, - expected: true, + name: "runs when not in hook context", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdLaunch, + expected: true, }, { - name: "blocked when in hook context", - exprOpts: &zapscript.ExprEnvOptions{InHookContext: true}, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdLaunch, - expected: false, + name: "blocked when in hook context", + inHookContext: true, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdLaunch, + expected: false, }, { - name: "blocked when hook script empty", - exprOpts: nil, - hookScript: "", - cmdName: gozapscript.ZapScriptCmdLaunch, - expected: false, + name: "blocked when hook script empty", + inHookContext: false, + hookScript: "", + cmdName: gozapscript.ZapScriptCmdLaunch, + expected: false, }, { - name: "blocked when command is not media-launching", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdExecute, - expected: false, + name: "blocked when command is not media-launching", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdExecute, + expected: false, }, { - name: "blocked when command is echo", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdEcho, - expected: false, + name: "blocked when command is echo", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdEcho, + expected: false, }, { - name: "blocked when command is delay", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdDelay, - expected: false, + name: "blocked when command is delay", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdDelay, + expected: false, }, { - name: "runs for launch.system command", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdLaunchSystem, - expected: true, + name: "runs for launch.system command", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdLaunchSystem, + expected: true, }, { - name: "runs for launch.random command", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdLaunchRandom, - expected: true, + name: "runs for launch.random command", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdLaunchRandom, + expected: true, }, { - name: "runs for launch.search command", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdLaunchSearch, - expected: true, + name: "runs for launch.search command", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdLaunchSearch, + expected: true, }, { - name: "blocked for playlist.play command (queues state change)", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdPlaylistPlay, - expected: false, + name: "blocked for playlist.play command (queues state change)", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdPlaylistPlay, + expected: false, }, { - name: "blocked for playlist.stop command", - exprOpts: nil, - hookScript: "**echo:test", - cmdName: gozapscript.ZapScriptCmdPlaylistStop, - expected: false, + name: "blocked for playlist.stop command", + inHookContext: false, + hookScript: "**echo:test", + cmdName: gozapscript.ZapScriptCmdPlaylistStop, + expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := shouldRunBeforeMediaStartHook(tt.exprOpts, tt.hookScript, tt.cmdName) + result := shouldRunBeforeMediaStartHook(tt.inHookContext, tt.hookScript, tt.cmdName) assert.Equal(t, tt.expected, result) }) } } -func TestBuildLaunchingExprOpts(t *testing.T) { +func TestBuildLaunchingContext(t *testing.T) { t.Parallel() t.Run("empty command", func(t *testing.T) { @@ -144,14 +143,12 @@ func TestBuildLaunchingExprOpts(t *testing.T) { Args: []string{}, } - opts := buildLaunchingExprOpts(cmd) + launching := buildLaunchingContext(cmd) - assert.NotNil(t, opts) - assert.NotNil(t, opts.Launching) - assert.True(t, opts.InHookContext, "InHookContext should always be true") - assert.Empty(t, opts.Launching.Path) - assert.Empty(t, opts.Launching.SystemID) - assert.Empty(t, opts.Launching.LauncherID) + assert.NotNil(t, launching) + assert.Empty(t, launching.Path) + assert.Empty(t, launching.SystemID) + assert.Empty(t, launching.LauncherID) }) t.Run("with path only", func(t *testing.T) { @@ -162,12 +159,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) { Args: []string{"/games/snes/mario.sfc"}, } - opts := buildLaunchingExprOpts(cmd) + launching := buildLaunchingContext(cmd) - assert.Equal(t, "/games/snes/mario.sfc", opts.Launching.Path) - assert.Empty(t, opts.Launching.SystemID) - assert.Empty(t, opts.Launching.LauncherID) - assert.True(t, opts.InHookContext) + assert.Equal(t, "/games/snes/mario.sfc", launching.Path) + assert.Empty(t, launching.SystemID) + assert.Empty(t, launching.LauncherID) }) t.Run("with system ID", func(t *testing.T) { @@ -179,11 +175,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) { AdvArgs: gozapscript.NewAdvArgs(map[string]string{"system": "genesis"}), } - opts := buildLaunchingExprOpts(cmd) + launching := buildLaunchingContext(cmd) - assert.Equal(t, "/games/sonic.bin", opts.Launching.Path) - assert.Equal(t, "genesis", opts.Launching.SystemID) - assert.Empty(t, opts.Launching.LauncherID) + assert.Equal(t, "/games/sonic.bin", launching.Path) + assert.Equal(t, "genesis", launching.SystemID) + assert.Empty(t, launching.LauncherID) }) t.Run("with launcher ID", func(t *testing.T) { @@ -195,11 +191,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) { AdvArgs: gozapscript.NewAdvArgs(map[string]string{"launcher": "retroarch"}), } - opts := buildLaunchingExprOpts(cmd) + launching := buildLaunchingContext(cmd) - assert.Equal(t, "/games/game.rom", opts.Launching.Path) - assert.Empty(t, opts.Launching.SystemID) - assert.Equal(t, "retroarch", opts.Launching.LauncherID) + assert.Equal(t, "/games/game.rom", launching.Path) + assert.Empty(t, launching.SystemID) + assert.Equal(t, "retroarch", launching.LauncherID) }) t.Run("with all fields", func(t *testing.T) { @@ -211,13 +207,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) { AdvArgs: gozapscript.NewAdvArgs(map[string]string{"system": "snes", "launcher": "mister"}), } - opts := buildLaunchingExprOpts(cmd) + launching := buildLaunchingContext(cmd) - assert.Equal(t, "/roms/snes/zelda.sfc", opts.Launching.Path) - assert.Equal(t, "snes", opts.Launching.SystemID) - assert.Equal(t, "mister", opts.Launching.LauncherID) - assert.True(t, opts.InHookContext) - assert.Nil(t, opts.Scanned, "Scanned should not be set") + assert.Equal(t, "/roms/snes/zelda.sfc", launching.Path) + assert.Equal(t, "snes", launching.SystemID) + assert.Equal(t, "mister", launching.LauncherID) }) t.Run("multiple args uses first as path", func(t *testing.T) { @@ -228,9 +222,9 @@ func TestBuildLaunchingExprOpts(t *testing.T) { Args: []string{"/path/to/game.rom", "extra", "args"}, } - opts := buildLaunchingExprOpts(cmd) + launching := buildLaunchingContext(cmd) - assert.Equal(t, "/path/to/game.rom", opts.Launching.Path) + assert.Equal(t, "/path/to/game.rom", launching.Path) }) } diff --git a/pkg/service/queues_playlist_test.go b/pkg/service/queues_playlist_test.go index 619896b1..e1c8ea67 100644 --- a/pkg/service/queues_playlist_test.go +++ b/pkg/service/queues_playlist_test.go @@ -35,15 +35,7 @@ import ( "github.com/stretchr/testify/require" ) -type playlistTestEnv struct { - platform *mocks.MockPlatform - cfg *config.Instance - st *state.State - db *database.Database - lsq chan *tokens.Token -} - -func setupPlaylistTestEnv(t *testing.T) *playlistTestEnv { +func setupPlaylistTestEnv(t *testing.T) *ServiceContext { t.Helper() mockPlatform := mocks.NewMockPlatform() @@ -69,19 +61,20 @@ func setupPlaylistTestEnv(t *testing.T) *playlistTestEnv { mockUserDB.On("GetEnabledMappings").Return([]database.Mapping{}, nil).Maybe() mockUserDB.On("GetSupportedZapLinkHosts").Return([]string{}, nil).Maybe() - return &playlistTestEnv{ - platform: mockPlatform, - cfg: cfg, - st: st, - db: &database.Database{UserDB: mockUserDB}, - lsq: make(chan *tokens.Token, 10), + return &ServiceContext{ + Platform: mockPlatform, + Config: cfg, + State: st, + DB: &database.Database{UserDB: mockUserDB}, + LaunchSoftwareQueue: make(chan *tokens.Token, 10), + PlaylistQueue: make(chan *playlists.Playlist, 10), } } func TestRunTokenZapScript_ClearsPlaylistOnMediaChange(t *testing.T) { t.Parallel() - env := setupPlaylistTestEnv(t) + svc := setupPlaylistTestEnv(t) plq := make(chan *playlists.Playlist, 10) plsc := playlists.PlaylistController{Queue: plq} @@ -91,7 +84,7 @@ func TestRunTokenZapScript_ClearsPlaylistOnMediaChange(t *testing.T) { ScanTime: time.Now(), } - err := runTokenZapScript(env.platform, env.cfg, env.st, token, env.db, env.lsq, plsc, nil) + err := runTokenZapScript(svc, token, plsc, nil, false) require.NoError(t, err) select { @@ -105,7 +98,7 @@ func TestRunTokenZapScript_ClearsPlaylistOnMediaChange(t *testing.T) { func TestRunTokenZapScript_SkipsPlaylistClearForPlaylistSource(t *testing.T) { t.Parallel() - env := setupPlaylistTestEnv(t) + svc := setupPlaylistTestEnv(t) plq := make(chan *playlists.Playlist, 10) plsc := playlists.PlaylistController{Queue: plq} @@ -116,7 +109,7 @@ func TestRunTokenZapScript_SkipsPlaylistClearForPlaylistSource(t *testing.T) { Source: tokens.SourcePlaylist, } - err := runTokenZapScript(env.platform, env.cfg, env.st, token, env.db, env.lsq, plsc, nil) + err := runTokenZapScript(svc, token, plsc, nil, false) require.NoError(t, err) select { @@ -130,8 +123,8 @@ func TestRunTokenZapScript_SkipsPlaylistClearForPlaylistSource(t *testing.T) { func TestRunTokenZapScript_NoPlaylistClearForNonMediaCommand(t *testing.T) { t.Parallel() - env := setupPlaylistTestEnv(t) - env.platform.On("KeyboardPress", "{f2}").Return(nil) + svc := setupPlaylistTestEnv(t) + svc.Platform.(*mocks.MockPlatform).On("KeyboardPress", "{f2}").Return(nil) plq := make(chan *playlists.Playlist, 10) plsc := playlists.PlaylistController{Queue: plq} @@ -141,7 +134,7 @@ func TestRunTokenZapScript_NoPlaylistClearForNonMediaCommand(t *testing.T) { ScanTime: time.Now(), } - err := runTokenZapScript(env.platform, env.cfg, env.st, token, env.db, env.lsq, plsc, nil) + err := runTokenZapScript(svc, token, plsc, nil, false) require.NoError(t, err) select { diff --git a/pkg/service/reader_manager_test.go b/pkg/service/reader_manager_test.go index 88704414..a1e0ef43 100644 --- a/pkg/service/reader_manager_test.go +++ b/pkg/service/reader_manager_test.go @@ -71,7 +71,16 @@ func setupReaderManager(t *testing.T) *readerManagerEnv { lsq := make(chan *tokens.Token, 10) plq := make(chan *playlists.Playlist, 10) - go readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer, nil) + svc := &ServiceContext{ + Platform: mockPlatform, + Config: cfg, + State: st, + DB: db, + LaunchSoftwareQueue: lsq, + PlaylistQueue: plq, + } + + go readerManager(svc, itq, scanQueue, mockPlayer, nil) t.Cleanup(func() { st.StopService() diff --git a/pkg/service/readers.go b/pkg/service/readers.go index 83103a5d..8511f1aa 100644 --- a/pkg/service/readers.go +++ b/pkg/service/readers.go @@ -28,15 +28,12 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/assets" "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/jonboulle/clockwork" "github.com/rs/zerolog/log" ) @@ -150,16 +147,11 @@ func connectReaders( } func runBeforeExitHook( - pl platforms.Platform, - cfg *config.Instance, - st *state.State, - db *database.Database, - lsq chan *tokens.Token, - plq chan *playlists.Playlist, + svc *ServiceContext, activeMedia models.ActiveMedia, //nolint:gocritic // single-use parameter in service function ) { var systemIDs []string - launchers := pl.Launchers(cfg) + launchers := svc.Platform.Launchers(svc.Config) for i := range launchers { l := &launchers[i] if l.ID == activeMedia.SystemID { @@ -174,12 +166,12 @@ func runBeforeExitHook( if len(systemIDs) > 0 { for _, systemID := range systemIDs { - defaults, ok := cfg.LookupSystemDefaults(systemID) + defaults, ok := svc.Config.LookupSystemDefaults(systemID) if !ok || defaults.BeforeExit == "" { continue } - if err := runHook(pl, cfg, st, db, lsq, plq, "before_exit", defaults.BeforeExit, nil); err != nil { + if err := runHook(svc, "before_exit", defaults.BeforeExit, nil, nil); err != nil { log.Error().Err(err).Msg("error running before_exit script") } @@ -189,12 +181,7 @@ func runBeforeExitHook( } func timedExit( - pl platforms.Platform, - cfg *config.Instance, - st *state.State, - db *database.Database, - lsq chan *tokens.Token, - plq chan *playlists.Playlist, + svc *ServiceContext, clock clockwork.Clock, exitTimer clockwork.Timer, ) clockwork.Timer { @@ -205,20 +192,20 @@ func timedExit( } } - if !cfg.HoldModeEnabled() { + if !svc.Config.HoldModeEnabled() { log.Debug().Msg("hold mode not enabled, skipping exit timer") return exitTimer } // Only hardware readers support hold mode exit - lastToken := st.GetLastScanned() + lastToken := svc.State.GetLastScanned() if lastToken.Source != tokens.SourceReader { log.Debug().Str("source", lastToken.Source).Msg("skipping exit timer for non-reader source") return exitTimer } // Check if the reader supports removal detection - r, ok := st.GetReader(lastToken.ReaderID) + r, ok := svc.State.GetReader(lastToken.ReaderID) if !ok { log.Debug().Str("readerID", lastToken.ReaderID).Msg("reader not found in state, skipping exit timer") return exitTimer @@ -228,47 +215,47 @@ func timedExit( return exitTimer } - timerLen := time.Duration(float64(cfg.ReadersScan().ExitDelay) * float64(time.Second)) + timerLen := time.Duration(float64(svc.Config.ReadersScan().ExitDelay) * float64(time.Second)) log.Debug().Msgf("exit timer set to: %s seconds", timerLen) exitTimer = clock.NewTimer(timerLen) go func() { select { case <-exitTimer.Chan(): - case <-st.GetContext().Done(): + case <-svc.State.GetContext().Done(): return } - if !cfg.HoldModeEnabled() { + if !svc.Config.HoldModeEnabled() { log.Debug().Msg("exit timer expired, but hold mode disabled") return } - activeMedia := st.ActiveMedia() + activeMedia := svc.State.ActiveMedia() if activeMedia == nil { log.Debug().Msg("no active media, cancelling exit") return } - if st.GetSoftwareToken() == nil { + if svc.State.GetSoftwareToken() == nil { log.Debug().Msg("no active software token, cancelling exit") return } - if cfg.IsHoldModeIgnoredSystem(activeMedia.SystemID) { + if svc.Config.IsHoldModeIgnoredSystem(activeMedia.SystemID) { log.Debug().Msg("active system ignored in config, cancelling exit") return } - runBeforeExitHook(pl, cfg, st, db, lsq, plq, *activeMedia) + runBeforeExitHook(svc, *activeMedia) log.Info().Msg("exiting media") - err := pl.StopActiveLauncher(platforms.StopForMenu) + err := svc.Platform.StopActiveLauncher(platforms.StopForMenu) if err != nil { log.Warn().Msgf("error killing launcher: %s", err) } - lsq <- nil + svc.LaunchSoftwareQueue <- nil }() return exitTimer @@ -285,13 +272,8 @@ func timedExit( // This manager also handles the logic of what to do when a token is removed // from the reader. func readerManager( - pl platforms.Platform, - cfg *config.Instance, - st *state.State, - db *database.Database, + svc *ServiceContext, itq chan<- tokens.Token, - lsq chan *tokens.Token, - plq chan *playlists.Playlist, scanQueue chan readers.Scan, player audio.Player, clock clockwork.Clock, @@ -306,44 +288,44 @@ func readerManager( var exitTimer clockwork.Timer var autoDetector *AutoDetector - if cfg.AutoDetect() { - autoDetector = NewAutoDetector(cfg) + if svc.Config.AutoDetect() { + autoDetector = NewAutoDetector(svc.Config) } readerTicker := time.NewTicker(1 * time.Second) playFail := func() { if time.Since(lastError) > 1*time.Second { - path, enabled := cfg.FailSoundPath(helpers.DataDir(pl)) + path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail") } } // manage reader connections go func() { - log.Info().Msgf("reader manager started, auto-detect=%v", cfg.AutoDetect()) + log.Info().Msgf("reader manager started, auto-detect=%v", svc.Config.AutoDetect()) sleepMonitor := helpers.NewSleepWakeMonitor(5 * time.Second) readerConnectAttempts := 0 lastReaderCount := 0 for { select { - case <-st.GetContext().Done(): + case <-svc.State.GetContext().Done(): log.Info().Msg("reader manager shutting down via context cancellation") return case <-readerTicker.C: // Check for wake from sleep and reconnect all readers if detected if sleepMonitor.Check() { log.Info().Msg("detected wake from sleep, reconnecting all readers") - for _, r := range st.ListReaders() { + for _, r := range svc.State.ListReaders() { if r != nil { - st.RemoveReader(r.ReaderID()) + svc.State.RemoveReader(r.ReaderID()) } } lastReaderCount = 0 } readerConnectAttempts++ - rs := st.ListReaders() + rs := svc.State.ListReaders() if len(rs) != lastReaderCount { if len(rs) == 0 { @@ -356,7 +338,7 @@ func readerManager( // Only log if no readers for 2 minutes log.Debug(). Int("attempts", readerConnectAttempts). - Bool("autoDetect", cfg.AutoDetect()). + Bool("autoDetect", svc.Config.AutoDetect()). Msg("no readers connected") } @@ -368,7 +350,7 @@ func readerManager( Str("path", r.Path()). Str("info", r.Info()). Msg("pruning disconnected reader") - st.RemoveReader(readerID) + svc.State.RemoveReader(readerID) if autoDetector != nil { autoDetector.ClearPath(r.Path()) autoDetector.ClearFailedPath(r.Path()) @@ -376,7 +358,7 @@ func readerManager( } } - if connectErr := connectReaders(pl, cfg, st, scanQueue, autoDetector); connectErr != nil { + if connectErr := connectReaders(svc.Platform, svc.Config, svc.State, scanQueue, autoDetector); connectErr != nil { log.Warn().Msgf("error connecting rs: %s", connectErr) } // Reset monitor after potentially blocking operations to avoid @@ -394,7 +376,7 @@ preprocessing: var scanSource string select { - case <-st.GetContext().Done(): + case <-svc.State.GetContext().Done(): log.Debug().Msg("closing reader manager via context cancellation") break preprocessing case t := <-scanQueue: @@ -409,15 +391,15 @@ preprocessing: scan = t.Token readerError = t.ReaderError scanSource = t.Source - case stoken := <-lsq: + case stoken := <-svc.LaunchSoftwareQueue: // a token has been launched that starts software, used for managing exits log.Debug().Msgf("new software token: %v", stoken) - if exitTimer != nil && !helpers.TokensEqual(stoken, st.GetSoftwareToken()) { + if exitTimer != nil && !helpers.TokensEqual(stoken, svc.State.GetSoftwareToken()) { if stopped := exitTimer.Stop(); stopped { log.Info().Msg("different software token inserted, cancelling exit") } } - st.SetSoftwareToken(stoken) + svc.State.SetSoftwareToken(stoken) continue preprocessing } @@ -433,43 +415,41 @@ preprocessing: log.Info().Msgf("new token scanned: %v", scan) // Run on_scan hook before SetActiveCard so last_scanned refers to previous token - if onScanScript := cfg.ReadersScan().OnScan; onScanScript != "" { - scannedOpts := &zapscript.ExprEnvOptions{ - Scanned: &gozapscript.ExprEnvScanned{ - ID: scan.UID, - Value: scan.Text, - Data: scan.Data, - }, + if onScanScript := svc.Config.ReadersScan().OnScan; onScanScript != "" { + scanned := &gozapscript.ExprEnvScanned{ + ID: scan.UID, + Value: scan.Text, + Data: scan.Data, } - if err := runHook(pl, cfg, st, db, lsq, plq, "on_scan", onScanScript, scannedOpts); err != nil { + if err := runHook(svc, "on_scan", onScanScript, scanned, nil); err != nil { log.Warn().Err(err).Msg("on_scan hook blocked token processing") continue preprocessing } } - st.SetActiveCard(*scan) + svc.State.SetActiveCard(*scan) if exitTimer != nil { stopped := exitTimer.Stop() - stoken := st.GetSoftwareToken() + stoken := svc.State.GetSoftwareToken() if stopped && helpers.TokensEqual(scan, stoken) { log.Info().Msg("same token reinserted, cancelling exit") continue preprocessing } else if stopped { log.Info().Msg("new token inserted, restarting exit timer") - exitTimer = timedExit(pl, cfg, st, db, lsq, plq, clock, exitTimer) + exitTimer = timedExit(svc, clock, exitTimer) } } // avoid launching a token that was just written by a reader // NOTE: This check requires both UID and Text to match (see helpers.TokensEqual). - wt := st.GetWroteToken() + wt := svc.State.GetWroteToken() if wt != nil && helpers.TokensEqual(scan, wt) { log.Info().Msg("skipping launching just written token") - st.SetWroteToken(nil) + svc.State.SetWroteToken(nil) continue preprocessing } - st.SetWroteToken(nil) + svc.State.SetWroteToken(nil) log.Info().Msgf("sending token to queue: %v", scan) @@ -485,28 +465,28 @@ preprocessing: log.Debug().Msg("cancelled exit timer due to reader error") } } - st.SetActiveCard(tokens.Token{}) + svc.State.SetActiveCard(tokens.Token{}) case scanNormalRemoval: log.Info().Msg("token was removed") // Clear ActiveCard before hook to prevent blocked removals from affecting new scans - st.SetActiveCard(tokens.Token{}) + svc.State.SetActiveCard(tokens.Token{}) // Run on_remove hook; errors skip exit timer but card state is already cleared - if onRemoveScript := cfg.ReadersScan().OnRemove; cfg.HoldModeEnabled() && onRemoveScript != "" { - if err := runHook(pl, cfg, st, db, lsq, plq, "on_remove", onRemoveScript, nil); err != nil { + if onRemoveScript := svc.Config.ReadersScan().OnRemove; svc.Config.HoldModeEnabled() && onRemoveScript != "" { + if err := runHook(svc, "on_remove", onRemoveScript, nil, nil); err != nil { log.Warn().Err(err).Msg("on_remove hook blocked exit, media will keep running") continue preprocessing } } - exitTimer = timedExit(pl, cfg, st, db, lsq, plq, clock, exitTimer) + exitTimer = timedExit(svc, clock, exitTimer) } } // daemon shutdown - rs := st.ListReaders() + rs := svc.State.ListReaders() for _, r := range rs { if r != nil { err := r.Close() diff --git a/pkg/service/scan_behavior_test.go b/pkg/service/scan_behavior_test.go index 903d74a9..628b5e9a 100644 --- a/pkg/service/scan_behavior_test.go +++ b/pkg/service/scan_behavior_test.go @@ -161,15 +161,24 @@ func setupScanBehavior( limitsManager := playtime.NewLimitsManager(db, mockPlatform, cfg, nil, mockPlayer) + svc := &ServiceContext{ + Platform: mockPlatform, + Config: cfg, + State: st, + DB: db, + LaunchSoftwareQueue: lsq, + PlaylistQueue: plq, + } + var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer, fakeClock) + readerManager(svc, itq, scanQueue, mockPlayer, fakeClock) }() go func() { defer wg.Done() - processTokenQueue(mockPlatform, cfg, st, itq, db, lsq, plq, limitsManager, mockPlayer) + processTokenQueue(svc, itq, limitsManager, mockPlayer) }() t.Cleanup(func() { diff --git a/pkg/service/service.go b/pkg/service/service.go index f8f37934..c76170d6 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -264,10 +264,19 @@ func Start( limitsManager.SetEnabled(true) } + svc := &ServiceContext{ + Platform: pl, + Config: cfg, + State: st, + DB: db, + LaunchSoftwareQueue: lsq, + PlaylistQueue: plq, + } + // Set up the OnMediaStart hook st.SetOnMediaStartHook(func(_ *models.ActiveMedia) { if script := cfg.LaunchersOnMediaStart(); script != "" { - if hookErr := runHook(pl, cfg, st, db, lsq, plq, "on_media_start", script, nil); hookErr != nil { + if hookErr := runHook(svc, "on_media_start", script, nil, nil); hookErr != nil { log.Error().Err(hookErr).Msg("error running on_media_start script") } } @@ -330,10 +339,10 @@ func Start( } log.Info().Msg("starting reader manager") - go readerManager(pl, cfg, st, db, itq, lsq, plq, make(chan readers.Scan), player, nil) + go readerManager(svc, itq, make(chan readers.Scan), player, nil) log.Info().Msg("starting input token queue manager") - go processTokenQueue(pl, cfg, st, itq, db, lsq, plq, limitsManager, player) + go processTokenQueue(svc, itq, limitsManager, player) log.Info().Msg("running platform post start") err = pl.StartPost(cfg, st.LauncherManager(), st.ActiveMedia, st.SetActiveMedia, db) diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go index 3fe5c313..31681818 100644 --- a/pkg/zapscript/commands.go +++ b/pkg/zapscript/commands.go @@ -28,6 +28,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "github.com/ZaparooProject/go-zapscript" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -68,53 +69,65 @@ func ParseAdvArgs[T any](pl platforms.Platform, env *platforms.CmdEnv, dest *T) return nil } -var cmdMap = map[string]func( - platforms.Platform, - platforms.CmdEnv, -) (platforms.CmdResult, error){ - zapscript.ZapScriptCmdLaunch: cmdLaunch, - zapscript.ZapScriptCmdLaunchSystem: cmdSystem, - zapscript.ZapScriptCmdLaunchRandom: cmdRandom, - zapscript.ZapScriptCmdLaunchSearch: cmdSearch, - zapscript.ZapScriptCmdLaunchTitle: cmdTitle, - - zapscript.ZapScriptCmdPlaylistPlay: cmdPlaylistPlay, - zapscript.ZapScriptCmdPlaylistStop: cmdPlaylistStop, - zapscript.ZapScriptCmdPlaylistNext: cmdPlaylistNext, - zapscript.ZapScriptCmdPlaylistPrevious: cmdPlaylistPrevious, - zapscript.ZapScriptCmdPlaylistGoto: cmdPlaylistGoto, - zapscript.ZapScriptCmdPlaylistPause: cmdPlaylistPause, - zapscript.ZapScriptCmdPlaylistLoad: cmdPlaylistLoad, - zapscript.ZapScriptCmdPlaylistOpen: cmdPlaylistOpen, - - zapscript.ZapScriptCmdExecute: cmdExecute, - zapscript.ZapScriptCmdDelay: cmdDelay, - zapscript.ZapScriptCmdStop: cmdStop, - zapscript.ZapScriptCmdEcho: cmdEcho, - - zapscript.ZapScriptCmdMisterINI: forwardCmd, - zapscript.ZapScriptCmdMisterCore: forwardCmd, - zapscript.ZapScriptCmdMisterScript: forwardCmd, - zapscript.ZapScriptCmdMisterMGL: forwardCmd, - - zapscript.ZapScriptCmdHTTPGet: cmdHTTPGet, - zapscript.ZapScriptCmdHTTPPost: cmdHTTPPost, - - zapscript.ZapScriptCmdInputKeyboard: cmdKeyboard, - zapscript.ZapScriptCmdInputGamepad: cmdGamepad, - zapscript.ZapScriptCmdInputCoinP1: cmdCoinP1, - zapscript.ZapScriptCmdInputCoinP2: cmdCoinP2, - - zapscript.ZapScriptCmdInputKey: cmdKey, // DEPRECATED - zapscript.ZapScriptCmdKey: cmdKey, // DEPRECATED - zapscript.ZapScriptCmdCoinP1: cmdCoinP1, // DEPRECATED - zapscript.ZapScriptCmdCoinP2: cmdCoinP2, // DEPRECATED - zapscript.ZapScriptCmdRandom: cmdRandom, // DEPRECATED - zapscript.ZapScriptCmdShell: cmdExecute, // DEPRECATED - zapscript.ZapScriptCmdCommand: cmdExecute, // DEPRECATED - zapscript.ZapScriptCmdINI: forwardCmd, // DEPRECATED - zapscript.ZapScriptCmdSystem: cmdSystem, // DEPRECATED - zapscript.ZapScriptCmdGet: cmdHTTPGet, // DEPRECATED +type cmdFunc = func(platforms.Platform, platforms.CmdEnv) (platforms.CmdResult, error) + +var ( + cmdMapOnce sync.Once + cmdMapVal map[string]cmdFunc +) + +func lookupCmd(name string) (cmdFunc, bool) { + cmdMapOnce.Do(func() { + cmdMapVal = map[string]cmdFunc{ + zapscript.ZapScriptCmdLaunch: cmdLaunch, + zapscript.ZapScriptCmdLaunchSystem: cmdSystem, + zapscript.ZapScriptCmdLaunchRandom: cmdRandom, + zapscript.ZapScriptCmdLaunchSearch: cmdSearch, + zapscript.ZapScriptCmdLaunchTitle: cmdTitle, + + zapscript.ZapScriptCmdPlaylistPlay: cmdPlaylistPlay, + zapscript.ZapScriptCmdPlaylistStop: cmdPlaylistStop, + zapscript.ZapScriptCmdPlaylistNext: cmdPlaylistNext, + zapscript.ZapScriptCmdPlaylistPrevious: cmdPlaylistPrevious, + zapscript.ZapScriptCmdPlaylistGoto: cmdPlaylistGoto, + zapscript.ZapScriptCmdPlaylistPause: cmdPlaylistPause, + zapscript.ZapScriptCmdPlaylistLoad: cmdPlaylistLoad, + zapscript.ZapScriptCmdPlaylistOpen: cmdPlaylistOpen, + + zapscript.ZapScriptCmdExecute: cmdExecute, + zapscript.ZapScriptCmdDelay: cmdDelay, + zapscript.ZapScriptCmdStop: cmdStop, + zapscript.ZapScriptCmdEcho: cmdEcho, + + zapscript.ZapScriptCmdControl: cmdControl, + + zapscript.ZapScriptCmdMisterINI: forwardCmd, + zapscript.ZapScriptCmdMisterCore: forwardCmd, + zapscript.ZapScriptCmdMisterScript: forwardCmd, + zapscript.ZapScriptCmdMisterMGL: forwardCmd, + + zapscript.ZapScriptCmdHTTPGet: cmdHTTPGet, + zapscript.ZapScriptCmdHTTPPost: cmdHTTPPost, + + zapscript.ZapScriptCmdInputKeyboard: cmdKeyboard, + zapscript.ZapScriptCmdInputGamepad: cmdGamepad, + zapscript.ZapScriptCmdInputCoinP1: cmdCoinP1, + zapscript.ZapScriptCmdInputCoinP2: cmdCoinP2, + + zapscript.ZapScriptCmdInputKey: cmdKey, // DEPRECATED + zapscript.ZapScriptCmdKey: cmdKey, // DEPRECATED + zapscript.ZapScriptCmdCoinP1: cmdCoinP1, // DEPRECATED + zapscript.ZapScriptCmdCoinP2: cmdCoinP2, // DEPRECATED + zapscript.ZapScriptCmdRandom: cmdRandom, // DEPRECATED + zapscript.ZapScriptCmdShell: cmdExecute, // DEPRECATED + zapscript.ZapScriptCmdCommand: cmdExecute, // DEPRECATED + zapscript.ZapScriptCmdINI: forwardCmd, // DEPRECATED + zapscript.ZapScriptCmdSystem: cmdSystem, // DEPRECATED + zapscript.ZapScriptCmdGet: cmdHTTPGet, // DEPRECATED + } + }) + f, ok := cmdMapVal[name] + return f, ok } // IsMediaLaunchingCommand returns true if the command launches media and should be subject to playtime limits. @@ -144,7 +157,7 @@ func IsMediaLaunchingCommand(cmdName string) bool { // IsValidCommand returns true if the command name is a valid ZapScript command. func IsValidCommand(cmdName string) bool { - _, ok := cmdMap[cmdName] + _, ok := lookupCmd(cmdName) return ok } @@ -190,18 +203,12 @@ func findFile(pl platforms.Platform, cfg *config.Instance, path string) (string, return path, fmt.Errorf("%w: %s", ErrFileNotFound, path) } -// ExprEnvOptions provides optional context for expression environment. -type ExprEnvOptions struct { - Scanned *zapscript.ExprEnvScanned - Launching *zapscript.ExprEnvLaunching - InHookContext bool // prevents recursive hook execution -} - -func getExprEnv( +func GetExprEnv( pl platforms.Platform, cfg *config.Instance, st *state.State, - opts *ExprEnvOptions, + scanned *zapscript.ExprEnvScanned, + launching *zapscript.ExprEnvLaunching, ) zapscript.ArgExprEnv { hostname, err := os.Hostname() if err != nil { @@ -237,19 +244,19 @@ func getExprEnv( env.ActiveMedia.Name = activeMedia.Name } - if opts != nil { - if opts.Scanned != nil { - env.Scanned = *opts.Scanned - } - if opts.Launching != nil { - env.Launching = *opts.Launching - } + if scanned != nil { + env.Scanned = *scanned + } + if launching != nil { + env.Launching = *launching } return env } // RunCommand parses and runs a single ZapScript command. +// The lm parameter is only needed for media-launching commands (launch guard); +// pass nil for contexts where media launches are not allowed (e.g. control scripts). func RunCommand( pl platforms.Platform, cfg *config.Instance, @@ -259,8 +266,8 @@ func RunCommand( totalCmds int, currentIndex int, db *database.Database, - st *state.State, - exprOpts *ExprEnvOptions, + lm *state.LauncherManager, + exprEnv *zapscript.ArgExprEnv, ) (platforms.CmdResult, error) { unsafe := token.Unsafe newCmds := make([]zapscript.Command, 0) @@ -288,11 +295,9 @@ func RunCommand( unsafe = true } - exprEnv := getExprEnv(pl, cfg, st, exprOpts) - for i, arg := range cmd.Args { reader := zapscript.NewParser(arg) - output, evalErr := reader.EvalExpressions(exprEnv) + output, evalErr := reader.EvalExpressions(*exprEnv) if evalErr != nil { return platforms.CmdResult{}, fmt.Errorf("error evaluating arg expression: %w", evalErr) } @@ -302,7 +307,7 @@ func RunCommand( var advArgEvalErr error cmd.AdvArgs.Range(func(k zapscript.Key, arg string) bool { reader := zapscript.NewParser(arg) - output, evalErr := reader.EvalExpressions(exprEnv) + output, evalErr := reader.EvalExpressions(*exprEnv) if evalErr != nil { advArgEvalErr = fmt.Errorf("error evaluating advanced arg expression: %w", evalErr) return false @@ -331,25 +336,32 @@ func RunCommand( CurrentIndex: currentIndex, Unsafe: unsafe, Database: db, - ExprEnv: &exprEnv, + ExprEnv: exprEnv, + } + + if lm != nil { + env.LauncherCtx = lm.GetContext() } - cmdFunc, ok := cmdMap[cmd.Name] + cmdFn, ok := lookupCmd(cmd.Name) if !ok { return platforms.CmdResult{}, fmt.Errorf("unknown command: %s", cmd) } // Acquire launch guard for media-launching commands to prevent concurrent launches if IsMediaLaunchingCommand(cmd.Name) { - if guardErr := st.LauncherManager().TryStartLaunch(); guardErr != nil { + if lm == nil { + return platforms.CmdResult{}, errors.New("launcher manager required for media-launching commands") + } + if guardErr := lm.TryStartLaunch(); guardErr != nil { return platforms.CmdResult{}, fmt.Errorf("launch guard: %w", guardErr) } - defer st.LauncherManager().EndLaunch() - env.LauncherCtx = st.LauncherManager().GetContext() + defer lm.EndLaunch() + env.LauncherCtx = lm.GetContext() } log.Info().Msgf("running command: %s", cmd) - res, err := cmdFunc(pl, env) + res, err := cmdFn(pl, env) if err != nil { if errors.Is(err, ErrFileNotFound) { log.Warn().Err(err).Msgf("error running command: %s", cmd) diff --git a/pkg/zapscript/commands_test.go b/pkg/zapscript/commands_test.go index 6222d513..b9c874c6 100644 --- a/pkg/zapscript/commands_test.go +++ b/pkg/zapscript/commands_test.go @@ -150,6 +150,11 @@ func TestIsMediaLaunchingCommand(t *testing.T) { cmdName: zapscript.ZapScriptCmdEcho, want: false, }, + { + name: "control command", + cmdName: zapscript.ZapScriptCmdControl, + want: false, + }, // HTTP commands - should NOT be blocked { @@ -278,6 +283,7 @@ func TestIsMediaLaunchingCommand_ComprehensiveCoverage(t *testing.T) { zapscript.ZapScriptCmdDelay, zapscript.ZapScriptCmdStop, zapscript.ZapScriptCmdEcho, + zapscript.ZapScriptCmdControl, zapscript.ZapScriptCmdPlaylistPlay, // queues state change zapscript.ZapScriptCmdPlaylistNext, // queues state change zapscript.ZapScriptCmdPlaylistPrevious, // queues state change @@ -318,7 +324,7 @@ func TestIsMediaLaunchingCommand_ComprehensiveCoverage(t *testing.T) { } } -// TestGetExprEnv_ScannedContext verifies that Scanned fields are populated from ExprEnvOptions. +// TestGetExprEnv_ScannedContext verifies that Scanned fields are populated. func TestGetExprEnv_ScannedContext(t *testing.T) { t.Parallel() @@ -328,22 +334,20 @@ func TestGetExprEnv_ScannedContext(t *testing.T) { cfg := &config.Instance{} st, _ := state.NewState(mockPlatform, "test-boot-uuid") - opts := &ExprEnvOptions{ - Scanned: &zapscript.ExprEnvScanned{ - ID: "scanned-token-id", - Value: "**launch:/games/sonic.bin", - Data: "NDEF-record-data", - }, + scanned := &zapscript.ExprEnvScanned{ + ID: "scanned-token-id", + Value: "**launch:/games/sonic.bin", + Data: "NDEF-record-data", } - env := getExprEnv(mockPlatform, cfg, st, opts) + env := GetExprEnv(mockPlatform, cfg, st, scanned, nil) assert.Equal(t, "scanned-token-id", env.Scanned.ID, "Scanned.ID should be populated") assert.Equal(t, "**launch:/games/sonic.bin", env.Scanned.Value, "Scanned.Value should be populated") assert.Equal(t, "NDEF-record-data", env.Scanned.Data, "Scanned.Data should be populated") } -// TestGetExprEnv_LaunchingContext verifies that Launching fields are populated from ExprEnvOptions. +// TestGetExprEnv_LaunchingContext verifies that Launching fields are populated. func TestGetExprEnv_LaunchingContext(t *testing.T) { t.Parallel() @@ -353,23 +357,21 @@ func TestGetExprEnv_LaunchingContext(t *testing.T) { cfg := &config.Instance{} st, _ := state.NewState(mockPlatform, "test-boot-uuid") - opts := &ExprEnvOptions{ - Launching: &zapscript.ExprEnvLaunching{ - Path: "/games/genesis/sonic.bin", - SystemID: "genesis", - LauncherID: "retroarch", - }, + launching := &zapscript.ExprEnvLaunching{ + Path: "/games/genesis/sonic.bin", + SystemID: "genesis", + LauncherID: "retroarch", } - env := getExprEnv(mockPlatform, cfg, st, opts) + env := GetExprEnv(mockPlatform, cfg, st, nil, launching) assert.Equal(t, "/games/genesis/sonic.bin", env.Launching.Path, "Launching.Path should be populated") assert.Equal(t, "genesis", env.Launching.SystemID, "Launching.SystemID should be populated") assert.Equal(t, "retroarch", env.Launching.LauncherID, "Launching.LauncherID should be populated") } -// TestGetExprEnv_NilOpts verifies that nil ExprEnvOptions leaves Scanned/Launching empty. -func TestGetExprEnv_NilOpts(t *testing.T) { +// TestGetExprEnv_NilParams verifies that nil scanned/launching leaves those fields empty. +func TestGetExprEnv_NilParams(t *testing.T) { t.Parallel() mockPlatform := mocks.NewMockPlatform() @@ -378,14 +380,14 @@ func TestGetExprEnv_NilOpts(t *testing.T) { cfg := &config.Instance{} st, _ := state.NewState(mockPlatform, "test-boot-uuid") - env := getExprEnv(mockPlatform, cfg, st, nil) + env := GetExprEnv(mockPlatform, cfg, st, nil, nil) - assert.Empty(t, env.Scanned.ID, "Scanned.ID should be empty with nil opts") - assert.Empty(t, env.Scanned.Value, "Scanned.Value should be empty with nil opts") - assert.Empty(t, env.Scanned.Data, "Scanned.Data should be empty with nil opts") - assert.Empty(t, env.Launching.Path, "Launching.Path should be empty with nil opts") - assert.Empty(t, env.Launching.SystemID, "Launching.SystemID should be empty with nil opts") - assert.Empty(t, env.Launching.LauncherID, "Launching.LauncherID should be empty with nil opts") + assert.Empty(t, env.Scanned.ID, "Scanned.ID should be empty with nil params") + assert.Empty(t, env.Scanned.Value, "Scanned.Value should be empty with nil params") + assert.Empty(t, env.Scanned.Data, "Scanned.Data should be empty with nil params") + assert.Empty(t, env.Launching.Path, "Launching.Path should be empty with nil params") + assert.Empty(t, env.Launching.SystemID, "Launching.SystemID should be empty with nil params") + assert.Empty(t, env.Launching.LauncherID, "Launching.LauncherID should be empty with nil params") } // TestGetExprEnv_BothContexts verifies both Scanned and Launching can be set simultaneously. @@ -398,20 +400,18 @@ func TestGetExprEnv_BothContexts(t *testing.T) { cfg := &config.Instance{} st, _ := state.NewState(mockPlatform, "test-boot-uuid") - opts := &ExprEnvOptions{ - Scanned: &zapscript.ExprEnvScanned{ - ID: "token-123", - Value: "test-value", - Data: "test-data", - }, - Launching: &zapscript.ExprEnvLaunching{ - Path: "/path/to/game", - SystemID: "snes", - LauncherID: "mister", - }, + scanned := &zapscript.ExprEnvScanned{ + ID: "token-123", + Value: "test-value", + Data: "test-data", + } + launching := &zapscript.ExprEnvLaunching{ + Path: "/path/to/game", + SystemID: "snes", + LauncherID: "mister", } - env := getExprEnv(mockPlatform, cfg, st, opts) + env := GetExprEnv(mockPlatform, cfg, st, scanned, launching) // Verify Scanned assert.Equal(t, "token-123", env.Scanned.ID) @@ -443,7 +443,7 @@ func TestGetExprEnv_ActiveMedia(t *testing.T) { Name: "Super Mario World", }) - env := getExprEnv(mockPlatform, cfg, st, nil) + env := GetExprEnv(mockPlatform, cfg, st, nil, nil) assert.True(t, env.MediaPlaying, "MediaPlaying should be true when media is active") assert.Equal(t, "retroarch", env.ActiveMedia.LauncherID) @@ -463,7 +463,7 @@ func TestGetExprEnv_NoActiveMedia(t *testing.T) { cfg := &config.Instance{} st, _ := state.NewState(mockPlatform, "test-boot-uuid") - env := getExprEnv(mockPlatform, cfg, st, nil) + env := GetExprEnv(mockPlatform, cfg, st, nil, nil) assert.False(t, env.MediaPlaying, "MediaPlaying should be false when no media is active") assert.Empty(t, env.ActiveMedia.LauncherID) @@ -491,6 +491,7 @@ func TestIsValidCommand(t *testing.T) { {name: "stop", cmdName: zapscript.ZapScriptCmdStop, want: true}, {name: "http.get", cmdName: zapscript.ZapScriptCmdHTTPGet, want: true}, {name: "input.keyboard", cmdName: zapscript.ZapScriptCmdInputKeyboard, want: true}, + {name: "control", cmdName: zapscript.ZapScriptCmdControl, want: true}, // Invalid commands {name: "unknown command", cmdName: "unknown.cmd", want: false}, {name: "empty string", cmdName: "", want: false}, diff --git a/pkg/zapscript/control.go b/pkg/zapscript/control.go index 3ffd2cfc..3a0415cb 100644 --- a/pkg/zapscript/control.go +++ b/pkg/zapscript/control.go @@ -28,7 +28,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" ) @@ -52,19 +51,27 @@ func IsPlaylistCommand(cmdName string) bool { } } +// IsControlCommand returns true if the command is the control command. +// The control command is blocked in control context to prevent recursion +// where a control's Script invokes another control command. +func IsControlCommand(cmdName string) bool { + return cmdName == gozapscript.ZapScriptCmdControl +} + // isControlAllowed returns true if the command is safe to run in control context. func isControlAllowed(cmdName string) bool { - return !IsMediaLaunchingCommand(cmdName) && !IsPlaylistCommand(cmdName) + return !IsMediaLaunchingCommand(cmdName) && !IsPlaylistCommand(cmdName) && !IsControlCommand(cmdName) } // RunControlScript parses and executes a zapscript string in control context. // All commands are validated before any are executed to prevent partial execution. +// The exprEnv is passed directly to each command instead of building from state. func RunControlScript( pl platforms.Platform, cfg *config.Instance, db *database.Database, - st *state.State, script string, + exprEnv *gozapscript.ArgExprEnv, ) error { parser := gozapscript.NewParser(script) parsed, err := parser.ParseScript() @@ -88,6 +95,11 @@ func RunControlScript( Source: tokens.SourceControl, } + var env gozapscript.ArgExprEnv + if exprEnv != nil { + env = *exprEnv + } + for i, cmd := range parsed.Cmds { _, err := RunCommand( pl, cfg, @@ -97,8 +109,8 @@ func RunControlScript( len(parsed.Cmds), i, db, - st, - nil, + nil, // lm not needed — control commands cannot launch media + &env, ) if err != nil { return fmt.Errorf("control command %q failed: %w", cmd.Name, err) diff --git a/pkg/zapscript/control_cmd.go b/pkg/zapscript/control_cmd.go new file mode 100644 index 00000000..478b86fb --- /dev/null +++ b/pkg/zapscript/control_cmd.go @@ -0,0 +1,113 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "context" + "errors" + "fmt" + + "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/rs/zerolog/log" +) + +var ( + ErrNoActiveMedia = errors.New("no active media") + ErrNoLauncher = errors.New("no launcher associated with active media") +) + +//nolint:gocritic // single-use parameter in command handler +func cmdControl(pl platforms.Platform, env platforms.CmdEnv) (platforms.CmdResult, error) { + if len(env.Cmd.Args) == 0 { + return platforms.CmdResult{}, ErrArgCount + } + + action := env.Cmd.Args[0] + if action == "" { + return platforms.CmdResult{}, ErrRequiredArgs + } + + if env.ExprEnv == nil || !env.ExprEnv.MediaPlaying { + return platforms.CmdResult{}, ErrNoActiveMedia + } + + launcherID := env.ExprEnv.ActiveMedia.LauncherID + if launcherID == "" { + return platforms.CmdResult{}, ErrNoLauncher + } + + var launcher *platforms.Launcher + for _, l := range pl.Launchers(env.Cfg) { + if l.ID == launcherID { + launcher = &l + break + } + } + if launcher == nil { + return platforms.CmdResult{}, fmt.Errorf("launcher not found: %s", launcherID) + } + + if len(launcher.Controls) == 0 { + return platforms.CmdResult{}, fmt.Errorf("launcher %s has no control capabilities", launcherID) + } + + control, ok := launcher.Controls[action] + if !ok { + return platforms.CmdResult{}, fmt.Errorf("action %q not supported by launcher %s", action, launcherID) + } + + // Build control params from advargs, stripping the global "when" key + var args map[string]string + raw := env.Cmd.AdvArgs.Raw() + if len(raw) > 0 { + args = make(map[string]string, len(raw)) + for k, v := range raw { + if k == string(zapscript.KeyWhen) { + continue + } + args[k] = v + } + if len(args) == 0 { + args = nil + } + } + + log.Info().Str("action", action).Str("launcher", launcherID).Msg("executing control command") + + var err error + switch { + case control.Func != nil: + ctx := env.LauncherCtx + if ctx == nil { + ctx = context.Background() + } + err = control.Func(ctx, env.Cfg, platforms.ControlParams{Args: args}) + case control.Script != "": + err = RunControlScript(pl, env.Cfg, env.Database, control.Script, env.ExprEnv) + default: + err = fmt.Errorf("control %q has no implementation", action) + } + if err != nil { + return platforms.CmdResult{}, fmt.Errorf("control action %q failed: %w", action, err) + } + + return platforms.CmdResult{}, nil +} diff --git a/pkg/zapscript/control_cmd_test.go b/pkg/zapscript/control_cmd_test.go new file mode 100644 index 00000000..8db9311a --- /dev/null +++ b/pkg/zapscript/control_cmd_test.go @@ -0,0 +1,420 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "context" + "errors" + "testing" + + "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newControlExprEnv(launcherID string) *zapscript.ArgExprEnv { + return &zapscript.ArgExprEnv{ + MediaPlaying: true, + ActiveMedia: zapscript.ExprEnvActiveMedia{ + LauncherID: launcherID, + }, + } +} + +func TestCmdControl_Success(t *testing.T) { + t.Parallel() + + var calledWith platforms.ControlParams + controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error { + calledWith = params + return nil + } + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "toggle_pause": {Func: controlFunc}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + result, err := cmdControl(mockPlatform, env) + require.NoError(t, err) + assert.Equal(t, platforms.CmdResult{}, result) + assert.Nil(t, calledWith.Args) +} + +func TestCmdControl_SuccessWithScript(t *testing.T) { + t.Parallel() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("ID").Return("test") + mockPlatform.On("KeyboardPress", "{f2}").Return(nil) + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "save_state": {Script: "**input.keyboard:{f2}"}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"save_state"}, + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + result, err := cmdControl(mockPlatform, env) + require.NoError(t, err) + assert.Equal(t, platforms.CmdResult{}, result) + mockPlatform.AssertCalled(t, "KeyboardPress", "{f2}") +} + +func TestCmdControl_ScriptExprEnvPropagation(t *testing.T) { + // Skipped: input macro commands (input.keyboard, input.gamepad) don't support + // expression evaluation in go-zapscript's parseInputMacroArg. + // See: https://github.com/ZaparooProject/go-zapscript/issues/2 + t.Skip("blocked by go-zapscript#2: input macro commands don't support expressions") +} + +func TestCmdControl_NoArgs(t *testing.T) { + t.Parallel() + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{}, + }, + } + + _, err := cmdControl(nil, env) + require.ErrorIs(t, err, ErrArgCount) +} + +func TestCmdControl_EmptyAction(t *testing.T) { + t.Parallel() + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{""}, + }, + } + + _, err := cmdControl(nil, env) + require.ErrorIs(t, err, ErrRequiredArgs) +} + +func TestCmdControl_NoActiveMedia(t *testing.T) { + t.Parallel() + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: &zapscript.ArgExprEnv{ + MediaPlaying: false, + }, + } + + _, err := cmdControl(nil, env) + require.ErrorIs(t, err, ErrNoActiveMedia) +} + +func TestCmdControl_NilExprEnv(t *testing.T) { + t.Parallel() + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + } + + _, err := cmdControl(nil, env) + require.ErrorIs(t, err, ErrNoActiveMedia) +} + +func TestCmdControl_EmptyLauncherID(t *testing.T) { + t.Parallel() + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: newControlExprEnv(""), + } + + _, err := cmdControl(nil, env) + require.ErrorIs(t, err, ErrNoLauncher) +} + +func TestCmdControl_LauncherNotFound(t *testing.T) { + t.Parallel() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + {ID: "other-launcher"}, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: newControlExprEnv("missing-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.Error(t, err) + assert.Contains(t, err.Error(), "launcher not found") +} + +func TestCmdControl_NoControls(t *testing.T) { + t.Parallel() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + {ID: "test-launcher"}, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.Error(t, err) + assert.Contains(t, err.Error(), "no control capabilities") +} + +func TestCmdControl_UnknownAction(t *testing.T) { + t.Parallel() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "toggle_pause": {Func: func(context.Context, *config.Instance, platforms.ControlParams) error { + return nil + }}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"nonexistent_action"}, + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported by launcher") +} + +func TestCmdControl_NoImplementation(t *testing.T) { + t.Parallel() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "toggle_pause": {}, // neither Func nor Script + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.Error(t, err) + assert.Contains(t, err.Error(), "no implementation") +} + +func TestCmdControl_AdvArgsPassThrough(t *testing.T) { + t.Parallel() + + var calledWith platforms.ControlParams + controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error { + calledWith = params + return nil + } + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "save_state": {Func: controlFunc}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"save_state"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{ + "slot": "3", + "name": "quicksave", + }), + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.NoError(t, err) + assert.Equal(t, map[string]string{"slot": "3", "name": "quicksave"}, calledWith.Args) +} + +func TestCmdControl_WhenKeyStripped(t *testing.T) { + t.Parallel() + + var calledWith platforms.ControlParams + controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error { + calledWith = params + return nil + } + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "save_state": {Func: controlFunc}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"save_state"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{ + "when": "true", + "slot": "1", + }), + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.NoError(t, err) + assert.Equal(t, map[string]string{"slot": "1"}, calledWith.Args) + assert.NotContains(t, calledWith.Args, "when") +} + +func TestCmdControl_WhenOnlyAdvArg(t *testing.T) { + t.Parallel() + + var calledWith platforms.ControlParams + controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error { + calledWith = params + return nil + } + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "toggle_pause": {Func: controlFunc}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{ + "when": "true", + }), + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.NoError(t, err) + assert.Nil(t, calledWith.Args, "args should be nil when only 'when' is present") +} + +func TestCmdControl_FuncError(t *testing.T) { + t.Parallel() + + controlFunc := func(_ context.Context, _ *config.Instance, _ platforms.ControlParams) error { + return errors.New("kodi connection refused") + } + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{ + { + ID: "test-launcher", + Controls: map[string]platforms.Control{ + "toggle_pause": {Func: controlFunc}, + }, + }, + }) + + env := platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: "control", + Args: []string{"toggle_pause"}, + }, + ExprEnv: newControlExprEnv("test-launcher"), + } + + _, err := cmdControl(mockPlatform, env) + require.Error(t, err) + assert.Contains(t, err.Error(), "kodi connection refused") + assert.Contains(t, err.Error(), "control action") +} diff --git a/pkg/zapscript/control_test.go b/pkg/zapscript/control_test.go index 92184bb2..f54c71c1 100644 --- a/pkg/zapscript/control_test.go +++ b/pkg/zapscript/control_test.go @@ -23,27 +23,12 @@ import ( "testing" gozapscript "github.com/ZaparooProject/go-zapscript" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func drainNotifications(t *testing.T, ns <-chan models.Notification) { - t.Helper() - t.Cleanup(func() { - for { - select { - case <-ns: - default: - return - } - } - }) -} - func TestRunControlScript_SingleCommand(t *testing.T) { t.Parallel() @@ -52,10 +37,9 @@ func TestRunControlScript_SingleCommand(t *testing.T) { mockPlatform.On("KeyboardPress", "{f2}").Return(nil) cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) + exprEnv := gozapscript.ArgExprEnv{Platform: "test"} - err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}") + err := RunControlScript(mockPlatform, cfg, nil, "**input.keyboard:{f2}", &exprEnv) require.NoError(t, err) mockPlatform.AssertCalled(t, "KeyboardPress", "{f2}") } @@ -69,10 +53,9 @@ func TestRunControlScript_MultiCommand(t *testing.T) { mockPlatform.On("KeyboardPress", "{f5}").Return(nil) cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) + exprEnv := gozapscript.ArgExprEnv{Platform: "test"} - err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}||**input.keyboard:{f5}") + err := RunControlScript(mockPlatform, cfg, nil, "**input.keyboard:{f2}||**input.keyboard:{f5}", &exprEnv) require.NoError(t, err) mockPlatform.AssertCalled(t, "KeyboardPress", "{f2}") mockPlatform.AssertCalled(t, "KeyboardPress", "{f5}") @@ -84,11 +67,7 @@ func TestRunControlScript_RejectsLaunchCommands(t *testing.T) { mockPlatform := mocks.NewMockPlatform() mockPlatform.On("ID").Return("test") - cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) - - err := RunControlScript(mockPlatform, cfg, nil, st, "**launch:/path/to/game") + err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**launch:/path/to/game", nil) require.ErrorIs(t, err, ErrControlCommandNotAllowed) assert.Contains(t, err.Error(), "launch") } @@ -99,11 +78,7 @@ func TestRunControlScript_RejectsPlaylistCommands(t *testing.T) { mockPlatform := mocks.NewMockPlatform() mockPlatform.On("ID").Return("test") - cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) - - err := RunControlScript(mockPlatform, cfg, nil, st, "**playlist.play") + err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**playlist.play", nil) require.ErrorIs(t, err, ErrControlCommandNotAllowed) assert.Contains(t, err.Error(), "playlist.play") } @@ -111,25 +86,14 @@ func TestRunControlScript_RejectsPlaylistCommands(t *testing.T) { func TestRunControlScript_EmptyScript(t *testing.T) { t.Parallel() - mockPlatform := mocks.NewMockPlatform() - cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) - - err := RunControlScript(mockPlatform, cfg, nil, st, "") + err := RunControlScript(nil, &config.Instance{}, nil, "", nil) require.Error(t, err) } func TestRunControlScript_InvalidSyntax(t *testing.T) { t.Parallel() - mockPlatform := mocks.NewMockPlatform() - cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) - - // An invalid script (just the prefix with no command) - err := RunControlScript(mockPlatform, cfg, nil, st, "**") + err := RunControlScript(nil, &config.Instance{}, nil, "**", nil) require.Error(t, err) } @@ -139,13 +103,9 @@ func TestRunControlScript_RejectsBeforeExecuting(t *testing.T) { mockPlatform := mocks.NewMockPlatform() mockPlatform.On("ID").Return("test") - cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) - // Valid command first, then a forbidden launch command. // The valid command must NOT execute. - err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}||**launch:/path/to/game") + err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**input.keyboard:{f2}||**launch:/path/to/game", nil) require.ErrorIs(t, err, ErrControlCommandNotAllowed) mockPlatform.AssertNotCalled(t, "KeyboardPress", "{f2}") } @@ -156,15 +116,44 @@ func TestRunControlScript_RejectsPlaylistInMultiCommand(t *testing.T) { mockPlatform := mocks.NewMockPlatform() mockPlatform.On("ID").Return("test") - cfg := &config.Instance{} - st, ns := state.NewState(mockPlatform, "test-boot-uuid") - drainNotifications(t, ns) - - err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}||**playlist.play") + err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**input.keyboard:{f2}||**playlist.play", nil) require.ErrorIs(t, err, ErrControlCommandNotAllowed) mockPlatform.AssertNotCalled(t, "KeyboardPress", "{f2}") } +func TestRunControlScript_RejectsControlCommand(t *testing.T) { + t.Parallel() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("ID").Return("test") + + err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**control:toggle_pause", nil) + require.ErrorIs(t, err, ErrControlCommandNotAllowed) + assert.Contains(t, err.Error(), "control") +} + +func TestIsControlCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmdName string + want bool + }{ + {name: "control", cmdName: gozapscript.ZapScriptCmdControl, want: true}, + {name: "launch is not control", cmdName: "launch", want: false}, + {name: "stop is not control", cmdName: "stop", want: false}, + {name: "empty string", cmdName: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsControlCommand(tt.cmdName)) + }) + } +} + func TestIsPlaylistCommand(t *testing.T) { t.Parallel() @@ -182,6 +171,7 @@ func TestIsPlaylistCommand(t *testing.T) { {name: "playlist.load", cmdName: gozapscript.ZapScriptCmdPlaylistLoad, want: true}, {name: "playlist.open", cmdName: gozapscript.ZapScriptCmdPlaylistOpen, want: true}, {name: "launch is not playlist", cmdName: "launch", want: false}, + {name: "control is not playlist", cmdName: gozapscript.ZapScriptCmdControl, want: false}, {name: "input.keyboard is not playlist", cmdName: "input.keyboard", want: false}, {name: "empty string", cmdName: "", want: false}, } From 24d23f22195da96b2535789ad1e22a507f09e9c8 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 15 Mar 2026 17:31:10 +0800 Subject: [PATCH 3/3] feat(steamos): add RetroArch controls for EmuDeck launchers Add playback controls (save_state, load_state, toggle_menu, toggle_pause, reset, fast_forward, stop) to EmuDeck launchers using RetroArch emulators. Uses script-based controls with RetroArch's default keyboard hotkeys. Standalone emulator launchers are unaffected. Also fix all pre-existing lint issues across the codebase: gosec G118 (context cancel), noctx (httptest), prealloc, and revive line-length. --- pkg/api/client/client_test.go | 2 + pkg/api/middleware/auth_test.go | 10 +++ pkg/api/middleware/ipfilter_test.go | 7 ++ pkg/api/server_fileserver_test.go | 2 + pkg/api/server_pna_test.go | 1 + pkg/api/server_post_test.go | 18 +++++ pkg/audio/audio.go | 4 +- pkg/config/auth.go | 2 +- pkg/platforms/batocera/tracker.go | 3 +- pkg/platforms/shared/installer/http_test.go | 1 + .../shared/installer/installer_test.go | 1 + pkg/platforms/steamos/emudeck.go | 22 ++++++ pkg/platforms/steamos/launchers_test.go | 79 +++++++++++++++++++ .../externaldrive/externaldrive_test.go | 1 + pkg/readers/libnfc/tags/mifare.go | 7 +- pkg/readers/shared/ndef/ndef.go | 3 +- pkg/service/context.go | 12 +-- pkg/service/hooks_test.go | 4 +- pkg/service/queues_playlist_test.go | 2 +- pkg/service/reader_manager_test.go | 2 +- pkg/service/readers.go | 6 +- pkg/service/scan_behavior_test.go | 2 +- pkg/service/service.go | 2 +- pkg/testing/helpers/api.go | 2 +- pkg/ui/tui/generatedb.go | 3 +- pkg/ui/tui/utils.go | 2 + scripts/tasks/utils/makezip/main.go | 2 + 27 files changed, 176 insertions(+), 26 deletions(-) diff --git a/pkg/api/client/client_test.go b/pkg/api/client/client_test.go index 717e06ab..112ba24b 100644 --- a/pkg/api/client/client_test.go +++ b/pkg/api/client/client_test.go @@ -197,6 +197,7 @@ func TestLocalClient_ContextCancellation(t *testing.T) { cfg := testConfigWithPort(t, port) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Cancel context after a short delay go func() { @@ -456,6 +457,7 @@ func TestWaitNotification_ContextCancellation(t *testing.T) { cfg := testConfigWithPort(t, port) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() go func() { time.Sleep(50 * time.Millisecond) diff --git a/pkg/api/middleware/auth_test.go b/pkg/api/middleware/auth_test.go index 71a30d35..66e6b17b 100644 --- a/pkg/api/middleware/auth_test.go +++ b/pkg/api/middleware/auth_test.go @@ -262,6 +262,7 @@ func TestHTTPAuthMiddleware(t *testing.T) { url += "?key=" + tt.queryParam } + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, url, http.NoBody) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) @@ -336,6 +337,7 @@ func TestWebSocketAuthHandler(t *testing.T) { url += "?key=" + tt.queryParam } + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, url, http.NoBody) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) @@ -373,6 +375,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) { // Test valid key - should reach all middlewares and handler callCount = 0 + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.Header.Set("Authorization", "Bearer valid-key") recorder := httptest.NewRecorder() @@ -383,6 +386,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) { // Test invalid key - should not reach subsequent middlewares or handler callCount = 0 + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.Header.Set("Authorization", "Bearer invalid-key") recorder = httptest.NewRecorder() @@ -393,6 +397,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) { // Test no key - should not reach subsequent middlewares or handler callCount = 0 + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -445,6 +450,7 @@ func TestHTTPAuthMiddleware_LocalhostExempt(t *testing.T) { wrapped := middleware(handler) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = tt.remoteAddr @@ -487,6 +493,7 @@ func TestWebSocketAuthHandler_LocalhostExempt(t *testing.T) { cfg := NewAuthConfig(keysProvider([]string{"secret-key"})) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/ws", http.NoBody) req.RemoteAddr = tt.remoteAddr @@ -546,6 +553,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) { wrapped := middleware(handler) // Request with valid key should succeed + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" // Non-localhost to require auth req.Header.Set("Authorization", "Bearer secret") @@ -557,6 +565,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) { keys = []string{"new-secret"} // Old key should now fail + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" req.Header.Set("Authorization", "Bearer secret") @@ -565,6 +574,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, recorder.Code) // New key should succeed + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" req.Header.Set("Authorization", "Bearer new-secret") diff --git a/pkg/api/middleware/ipfilter_test.go b/pkg/api/middleware/ipfilter_test.go index 023fa406..5d18da6c 100644 --- a/pkg/api/middleware/ipfilter_test.go +++ b/pkg/api/middleware/ipfilter_test.go @@ -312,6 +312,7 @@ func TestHTTPIPFilterMiddleware(t *testing.T) { wrapped := middleware(handler) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = tt.remoteAddr @@ -351,6 +352,7 @@ func TestHTTPIPFilterMiddleware_Integration(t *testing.T) { // Test allowed IP - should reach all middlewares and handler callCount = 0 + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" recorder := httptest.NewRecorder() @@ -361,6 +363,7 @@ func TestHTTPIPFilterMiddleware_Integration(t *testing.T) { // Test blocked IP - should not reach subsequent middlewares or handler callCount = 0 + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "10.0.0.1:12345" recorder = httptest.NewRecorder() @@ -515,6 +518,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) { wrapped := middleware(handler) // Request from allowed IP should succeed + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" recorder := httptest.NewRecorder() @@ -522,6 +526,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) { assert.Equal(t, http.StatusOK, recorder.Code) // Request from blocked IP should fail + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.200:12345" recorder = httptest.NewRecorder() @@ -532,6 +537,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) { allowedIPs = []string{"192.168.1.200"} // Old IP should now be blocked + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" recorder = httptest.NewRecorder() @@ -539,6 +545,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) { assert.Equal(t, http.StatusForbidden, recorder.Code) // New IP should now be allowed + //nolint:noctx // test helper, no context needed req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.200:12345" recorder = httptest.NewRecorder() diff --git a/pkg/api/server_fileserver_test.go b/pkg/api/server_fileserver_test.go index 5dcd9e44..0b643f64 100644 --- a/pkg/api/server_fileserver_test.go +++ b/pkg/api/server_fileserver_test.go @@ -136,6 +136,7 @@ func TestFsCustom404(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, tt.path, http.NoBody) rec := httptest.NewRecorder() @@ -176,6 +177,7 @@ func TestFsCustom404_MissingIndex(t *testing.T) { handler := fsCustom404(http.FS(mockFS)) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodGet, "/unknown", http.NoBody) rec := httptest.NewRecorder() diff --git a/pkg/api/server_pna_test.go b/pkg/api/server_pna_test.go index 173e087f..9556e805 100644 --- a/pkg/api/server_pna_test.go +++ b/pkg/api/server_pna_test.go @@ -79,6 +79,7 @@ func TestPrivateNetworkAccessMiddleware(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(tt.method, "/api", http.NoBody) if tt.requestPNAHeader != "" { req.Header.Set("Access-Control-Request-Private-Network", tt.requestPNAHeader) diff --git a/pkg/api/server_post_test.go b/pkg/api/server_post_test.go index 7a8be55f..8318b771 100644 --- a/pkg/api/server_post_test.go +++ b/pkg/api/server_post_test.go @@ -90,6 +90,7 @@ func TestHandlePostRequest_ValidRequest(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -112,6 +113,7 @@ func TestHandlePostRequest_InvalidJSON(t *testing.T) { handler, _ := createTestPostHandler(t) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{invalid json`)) req.Header.Set("Content-Type", "application/json") @@ -136,6 +138,7 @@ func TestHandlePostRequest_UnknownMethod(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"nonexistent.method"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -157,6 +160,7 @@ func TestHandlePostRequest_WrongContentType(t *testing.T) { handler, _ := createTestPostHandler(t) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{"test":"data"}`)) req.Header.Set("Content-Type", "text/plain") @@ -173,6 +177,7 @@ func TestHandlePostRequest_ContentTypeWithCharset(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json; charset=utf-8") @@ -191,6 +196,7 @@ func TestHandlePostRequest_Notification(t *testing.T) { // JSON-RPC notification (no ID field) reqBody := `{"jsonrpc":"2.0","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -209,6 +215,7 @@ func TestHandlePostRequest_MethodError(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.error"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -232,6 +239,7 @@ func TestHandlePostRequest_OversizedBody(t *testing.T) { // Create a body larger than 1MB largeBody := strings.Repeat("x", 2<<20) // 2MB + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(largeBody)) req.Header.Set("Content-Type", "application/json") @@ -249,6 +257,7 @@ func TestHandlePostRequest_EmptyBody(t *testing.T) { handler, _ := createTestPostHandler(t) + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader("")) req.Header.Set("Content-Type", "application/json") @@ -271,6 +280,7 @@ func TestHandlePostRequest_InvalidJSONRPCVersion(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"1.0","id":"` + uuid.New().String() + `","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -293,6 +303,7 @@ func TestHandlePostRequest_StringID(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"my-custom-string-id","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -314,6 +325,7 @@ func TestHandlePostRequest_NumberID(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":12345,"method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -337,6 +349,7 @@ func TestHandlePostRequest_MissingID(t *testing.T) { // Request without ID field = notification reqBody := `{"jsonrpc":"2.0","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -356,6 +369,7 @@ func TestHandlePostRequest_NullID(t *testing.T) { // Request with explicit null ID reqBody := `{"jsonrpc":"2.0","id":null,"method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -381,6 +395,7 @@ func TestHandlePostRequest_UUIDStringID(t *testing.T) { testUUID := uuid.New().String() reqBody := `{"jsonrpc":"2.0","id":"` + testUUID + `","method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -403,6 +418,7 @@ func TestHandlePostRequest_InvalidObjectID(t *testing.T) { // Object ID is invalid per JSON-RPC spec reqBody := `{"jsonrpc":"2.0","id":{"nested":"object"},"method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -427,6 +443,7 @@ func TestHandlePostRequest_InvalidArrayID(t *testing.T) { // Array ID is invalid per JSON-RPC spec reqBody := `{"jsonrpc":"2.0","id":[1,2,3],"method":"test.echo"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -460,6 +477,7 @@ func TestHandlePostRequest_ResponseWithCallback(t *testing.T) { require.NoError(t, err) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.callback"}` + //nolint:noctx // test helper, no context needed req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") diff --git a/pkg/audio/audio.go b/pkg/audio/audio.go index 83312433..8634153c 100644 --- a/pkg/audio/audio.go +++ b/pkg/audio/audio.go @@ -84,7 +84,7 @@ func (p *MalgoPlayer) playWAV(r io.ReadCloser) error { if p.currentCancel != nil { p.currentCancel() } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: stored in p.currentCancel p.currentCancel = cancel p.playbackGen++ thisGen := p.playbackGen @@ -161,7 +161,7 @@ func (p *MalgoPlayer) PlayFile(path string) error { if p.currentCancel != nil { p.currentCancel() } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: stored in p.currentCancel p.currentCancel = cancel p.playbackGen++ thisGen := p.playbackGen diff --git a/pkg/config/auth.go b/pkg/config/auth.go index e3cdb3f3..0b1cc7c9 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -234,7 +234,7 @@ func marshalAuthFile( Creds: creds, } - data, err := toml.Marshal(file) + data, err := toml.Marshal(file) //nolint:gosec // G117: field name matches existing TOML key, not a secret if err != nil { return nil, fmt.Errorf("failed to marshal auth file: %w", err) } diff --git a/pkg/platforms/batocera/tracker.go b/pkg/platforms/batocera/tracker.go index f6120039..953cf208 100644 --- a/pkg/platforms/batocera/tracker.go +++ b/pkg/platforms/batocera/tracker.go @@ -47,8 +47,7 @@ func (p *Platform) startGameTracker( p.clock = clockwork.NewRealClock() } - ctx, cancel := context.WithCancel(context.Background()) - + ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel returned in closure // Poll every 2 seconds for responsive tracking ticker := p.clock.NewTicker(2 * time.Second) diff --git a/pkg/platforms/shared/installer/http_test.go b/pkg/platforms/shared/installer/http_test.go index f489f794..cd399860 100644 --- a/pkg/platforms/shared/installer/http_test.go +++ b/pkg/platforms/shared/installer/http_test.go @@ -103,6 +103,7 @@ func TestDownloadHTTPFile_ContextCancellation(t *testing.T) { finalPath := filepath.Join(tempDir, "game.rom") ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Cancel after a short delay go func() { diff --git a/pkg/platforms/shared/installer/installer_test.go b/pkg/platforms/shared/installer/installer_test.go index d2d9ccd8..f69fbf3a 100644 --- a/pkg/platforms/shared/installer/installer_test.go +++ b/pkg/platforms/shared/installer/installer_test.go @@ -222,6 +222,7 @@ func TestInstallRemoteFile_ContextCancellation(t *testing.T) { setupShowLoader(mockPlatform) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() downloader := func(args DownloaderArgs) error { // Simulate that the downloader checks context and returns error when cancelled diff --git a/pkg/platforms/steamos/emudeck.go b/pkg/platforms/steamos/emudeck.go index d1de04e7..7a355cd7 100644 --- a/pkg/platforms/steamos/emudeck.go +++ b/pkg/platforms/steamos/emudeck.go @@ -448,9 +448,31 @@ func LaunchViaEmuDeck(ctx context.Context, romPath, systemFolder string) (*os.Pr return cmd.Process, nil } +// retroArchControls returns the control map for RetroArch-based launchers +// using RetroArch's default keyboard hotkeys. +func retroArchControls() map[string]platforms.Control { + return map[string]platforms.Control{ + platforms.ControlSaveState: {Script: "**input.keyboard:{f2}"}, + platforms.ControlLoadState: {Script: "**input.keyboard:{f4}"}, + platforms.ControlToggleMenu: {Script: "**input.keyboard:{f1}"}, + platforms.ControlTogglePause: {Script: "**input.keyboard:p"}, + platforms.ControlReset: {Script: "**input.keyboard:{f9}"}, + platforms.ControlFastForward: {Script: "**input.keyboard:l"}, + platforms.ControlStop: {Script: "**stop"}, + } +} + // createEmuDeckLauncher creates a launcher for a specific EmuDeck system. func createEmuDeckLauncher(systemFolder string, systemInfo esde.SystemInfo, paths EmuDeckPaths) platforms.Launcher { + emulator := emulatorMapping[systemFolder] + + var controls map[string]platforms.Control + if emulator.Type == EmulatorRetroArch { + controls = retroArchControls() + } + return platforms.Launcher{ + Controls: controls, ID: "EmuDeck" + systemInfo.GetLauncherID(), SystemID: systemInfo.SystemID, Lifecycle: platforms.LifecycleTracked, diff --git a/pkg/platforms/steamos/launchers_test.go b/pkg/platforms/steamos/launchers_test.go index 04a1b0ef..83457c0b 100644 --- a/pkg/platforms/steamos/launchers_test.go +++ b/pkg/platforms/steamos/launchers_test.go @@ -26,6 +26,7 @@ import ( "path/filepath" "testing" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared/esde" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -282,6 +283,84 @@ func TestRetroDECKLauncherID(t *testing.T) { assert.Contains(t, launcher.ID, "RetroDECK") } +// TestEmuDeckRetroArchLauncherHasControls tests that RetroArch-based EmuDeck +// launchers have playback controls set. +func TestEmuDeckRetroArchLauncherHasControls(t *testing.T) { + t.Parallel() + + paths := EmuDeckPaths{ + RomsPath: "/home/testuser/Emulation/roms", + GamelistPath: "/home/testuser/ES-DE/gamelists", + } + + systemInfo := esde.SystemInfo{ + SystemID: "nes", + } + + launcher := createEmuDeckLauncher("nes", systemInfo, paths) + + expectedControls := []string{ + platforms.ControlSaveState, + platforms.ControlLoadState, + platforms.ControlToggleMenu, + platforms.ControlTogglePause, + platforms.ControlReset, + platforms.ControlFastForward, + platforms.ControlStop, + } + + require.NotNil(t, launcher.Controls, "RetroArch launcher should have controls") + for _, name := range expectedControls { + ctrl, ok := launcher.Controls[name] + assert.True(t, ok, "should have control: %s", name) + assert.NotEmpty(t, ctrl.Script, "control %s should have a script", name) + } +} + +// TestEmuDeckStandaloneLauncherHasNoControls tests that standalone emulator +// launchers do not have controls set. +func TestEmuDeckStandaloneLauncherHasNoControls(t *testing.T) { + t.Parallel() + + paths := EmuDeckPaths{ + RomsPath: "/home/testuser/Emulation/roms", + GamelistPath: "/home/testuser/ES-DE/gamelists", + } + + systemInfo := esde.SystemInfo{ + SystemID: "psx", + } + + launcher := createEmuDeckLauncher("psx", systemInfo, paths) + + assert.Nil(t, launcher.Controls, "standalone launcher should not have controls") +} + +// TestRetroArchControls tests the retroArchControls helper returns the +// expected control scripts. +func TestRetroArchControls(t *testing.T) { + t.Parallel() + + controls := retroArchControls() + + expected := map[string]string{ + platforms.ControlSaveState: "**input.keyboard:{f2}", + platforms.ControlLoadState: "**input.keyboard:{f4}", + platforms.ControlToggleMenu: "**input.keyboard:{f1}", + platforms.ControlTogglePause: "**input.keyboard:p", + platforms.ControlReset: "**input.keyboard:{f9}", + platforms.ControlFastForward: "**input.keyboard:l", + platforms.ControlStop: "**stop", + } + + assert.Len(t, controls, len(expected)) + for name, script := range expected { + ctrl, ok := controls[name] + assert.True(t, ok, "missing control: %s", name) + assert.Equal(t, script, ctrl.Script, "wrong script for control: %s", name) + } +} + // TestGetRetroArchCoresPath tests the RetroArch cores path function. func TestGetRetroArchCoresPath(t *testing.T) { t.Parallel() diff --git a/pkg/readers/externaldrive/externaldrive_test.go b/pkg/readers/externaldrive/externaldrive_test.go index 3d8e9674..1450cc42 100644 --- a/pkg/readers/externaldrive/externaldrive_test.go +++ b/pkg/readers/externaldrive/externaldrive_test.go @@ -47,6 +47,7 @@ const ( // testContext returns a context with the specified timeout for test synchronization. // Using context instead of raw time.After provides better semantics and cancellation support. func testContext(timeout time.Duration) (context.Context, context.CancelFunc) { + //nolint:gosec // G118: caller is responsible for cancel return context.WithTimeout(context.Background(), timeout) } diff --git a/pkg/readers/libnfc/tags/mifare.go b/pkg/readers/libnfc/tags/mifare.go index 613fd629..87a648b9 100644 --- a/pkg/readers/libnfc/tags/mifare.go +++ b/pkg/readers/libnfc/tags/mifare.go @@ -42,14 +42,15 @@ const ( // buildMifareAuthCommand returns a command to authenticate against a block func buildMifareAuthCommand(block byte, cardUID string) []byte { - command := []byte{ + uidBytes, _ := hex.DecodeString(cardUID) + command := make([]byte, 0, 8+len(uidBytes)) + command = append(command, // Auth using key A 0x60, block, // Using the NDEF well known private key 0xd3, 0xf7, 0xd3, 0xf7, 0xd3, 0xf7, - } + ) // And finally append the tag UID to the end - uidBytes, _ := hex.DecodeString(cardUID) return append(command, uidBytes...) } diff --git a/pkg/readers/shared/ndef/ndef.go b/pkg/readers/shared/ndef/ndef.go index 20cd6332..11b3f337 100644 --- a/pkg/readers/shared/ndef/ndef.go +++ b/pkg/readers/shared/ndef/ndef.go @@ -76,11 +76,12 @@ func calculateNDEFHeader(payload []byte) ([]byte, error) { return nil, errors.New("NDEF payload too large") } - header := []byte{0x03, 0xFF} buf := new(bytes.Buffer) if err := binary.Write(buf, binary.BigEndian, uint16(length)); err != nil { return nil, fmt.Errorf("failed to write NDEF length header: %w", err) } + header := make([]byte, 0, 2+buf.Len()) + header = append(header, 0x03, 0xFF) return append(header, buf.Bytes()...), nil } diff --git a/pkg/service/context.go b/pkg/service/context.go index 993bc20d..2b522d84 100644 --- a/pkg/service/context.go +++ b/pkg/service/context.go @@ -31,10 +31,10 @@ import ( // ServiceContext holds the shared dependencies threaded through all // service-layer functions. Created once in Start() and passed by pointer. type ServiceContext struct { - Platform platforms.Platform - Config *config.Instance - State *state.State - DB *database.Database - LaunchSoftwareQueue chan *tokens.Token - PlaylistQueue chan *playlists.Playlist + Platform platforms.Platform + Config *config.Instance + State *state.State + DB *database.Database + LaunchSoftwareQueue chan *tokens.Token + PlaylistQueue chan *playlists.Playlist } diff --git a/pkg/service/hooks_test.go b/pkg/service/hooks_test.go index 614068ab..f0456062 100644 --- a/pkg/service/hooks_test.go +++ b/pkg/service/hooks_test.go @@ -54,8 +54,8 @@ func setupHookTest(t *testing.T) *ServiceContext { return &ServiceContext{ Platform: mockPlatform, - Config: cfg, - State: st, + Config: cfg, + State: st, DB: &database.Database{ UserDB: mockUserDB, MediaDB: mockMediaDB, diff --git a/pkg/service/queues_playlist_test.go b/pkg/service/queues_playlist_test.go index e1c8ea67..b922b21f 100644 --- a/pkg/service/queues_playlist_test.go +++ b/pkg/service/queues_playlist_test.go @@ -62,7 +62,7 @@ func setupPlaylistTestEnv(t *testing.T) *ServiceContext { mockUserDB.On("GetSupportedZapLinkHosts").Return([]string{}, nil).Maybe() return &ServiceContext{ - Platform: mockPlatform, + Platform: mockPlatform, Config: cfg, State: st, DB: &database.Database{UserDB: mockUserDB}, diff --git a/pkg/service/reader_manager_test.go b/pkg/service/reader_manager_test.go index a1e0ef43..a2baf17a 100644 --- a/pkg/service/reader_manager_test.go +++ b/pkg/service/reader_manager_test.go @@ -72,7 +72,7 @@ func setupReaderManager(t *testing.T) *readerManagerEnv { plq := make(chan *playlists.Playlist, 10) svc := &ServiceContext{ - Platform: mockPlatform, + Platform: mockPlatform, Config: cfg, State: st, DB: db, diff --git a/pkg/service/readers.go b/pkg/service/readers.go index 8511f1aa..2d28021b 100644 --- a/pkg/service/readers.go +++ b/pkg/service/readers.go @@ -358,7 +358,8 @@ func readerManager( } } - if connectErr := connectReaders(svc.Platform, svc.Config, svc.State, scanQueue, autoDetector); connectErr != nil { + connectErr := connectReaders(svc.Platform, svc.Config, svc.State, scanQueue, autoDetector) + if connectErr != nil { log.Warn().Msgf("error connecting rs: %s", connectErr) } // Reset monitor after potentially blocking operations to avoid @@ -474,7 +475,8 @@ preprocessing: svc.State.SetActiveCard(tokens.Token{}) // Run on_remove hook; errors skip exit timer but card state is already cleared - if onRemoveScript := svc.Config.ReadersScan().OnRemove; svc.Config.HoldModeEnabled() && onRemoveScript != "" { + onRemoveScript := svc.Config.ReadersScan().OnRemove + if svc.Config.HoldModeEnabled() && onRemoveScript != "" { if err := runHook(svc, "on_remove", onRemoveScript, nil, nil); err != nil { log.Warn().Err(err).Msg("on_remove hook blocked exit, media will keep running") continue preprocessing diff --git a/pkg/service/scan_behavior_test.go b/pkg/service/scan_behavior_test.go index 628b5e9a..e10f93ee 100644 --- a/pkg/service/scan_behavior_test.go +++ b/pkg/service/scan_behavior_test.go @@ -162,7 +162,7 @@ func setupScanBehavior( limitsManager := playtime.NewLimitsManager(db, mockPlatform, cfg, nil, mockPlayer) svc := &ServiceContext{ - Platform: mockPlatform, + Platform: mockPlatform, Config: cfg, State: st, DB: db, diff --git a/pkg/service/service.go b/pkg/service/service.go index c76170d6..f3300649 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -626,7 +626,7 @@ func startPublishers( // CRITICAL: Always start the drain goroutine, even if there are no active publishers. // The notifChan MUST be consumed or it will fill up and block the notification system. // If there are no publishers, notifications are simply discarded after being consumed. - ctx, cancel := context.WithCancel(st.GetContext()) + ctx, cancel := context.WithCancel(st.GetContext()) //nolint:gosec // G118: cancel returned to caller go func() { for { select { diff --git a/pkg/testing/helpers/api.go b/pkg/testing/helpers/api.go index 869b20f5..6a7a22c1 100644 --- a/pkg/testing/helpers/api.go +++ b/pkg/testing/helpers/api.go @@ -446,7 +446,7 @@ func (m *MockWebSocketConnection) SetCloseError(err error) { // CreateTestContext creates a context with timeout for testing func CreateTestContext(timeout time.Duration) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), timeout) + return context.WithTimeout(context.Background(), timeout) //nolint:gosec // G118: caller is responsible for cancel } // WaitForMessages waits for a specific number of messages with timeout diff --git a/pkg/ui/tui/generatedb.go b/pkg/ui/tui/generatedb.go index c592edca..c8bdee3c 100644 --- a/pkg/ui/tui/generatedb.go +++ b/pkg/ui/tui/generatedb.go @@ -133,8 +133,7 @@ func BuildGenerateDBPage( pages *tview.Pages, app *tview.Application, ) { - ctx, cancel := context.WithCancel(context.Background()) - + ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel called in goBack // Create page frame frame := NewPageFrame(app). SetTitle("Update Media DB") diff --git a/pkg/ui/tui/utils.go b/pkg/ui/tui/utils.go index 60e2cd74..3f99b275 100644 --- a/pkg/ui/tui/utils.go +++ b/pkg/ui/tui/utils.go @@ -41,12 +41,14 @@ const TagReadTimeout = 30 * time.Second // tuiContext creates a context with the TUI request timeout. // Use this for API calls from the TUI to avoid long hangs. func tuiContext() (context.Context, context.CancelFunc) { + //nolint:gosec // G118: caller is responsible for cancel return context.WithTimeout(context.Background(), TUIRequestTimeout) } // tagReadContext creates a context with the tag read timeout. // Use this for operations where the user needs to physically interact with a tag. func tagReadContext() (context.Context, context.CancelFunc) { + //nolint:gosec // G118: caller is responsible for cancel return context.WithTimeout(context.Background(), TagReadTimeout) } diff --git a/scripts/tasks/utils/makezip/main.go b/scripts/tasks/utils/makezip/main.go index 3ee81fbc..759a3329 100644 --- a/scripts/tasks/utils/makezip/main.go +++ b/scripts/tasks/utils/makezip/main.go @@ -387,6 +387,7 @@ func addDirToZip(zipWriter *zip.Writer, dirPath, buildDir string) error { } destPath := filepath.Join(buildDir, filepath.Base(dirPath), relPath) + //nolint:gosec // G703: paths from internal walk, not user input if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil { return fmt.Errorf("failed to create directory: %w", err) } @@ -524,6 +525,7 @@ func addDirToTar(tarWriter *tar.Writer, dirPath, buildDir string) error { } destPath := filepath.Join(buildDir, filepath.Base(dirPath), relPath) + //nolint:gosec // G703: paths from internal walk, not user input if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil { return fmt.Errorf("failed to create directory: %w", err) }