Compare commits

...

56 Commits

Author SHA1 Message Date
17b8e53da3 read version from build info if version was not injected at build time (go install)
inject version from tag at build time for local builds
2025-06-03 16:35:19 +01:00
92761ab1b3 upd link to --version 2025-06-03 13:12:22 +01:00
4446784709 add short names for root options
fix flag example in readme
2025-06-03 12:33:37 +01:00
89a5add7ad upd VersionCmd test 2025-06-02 18:12:43 +01:00
878ecbd33e add 0.9.0 to CHANGELOG 2025-06-02 18:12:06 +01:00
18a90e727f define main.version in local builds 2025-06-02 18:11:56 +01:00
95ebb2afb6 add VersionFlag to CLI struct
upd VersionCmd struct
2025-06-02 18:11:37 +01:00
666b4cf744 add VersionFlag
upd VersionCmd
2025-06-02 18:10:07 +01:00
9ee6fa9e34 typo 2025-05-29 20:03:37 +01:00
e5223fbdfd add 0.8.2 to CHANGELOG 2025-05-29 15:31:53 +01:00
c22ab4384d upd README to reflect changes to --parent option 2025-05-29 15:28:21 +01:00
93a3d3e49f print a more useful sceneitem list table
rename --parent option to --group
2025-05-29 15:28:02 +01:00
2228574837 fix err message test 2025-05-28 15:50:02 +01:00
8f1d42b677 ensure studio mode disabled at end of tests 2025-05-28 15:39:12 +01:00
620adf7e98 return errors if required
upd tests to reflect changes
2025-05-28 15:38:59 +01:00
4a7b8a074a check current active state before starting/stopping recording
return appropriate errors if required

update tests to reflect changes
2025-05-28 15:33:53 +01:00
0811d711aa split record start/stop tests
test output according to current active state
2025-05-28 14:37:55 +01:00
306f19eeae add 0.8.0 to CHANGELOG 2025-05-27 01:28:14 +01:00
43dd77ffdc record directory added to README 2025-05-27 01:27:50 +01:00
f94ac1ca0c record stop now prints output path of recording
record directory command added
2025-05-27 01:27:30 +01:00
c27a5ea6c5 add 0.7.0 section to CHANGELOG 2025-05-26 22:08:44 +01:00
af962a26cc add projector commands
add ProjectorCmd section to README
2025-05-26 22:08:33 +01:00
360d45aa47 create source filters on test setup
remove source filters, ensure replyabuffer is stopped on test teardown
2025-05-26 19:51:27 +01:00
3deb03cf32 add filter/input/version tests 2025-05-26 19:49:51 +01:00
f58b2dfeab add output to replaybuffer start/stop
add replaybuffer tests
2025-05-26 19:49:30 +01:00
6ad530ce2e virtualcam - print OutputActive status on toggle 2025-05-25 22:18:24 +01:00
d9d3c7c8b2 Merge pull request #1 from onyx-and-iris/dependabot/go_modules/golang.org/x/sys-0.1.0
Bump golang.org/x/sys from 0.0.0-20210615035016-665e8c7367d1 to 0.1.0
2025-05-25 15:18:07 +01:00
dependabot[bot]
f72e34adfb Bump golang.org/x/sys from 0.0.0-20210615035016-665e8c7367d1 to 0.1.0
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.0.0-20210615035016-665e8c7367d1 to 0.1.0.
- [Commits](https://github.com/golang/sys/commits/v0.1.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.1.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-25 14:17:44 +00:00
ccb3f59513 md fix 2025-05-25 15:09:41 +01:00
e3fd88cf92 upd CHANGELOG + README 2025-05-25 15:07:34 +01:00
12dfab5642 print list commands as tables 2025-05-25 15:07:13 +01:00
7a2765f72c fix link to filtercmd section 2025-05-25 11:58:19 +01:00
90aa5d4423 add filter commands
upd README, CHANGELOG
2025-05-25 11:55:45 +01:00
da010d67a0 uppercase CLI struct name 2025-05-22 16:41:01 +01:00
0c695298fd add 0.5.0 section to changelog 2025-05-22 09:48:34 +01:00
2f77fa1c54 add hotkey commands 2025-05-22 09:48:26 +01:00
eafc3312a5 add HotkeyCmd section 2025-05-22 09:46:59 +01:00
02541f9915 upd changelog 0.4.2 2025-05-08 05:13:08 +01:00
7fa43eb35c add unit tests 2025-05-08 05:04:03 +01:00
8aeb7cb183 add studiomode enable/disable output 2025-05-08 01:19:11 +01:00
6e25927bc1 add stream start/stop output 2025-05-08 01:18:58 +01:00
dd0bbfc0da add missing record status command
add replaybuffer toggle to readme
2025-05-08 00:47:15 +01:00
c04324d173 add 0.4.0 to changelog 2025-05-07 20:24:30 +01:00
36d0753bd9 use ToggleRecord()/ToggleStream methods 2025-05-07 20:17:26 +01:00
3095c0c49d add replaybuffer toggle command 2025-05-07 20:16:44 +01:00
53bbb58cfb keep struct names consistent 2025-05-07 20:16:31 +01:00
5f2fe05caa fix date 2025-05-02 13:33:29 +01:00
c653047c66 add 0.3.1 to changelog 2025-05-02 12:26:42 +01:00
30fabe8cfc add env file resolver 2025-05-02 12:24:46 +01:00
8cf969c906 add task to view man page
move man tasks into Taskfile.man.yaml
2025-04-29 18:35:58 +01:00
3540c60c4b reword 2025-04-29 16:59:08 +01:00
b2c5980b4a use task var 2025-04-29 16:54:51 +01:00
da1ef9f993 upd task name + outfile name 2025-04-29 16:50:45 +01:00
0a2c622645 add --man flag for generating manfile 2025-04-29 15:19:16 +01:00
cb973c09f5 add installation section to readme 2025-04-27 14:14:42 +01:00
4fa32bfb42 upd changelog 2025-04-27 13:30:44 +01:00
37 changed files with 2099 additions and 164 deletions

9
.gitignore vendored
View File

@@ -26,5 +26,12 @@ go.work
# End of gignore: github.com/onyx-and-iris/gignore # End of gignore: github.com/onyx-and-iris/gignore
# Environment
.env
.envrc .envrc
*_test.go
# Man pages
gobs-cli.1
# Config files
config.yaml

View File

@@ -50,3 +50,6 @@ issues:
exclude: exclude:
# gosec: Duplicated errcheck checks # gosec: Duplicated errcheck checks
- G104 - G104
exclude-files:
# Exclude vendor directory
- main_test.go

View File

@@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# [0.9.0]
### Added
- --version/-v option. See [Flags](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#flags)
### Changed
- version command renamed to obs-version
# [0.8.2]
### Added
- record start/stop and stream start/stop commands check outputActive states first.
- Errors are returned if the command cannot be performed.
### Changed
- The --parent flag for the sceneitem commands has been renamed to --group, see [SceneItemCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#sceneitemcmd)
# [0.8.0] - 2025-05-27
### Added
- record directory command, see [directory under RecordCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#recordcmd)
### Changed
- record stop now prints the output path of the recording.
# [0.7.0] - 2025-05-26
### Added
- projector commands, see [ProjectorCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#projectorcmd)
# [0.6.1] - 2025-05-25
### Added
- filter commands, see [FilterCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#filtercmd)
### Changed
- list commands are now printed as tables.
- This affects group, hotkey, input, profile, scene, scenecollection and sceneitem command groups.
# [0.5.0] - 2025-05-22
### Added
- hotkey commands, see [HotkeyCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#hotkeycmd)
# [0.4.2] - 2025-05-08
### Added
- replaybuffer toggle command
- studiomode enable/disable now print output to console
- stream start/stop now print output to console
- Unit tests
# [0.3.1] - 2025-05-02
### Added
- --man flag for generating/viewing a man page.
- Ability to load env vars from env files, see the [README](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#environment-variables)
# [0.2.0] - 2025-04-27
### Added
- sceneitem transform, see *transform* under [SceneItemCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#sceneitemcmd)
# [0.1.0] - 2025-04-24 # [0.1.0] - 2025-04-24
### Added ### Added

183
README.md
View File

@@ -4,35 +4,51 @@ A command line interface for OBS Websocket v5
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md) For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
## Installation
```console
go install github.com/onyx-and-iris/gobs-cli@latest
```
## Configuration ## Configuration
#### Flags #### Flags
Pass `--host`, `--port` and `--password` as flags to the root command, for example: - --host/-H: Websocket host
- --port/-P Websocket port
- --password/-p: Websocket password
- --timeout/-T: Websocket timeout
- --version/-v: Print the gobs-cli version
Pass `--host`, `--port` and `--password` as flags on the root command, for example:
```console ```console
gobs-cli --host=localhost --port=4455 --password=<websocket password> --help gobs-cli --host localhost --port 4455 --password 'websocket password' --help
``` ```
#### Environment Variables #### Environment Variables
Load connection details from your environment: Store and load environment variables from:
```bash - A `.env` file in the cwd
#!/usr/bin/env bash - $XDG_CONFIG_HOME / gobs-cli / config.env (see [os.UserConfigDir][userconfigdir])
export OBS_HOST=localhost ```env
export OBS_PORT=4455 OBS_HOST=localhost
export OBS_PASSWORD=<websocket password> OBS_PORT=4455
export OBS_TIMEOUT=5 OBS_PASSWORD=<websocket password>
OBS_TIMEOUT=5
``` ```
## Commands ## Commands
### VersionCmd ### ObsVersionCmd
- Print OBS client and websocket version.
```console ```console
gobs-cli version gobs-cli obs-version
``` ```
### SceneCmd ### SceneCmd
@@ -71,9 +87,14 @@ gobs-cli scene switch --preview LIVE
### SceneItemCmd ### SceneItemCmd
- list: List all scene items. - list: List all scene items.
*optional*
- args: SceneName - args: SceneName
- defaults to current scene
```console ```console
gobs-cli sceneitem list
gobs-cli sceneitem list LIVE gobs-cli sceneitem list LIVE
``` ```
@@ -81,7 +102,7 @@ gobs-cli sceneitem list LIVE
- flags: - flags:
*optional* *optional*
- --parent: Parent group name. - --group: Parent group name.
- args: SceneName ItemName - args: SceneName ItemName
```console ```console
@@ -92,7 +113,7 @@ gobs-cli sceneitem show START "Colour Source"
- flags: - flags:
*optional* *optional*
- --parent: Parent group name. - --group: Parent group name.
- args: SceneName ItemName - args: SceneName ItemName
```console ```console
@@ -103,28 +124,29 @@ gobs-cli sceneitem hide START "Colour Source"
- flags: - flags:
*optional* *optional*
- --parent: Parent group name. - --group: Parent group name.
- args: SceneName ItemName - args: SceneName ItemName
```console ```console
gobs-cli sceneitem toggle --parent=test_group START "Colour Source 3" gobs-cli sceneitem toggle --group=test_group START "Colour Source 3"
``` ```
- visible: Get scene item visibility. - visible: Get scene item visibility.
- flags: - flags:
*optional* *optional*
- --parent: Parent group name. - --group: Parent group name.
- args: SceneName ItemName - args: SceneName ItemName
```console ```console
gobs-cli sceneitem visible --parent=test_group START "Colour Source 4" gobs-cli sceneitem visible --group=test_group START "Colour Source 4"
``` ```
- transform: Transform scene item. - transform: Transform scene item.
- flags: - flags:
*optional* *optional*
- --parent: Parent group name. - --group: Parent group name.
- --alignment: Alignment of the scene item. - --alignment: Alignment of the scene item.
- --bounds-alignment: Bounds alignment of the scene item. - --bounds-alignment: Bounds alignment of the scene item.
@@ -153,9 +175,14 @@ gobs-cli sceneitem transform \
### GroupCmd ### GroupCmd
- list: List all groups. - list: List all groups.
*optional*
- args: SceneName - args: SceneName
- defaults to current scene
```console ```console
gobs-cli group list
gobs-cli group list START gobs-cli group list START
``` ```
@@ -262,6 +289,19 @@ gobs-cli record pause
gobs-cli record resume gobs-cli record resume
``` ```
- directory: Get/Set recording directory.
*optional*
- args: RecordDirectory
- if not passed the current record directory will be printed.
```console
gobs-cli record directory
gobs-cli record directory "/home/me/obs-vids/"
gobs-cli record directory "C:/Users/me/Videos"
```
### StreamCmd ### StreamCmd
- start: Start streaming. - start: Start streaming.
@@ -365,6 +405,12 @@ gobs-cli replaybuffer start
gobs-cli replaybuffer stop gobs-cli replaybuffer stop
``` ```
- toggle: Toggle replay buffer.
```console
gobs-cli replaybuffer toggle
```
- status: Get replay buffer status. - status: Get replay buffer status.
```console ```console
@@ -428,3 +474,104 @@ gobs-cli virtualcam toggle
```console ```console
gobs-cli virtualcam status gobs-cli virtualcam status
``` ```
### HotkeyCmd
- list: List all hotkeys.
```console
gobs-cli hotkey list
```
- trigger: Trigger a hotkey by name.
```console
gobs-cli hotkey trigger OBSBasic.StartStreaming
gobs-cli hotkey trigger OBSBasic.StopStreaming
```
- trigger-sequence: Trigger a hotkey by sequence.
- flags:
*optional*
- --shift: Press shift.
- --ctrl: Press control.
- --alt: Press alt.
- --cmd: Press command (mac).
- args: keyID
- Check [obs-hotkeys.h][obs-keyids] for a full list of OBS key ids.
```console
gobs-cli hotkey trigger-sequence OBS_KEY_F1 --ctrl
gobs-cli hotkey trigger-sequence OBS_KEY_F1 --shift --ctrl
```
### FilterCmd
- list: List all filters.
```console
gobs-cli filter list
```
- enable: Enable filter.
- args: SourceName FilterName
```console
gobs-cli enable 'Mic/Aux' 'Gain'
```
- disable: Disable filter.
- args: SourceName FilterName
```console
gobs-cli disable 'Mic/Aux' 'Gain'
```
- toggle: Toggle filter.
- args: SourceName FilterName
```console
gobs-cli toggle 'Mic/Aux' 'Gain'
```
- status: Get filter status.
- args: SourceName FilterName
```console
gobs-cli status 'Mic/Aux' 'Gain'
```
### ProjectorCmd
- list-monitors: List available monitors.
```console
gobs-cli projector list-monitors
```
- open: Open a fullscreen projector for a source on a specific monitor.
- flags:
*optional*
- --monitor-index: Index of the monitor to open the projector on.
- defaults to 0
*optional*
- args: <source_name>
- defaults to current scene
```console
gobs-cli projector open
gobs-cli projector open --monitor-index=1 "test_scene"
gobs-cli projector open --monitor-index=1 "test_group"
```
[userconfigdir]: https://pkg.go.dev/os#UserConfigDir
[obs-keyids]: https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h

17
Taskfile.man.yaml Normal file
View File

@@ -0,0 +1,17 @@
version: '3'
tasks:
default:
desc: View man page
cmds:
- task: view
view:
desc: View man page
cmds:
- go run . --man | man -l -
generate:
desc: Generate man page
cmds:
- go run . --man > {{.PROGRAM}}.1

View File

@@ -1,9 +1,14 @@
version: '3' version: '3'
includes:
man: Taskfile.man.yaml
vars: vars:
PROGRAM: gobs-cli PROGRAM: gobs-cli
SHELL: '{{if eq .OS "Windows_NT"}}powershell{{end}}' SHELL: '{{if eq .OS "Windows_NT"}}powershell{{end}}'
BIN_DIR: bin BIN_DIR: bin
VERSION:
sh: 'git describe --tags $(git rev-list --tags --max-count=1)'
tasks: tasks:
default: default:
@@ -32,13 +37,13 @@ tasks:
build-windows: build-windows:
desc: Build the gobs-cli project for Windows desc: Build the gobs-cli project for Windows
cmds: cmds:
- GOOS=windows GOARCH=amd64 go build -o {{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe - GOOS=windows GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe
internal: true internal: true
build-linux: build-linux:
desc: Build the gobs-cli project for Linux desc: Build the gobs-cli project for Linux
cmds: cmds:
- GOOS=linux GOARCH=amd64 go build -o {{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64 - GOOS=linux GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64
internal: true internal: true
test: test:

176
filter.go Normal file
View File

@@ -0,0 +1,176 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/andreykaipov/goobs/api/requests/filters"
"github.com/aquasecurity/table"
)
// FilterCmd provides commands to manage filters in OBS Studio.
type FilterCmd struct {
List FilterListCmd `cmd:"" help:"List all filters." aliases:"ls"`
Enable FilterEnableCmd `cmd:"" help:"Enable filter." aliases:"on"`
Disable FilterDisableCmd `cmd:"" help:"Disable filter." aliases:"off"`
Toggle FilterToggleCmd `cmd:"" help:"Toggle filter." aliases:"tg"`
Status FilterStatusCmd `cmd:"" help:"Get filter status." aliases:"ss"`
}
// FilterListCmd provides a command to list all filters in a scene.
type FilterListCmd struct {
SourceName string `arg:"" help:"Name of the source to list filters from."`
}
// Run executes the command to list all filters in a scene.
func (cmd *FilterListCmd) Run(ctx *context) error {
filters, err := ctx.Client.Filters.GetSourceFilterList(
filters.NewGetSourceFilterListParams().WithSourceName(cmd.SourceName),
)
if err != nil {
return err
}
if len(filters.Filters) == 0 {
fmt.Fprintf(ctx.Out, "No filters found for source %s.\n", cmd.SourceName)
return nil
}
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignCenter, table.AlignLeft)
t.SetHeaders("Filter Name", "Kind", "Enabled", "Settings")
for _, filter := range filters.Filters {
var lines []string
for k, v := range filter.FilterSettings {
lines = append(lines, fmt.Sprintf("%s %v", k, v))
}
sort.Slice(lines, func(i, j int) bool {
return strings.ToLower(lines[i]) < strings.ToLower(lines[j])
})
t.AddRow(
filter.FilterName,
snakeCaseToTitleCase(filter.FilterKind),
getEnabledMark(filter.FilterEnabled),
strings.Join(lines, "\n"),
)
}
t.Render()
return nil
}
// FilterEnableCmd provides a command to enable a filter in a scene.
type FilterEnableCmd struct {
SourceName string `arg:"" help:"Name of the source to enable filter from."`
FilterName string `arg:"" help:"Name of the filter to enable."`
}
// Run executes the command to enable a filter in a scene.
func (cmd *FilterEnableCmd) Run(ctx *context) error {
_, err := ctx.Client.Filters.SetSourceFilterEnabled(
filters.NewSetSourceFilterEnabledParams().
WithSourceName(cmd.SourceName).
WithFilterName(cmd.FilterName).
WithFilterEnabled(true),
)
if err != nil {
return fmt.Errorf("failed to enable filter %s on source %s: %w",
cmd.FilterName, cmd.SourceName, err)
}
fmt.Fprintf(ctx.Out, "Filter %s enabled on source %s.\n",
cmd.FilterName, cmd.SourceName)
return nil
}
// FilterDisableCmd provides a command to disable a filter in a scene.
type FilterDisableCmd struct {
SourceName string `arg:"" help:"Name of the source to disable filter from."`
FilterName string `arg:"" help:"Name of the filter to disable."`
}
// Run executes the command to disable a filter in a scene.
func (cmd *FilterDisableCmd) Run(ctx *context) error {
_, err := ctx.Client.Filters.SetSourceFilterEnabled(
filters.NewSetSourceFilterEnabledParams().
WithSourceName(cmd.SourceName).
WithFilterName(cmd.FilterName).
WithFilterEnabled(false),
)
if err != nil {
return fmt.Errorf("failed to disable filter %s on source %s: %w",
cmd.FilterName, cmd.SourceName, err)
}
fmt.Fprintf(ctx.Out, "Filter %s disabled on source %s.\n",
cmd.FilterName, cmd.SourceName)
return nil
}
// FilterToggleCmd provides a command to toggle a filter in a scene.
type FilterToggleCmd struct {
SourceName string `arg:"" help:"Name of the source to toggle filter from."`
FilterName string `arg:"" help:"Name of the filter to toggle."`
}
// Run executes the command to toggle a filter in a scene.
func (cmd *FilterToggleCmd) Run(ctx *context) error {
filter, err := ctx.Client.Filters.GetSourceFilter(
filters.NewGetSourceFilterParams().
WithSourceName(cmd.SourceName).
WithFilterName(cmd.FilterName),
)
if err != nil {
return fmt.Errorf("failed to get filter %s on source %s: %w",
cmd.FilterName, cmd.SourceName, err)
}
newStatus := !filter.FilterEnabled
_, err = ctx.Client.Filters.SetSourceFilterEnabled(
filters.NewSetSourceFilterEnabledParams().
WithSourceName(cmd.SourceName).
WithFilterName(cmd.FilterName).
WithFilterEnabled(newStatus),
)
if err != nil {
return fmt.Errorf("failed to toggle filter %s on source %s: %w",
cmd.FilterName, cmd.SourceName, err)
}
if newStatus {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is now enabled.\n",
cmd.FilterName, cmd.SourceName)
} else {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is now disabled.\n",
cmd.FilterName, cmd.SourceName)
}
return nil
}
// FilterStatusCmd provides a command to get the status of a filter in a scene.
type FilterStatusCmd struct {
SourceName string `arg:"" help:"Name of the source to get filter status from."`
FilterName string `arg:"" help:"Name of the filter to get status."`
}
// Run executes the command to get the status of a filter in a scene.
func (cmd *FilterStatusCmd) Run(ctx *context) error {
filter, err := ctx.Client.Filters.GetSourceFilter(
filters.NewGetSourceFilterParams().
WithSourceName(cmd.SourceName).
WithFilterName(cmd.FilterName),
)
if err != nil {
return fmt.Errorf("failed to get status of filter %s on source %s: %w",
cmd.FilterName, cmd.SourceName, err)
}
if filter.FilterEnabled {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is enabled.\n",
cmd.FilterName, cmd.SourceName)
} else {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is disabled.\n",
cmd.FilterName, cmd.SourceName)
}
return nil
}

76
filter_test.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestFilterList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &FilterListCmd{
SourceName: "Mic/Aux",
}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list filters: %v", err)
}
if !strings.Contains(out.String(), "test_filter") {
t.Fatalf("Expected output to contain 'test_filter', got '%s'", out.String())
}
}
func TestFilterListScene(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &FilterListCmd{
SourceName: "gobs-test",
}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list filters in scene: %v", err)
}
if !strings.Contains(out.String(), "test_filter") {
t.Fatalf("Expected output to contain 'test_filter', got '%s'", out.String())
}
}
func TestFilterListEmpty(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &FilterListCmd{
SourceName: "NonExistentSource",
}
err := cmd.Run(context)
if err == nil {
t.Fatal("Expected error for non-existent source, but got none")
}
if !strings.Contains(err.Error(), "No source was found by the name of `NonExistentSource`.") {
t.Fatalf(
"Expected error to contain 'No source was found by the name of `NonExistentSource`.', got '%s'",
err.Error(),
)
}
}

10
go.mod
View File

@@ -4,14 +4,24 @@ go 1.24.0
require ( require (
github.com/alecthomas/kong v1.10.0 github.com/alecthomas/kong v1.10.0
github.com/alecthomas/mango-kong v0.1.0
github.com/andreykaipov/goobs v1.5.6 github.com/andreykaipov/goobs v1.5.6
github.com/aquasecurity/table v1.10.0
github.com/titusjaka/kong-dotenv-go v0.1.0
) )
require ( require (
github.com/buger/jsonparser v1.1.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mmcloughlin/profile v0.1.1 // indirect github.com/mmcloughlin/profile v0.1.1 // indirect
github.com/muesli/mango v0.1.1-0.20220205060214-77e2058169ab // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
) )

20
go.sum
View File

@@ -2,10 +2,14 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw= github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw=
github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/mango-kong v0.1.0 h1:iFVfP1k1K4qpml3JUQmD5I8MCQYfIvsD9mRdrw7jJC4=
github.com/alecthomas/mango-kong v0.1.0/go.mod h1:t+TYVdsONUolf/BwVcm+15eqcdAj15h4Qe9MMFAwwT4=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andreykaipov/goobs v1.5.6 h1:eIkEqYN99+2VJvmlY/56Ah60nkRKS6efMQvpM3oUgPQ= github.com/andreykaipov/goobs v1.5.6 h1:eIkEqYN99+2VJvmlY/56Ah60nkRKS6efMQvpM3oUgPQ=
github.com/andreykaipov/goobs v1.5.6/go.mod h1:iSZP93FJ4d9X/U1x4DD4IyILLtig+vViqZWBGjLywcY= github.com/andreykaipov/goobs v1.5.6/go.mod h1:iSZP93FJ4d9X/U1x4DD4IyILLtig+vViqZWBGjLywcY=
github.com/aquasecurity/table v1.10.0 h1:gPWV28qp9XSlvXdT3ku8yKQoZE6II0vsmegKpW+dB08=
github.com/aquasecurity/table v1.10.0/go.mod h1:eqOmvjjB7AhXFgFqpJUEE/ietg7RrMSJZXyTN8E/wZw=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -16,15 +20,31 @@ github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mmcloughlin/profile v0.1.1 h1:jhDmAqPyebOsVDOCICJoINoLb/AnLBaUw58nFzxWS2w= github.com/mmcloughlin/profile v0.1.1 h1:jhDmAqPyebOsVDOCICJoINoLb/AnLBaUw58nFzxWS2w=
github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU=
github.com/muesli/mango v0.1.1-0.20220205060214-77e2058169ab h1:m7QFONkzLK0fVXCjwX5tANcnj1yXxTnYQtnfJiY3tcA=
github.com/muesli/mango v0.1.1-0.20220205060214-77e2058169ab/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/titusjaka/kong-dotenv-go v0.1.0 h1:TmUjP/sXoNiKLr6oR7n9xrB5XyXi/Ssuebzfz5nxZj4=
github.com/titusjaka/kong-dotenv-go v0.1.0/go.mod h1:pBgLjcu82oqUgb7+bngK9+Ch7jg49E0YADP8Wnj2MXU=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/andreykaipov/goobs/api/requests/sceneitems" "github.com/andreykaipov/goobs/api/requests/sceneitems"
"github.com/aquasecurity/table"
) )
// GroupCmd provides commands to manage groups in OBS Studio. // GroupCmd provides commands to manage groups in OBS Studio.
@@ -17,21 +18,36 @@ type GroupCmd struct {
// GroupListCmd provides a command to list all groups in a scene. // GroupListCmd provides a command to list all groups in a scene.
type GroupListCmd struct { type GroupListCmd struct {
SceneName string `arg:"" help:"Name of the scene to list groups from."` SceneName string `arg:"" help:"Name of the scene to list groups from." default:""`
} }
// Run executes the command to list all groups in a scene. // Run executes the command to list all groups in a scene.
func (cmd *GroupListCmd) Run(ctx *context) error { func (cmd *GroupListCmd) Run(ctx *context) error {
if cmd.SceneName == "" {
currentScene, err := ctx.Client.Scenes.GetCurrentProgramScene()
if err != nil {
return fmt.Errorf("failed to get current program scene: %w", err)
}
cmd.SceneName = currentScene.SceneName
}
resp, err := ctx.Client.SceneItems.GetSceneItemList(sceneitems.NewGetSceneItemListParams(). resp, err := ctx.Client.SceneItems.GetSceneItemList(sceneitems.NewGetSceneItemListParams().
WithSceneName(cmd.SceneName)) WithSceneName(cmd.SceneName))
if err != nil { if err != nil {
return fmt.Errorf("failed to get scene item list: %w", err) return fmt.Errorf("failed to get scene item list: %w", err)
} }
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignCenter, table.AlignLeft, table.AlignCenter)
t.SetHeaders("ID", "Group Name", "Enabled")
for _, item := range resp.SceneItems { for _, item := range resp.SceneItems {
if item.IsGroup { if item.IsGroup {
fmt.Fprintf(ctx.Out, "Group ID: %d, Source Name: %s\n", item.SceneItemID, item.SourceName) t.AddRow(fmt.Sprintf("%d", item.SceneItemID), item.SourceName, getEnabledMark(item.SceneItemEnabled))
} }
} }
t.Render()
return nil return nil
} }

130
group_test.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestGroupList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &GroupListCmd{
SceneName: "Scene",
}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list groups: %v", err)
}
if !strings.Contains(out.String(), "test_group") {
t.Fatalf("Expected output to contain 'test_group', got '%s'", out.String())
}
}
func TestGroupShow(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &GroupShowCmd{
SceneName: "Scene",
GroupName: "test_group",
}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to show group: %v", err)
}
if out.String() != "Group test_group is now shown.\n" {
t.Fatalf("Expected output to be 'Group test_group is now shown.', got '%s'", out.String())
}
}
func TestGroupToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &GroupStatusCmd{
SceneName: "Scene",
GroupName: "test_group",
}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get group status: %v", err)
}
var enabled bool
if strings.Contains(out.String(), "Group test_group is shown.") {
enabled = true
}
// Reset output buffer for the next command
out.Reset()
cmdToggle := &GroupToggleCmd{
SceneName: "Scene",
GroupName: "test_group",
}
err = cmdToggle.Run(context)
if err != nil {
t.Fatalf("Failed to toggle group: %v", err)
}
if enabled {
if out.String() != "Group test_group is now hidden.\n" {
t.Fatalf("Expected output to be 'Group test_group is now hidden.', got '%s'", out.String())
}
} else {
if out.String() != "Group test_group is now shown.\n" {
t.Fatalf("Expected output to be 'Group test_group is now shown.', got '%s'", out.String())
}
}
}
func TestGroupStatus(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdShow := &GroupShowCmd{
SceneName: "Scene",
GroupName: "test_group",
}
err := cmdShow.Run(context)
if err != nil {
t.Fatalf("Failed to show group: %v", err)
}
// Reset output buffer for the next command
out.Reset()
cmdStatus := &GroupStatusCmd{
SceneName: "Scene",
GroupName: "test_group",
}
err = cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get group status: %v", err)
}
if out.String() != "Group test_group is shown.\n" {
t.Fatalf("Expected output to be 'Group test_group is shown.', got '%s'", out.String())
}
}

79
hotkey.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"github.com/andreykaipov/goobs/api/requests/general"
"github.com/andreykaipov/goobs/api/typedefs"
"github.com/aquasecurity/table"
)
// HotkeyCmd provides commands to manage hotkeys in OBS Studio.
type HotkeyCmd struct {
List HotkeyListCmd `cmd:"" help:"List all hotkeys." aliases:"ls"`
Trigger HotkeyTriggerCmd `cmd:"" help:"Trigger a hotkey by name." aliases:"tr"`
TriggerSequence HotkeyTriggerSequenceCmd `cmd:"" help:"Trigger a hotkey by sequence." aliases:"trs"`
}
// HotkeyListCmd provides a command to list all hotkeys.
type HotkeyListCmd struct{} // size = 0x0
// Run executes the command to list all hotkeys.
func (cmd *HotkeyListCmd) Run(ctx *context) error {
resp, err := ctx.Client.General.GetHotkeyList()
if err != nil {
return err
}
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignLeft)
t.SetHeaders("Hotkey Name")
for _, hotkey := range resp.Hotkeys {
t.AddRow(hotkey)
}
t.Render()
return nil
}
// HotkeyTriggerCmd provides a command to trigger a hotkey.
type HotkeyTriggerCmd struct {
Hotkey string `help:"Hotkey name to trigger." arg:""`
}
// Run executes the command to trigger a hotkey.
func (cmd *HotkeyTriggerCmd) Run(ctx *context) error {
_, err := ctx.Client.General.TriggerHotkeyByName(
general.NewTriggerHotkeyByNameParams().WithHotkeyName(cmd.Hotkey),
)
if err != nil {
return err
}
return nil
}
// HotkeyTriggerSequenceCmd provides a command to trigger a hotkey sequence.
type HotkeyTriggerSequenceCmd struct {
Shift bool `flag:"" help:"Shift modifier."`
Ctrl bool `flag:"" help:"Control modifier."`
Alt bool `flag:"" help:"Alt modifier."`
Cmd bool `flag:"" help:"Command modifier."`
KeyID string ` help:"Key ID to trigger." arg:""`
}
// Run executes the command to trigger a hotkey sequence.
func (cmd *HotkeyTriggerSequenceCmd) Run(ctx *context) error {
_, err := ctx.Client.General.TriggerHotkeyByKeySequence(
general.NewTriggerHotkeyByKeySequenceParams().
WithKeyId(cmd.KeyID).
WithKeyModifiers(&typedefs.KeyModifiers{
Shift: cmd.Shift,
Control: cmd.Ctrl,
Alt: cmd.Alt,
Command: cmd.Cmd,
}),
)
if err != nil {
return err
}
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/andreykaipov/goobs/api/requests/inputs" "github.com/andreykaipov/goobs/api/requests/inputs"
"github.com/aquasecurity/table"
) )
// InputCmd provides commands to manage inputs in OBS Studio. // InputCmd provides commands to manage inputs in OBS Studio.
@@ -28,21 +29,28 @@ func (cmd *InputListCmd) Run(ctx *context) error {
if err != nil { if err != nil {
return err return err
} }
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignLeft, table.AlignLeft)
t.SetHeaders("Input Name", "Kind")
for _, input := range resp.Inputs { for _, input := range resp.Inputs {
if cmd.Input && strings.Contains(input.InputKind, "input") { if cmd.Input && strings.Contains(input.InputKind, "input") {
fmt.Fprintln(ctx.Out, "Input:", input.InputName) t.AddRow(input.InputName, input.InputKind)
} }
if cmd.Output && strings.Contains(input.InputKind, "output") { if cmd.Output && strings.Contains(input.InputKind, "output") {
fmt.Fprintln(ctx.Out, "Output:", input.InputName) t.AddRow(input.InputName, input.InputKind)
} }
if cmd.Colour && strings.Contains(input.InputKind, "color") { // nolint if cmd.Colour && strings.Contains(input.InputKind, "color") { // nolint
fmt.Fprintln(ctx.Out, "Colour Source:", input.InputName) t.AddRow(input.InputName, input.InputKind)
} }
if !cmd.Input && !cmd.Output && !cmd.Colour { if !cmd.Input && !cmd.Output && !cmd.Colour {
fmt.Fprintln(ctx.Out, "Source:", input.InputName) t.AddRow(input.InputName, input.InputKind)
} }
} }
t.Render()
return nil return nil
} }

140
input_test.go Normal file
View File

@@ -0,0 +1,140 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestInputList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &InputListCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list inputs: %v", err)
}
expectedInputs := []string{
"Desktop Audio",
"Mic/Aux",
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
output := out.String()
for _, input := range expectedInputs {
if !strings.Contains(output, input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, output)
}
}
}
func TestInputListFilterInput(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &InputListCmd{Input: true}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list inputs with filter: %v", err)
}
expectedInputs := []string{
"Mic/Aux",
}
expectedFilteredOut := []string{
"Desktop Audio",
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
for _, filteredOut := range expectedFilteredOut {
if strings.Contains(out.String(), filteredOut) {
t.Fatalf("Expected output to NOT contain '%s', got '%s'", filteredOut, out.String())
}
}
}
func TestInputListFilterOutput(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &InputListCmd{Output: true}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list outputs with filter: %v", err)
}
expectedInputs := []string{
"Desktop Audio",
}
expectedFilteredOut := []string{
"Mic/Aux",
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
for _, filteredOut := range expectedFilteredOut {
if strings.Contains(out.String(), filteredOut) {
t.Fatalf("Expected output to NOT contain '%s', got '%s'", filteredOut, out.String())
}
}
}
func TestInputListFilterColour(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &InputListCmd{Colour: true}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list colour inputs with filter: %v", err)
}
expectedInputs := []string{
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
}

32
main.go
View File

@@ -7,26 +7,32 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"time" "time"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
mangokong "github.com/alecthomas/mango-kong"
"github.com/andreykaipov/goobs" "github.com/andreykaipov/goobs"
kongdotenv "github.com/titusjaka/kong-dotenv-go"
) )
// ObsConfig holds the configuration for connecting to the OBS WebSocket server. // ObsConfig holds the configuration for connecting to the OBS WebSocket server.
type ObsConfig struct { type ObsConfig struct {
Host string `flag:"host" help:"Host to connect to." default:"localhost" env:"OBS_HOST"` Host string `flag:"host" help:"Host to connect to." default:"localhost" env:"OBS_HOST" short:"H"`
Port int `flag:"port" help:"Port to connect to." default:"4455" env:"OBS_PORT"` Port int `flag:"port" help:"Port to connect to." default:"4455" env:"OBS_PORT" short:"P"`
Password string `flag:"password" help:"Password for authentication." default:"" env:"OBS_PASSWORD"` Password string `flag:"password" help:"Password for authentication." default:"" env:"OBS_PASSWORD" short:"p"`
Timeout int `flag:"timeout" help:"Timeout in seconds." default:"5" env:"OBS_TIMEOUT"` Timeout int `flag:"timeout" help:"Timeout in seconds." default:"5" env:"OBS_TIMEOUT" short:"T"`
} }
// cli is the main command line interface structure. // CLI is the main command line interface structure.
// It embeds the ObsConfig struct to inherit its fields and flags. // It embeds the ObsConfig struct to inherit its fields and flags.
type cli struct { type CLI struct {
ObsConfig `embed:"" help:"OBS WebSocket configuration."` ObsConfig `embed:"" help:"OBS WebSocket configuration."`
Version VersionCmd `help:"Show version." cmd:"" aliases:"v"` Man mangokong.ManFlag `help:"Print man page."`
Version VersionFlag `help:"Print gobs-cli version information and quit" name:"version" short:"v"`
ObsVersion ObsVersionCmd `help:"Print OBS client and websocket version." cmd:"" aliases:"v"`
Scene SceneCmd `help:"Manage scenes." cmd:"" aliases:"sc"` Scene SceneCmd `help:"Manage scenes." cmd:"" aliases:"sc"`
Sceneitem SceneItemCmd `help:"Manage scene items." cmd:"" aliases:"si"` Sceneitem SceneItemCmd `help:"Manage scene items." cmd:"" aliases:"si"`
Group GroupCmd `help:"Manage groups." cmd:"" aliases:"g"` Group GroupCmd `help:"Manage groups." cmd:"" aliases:"g"`
@@ -38,6 +44,9 @@ type cli struct {
Replaybuffer ReplayBufferCmd `help:"Manage replay buffer." cmd:"" aliases:"rb"` Replaybuffer ReplayBufferCmd `help:"Manage replay buffer." cmd:"" aliases:"rb"`
Studiomode StudioModeCmd `help:"Manage studio mode." cmd:"" aliases:"sm"` Studiomode StudioModeCmd `help:"Manage studio mode." cmd:"" aliases:"sm"`
Virtualcam VirtualCamCmd `help:"Manage virtual camera." cmd:"" aliases:"vc"` Virtualcam VirtualCamCmd `help:"Manage virtual camera." cmd:"" aliases:"vc"`
Hotkey HotkeyCmd `help:"Manage hotkeys." cmd:"" aliases:"hk"`
Filter FilterCmd `help:"Manage filters." cmd:"" aliases:"f"`
Projector ProjectorCmd `help:"Manage projectors." cmd:"" aliases:"prj"`
} }
type context struct { type context struct {
@@ -46,11 +55,18 @@ type context struct {
} }
func main() { func main() {
cli := cli{} userConfigDir, err := os.UserConfigDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting user config directory: %v\n", err)
os.Exit(1)
}
var cli CLI
ctx := kong.Parse( ctx := kong.Parse(
&cli, &cli,
kong.Name("GOBS-CLI"), kong.Name("GOBS-CLI"),
kong.Description("A command line tool to interact with OBS Websocket."), kong.Description("A command line tool to interact with OBS Websocket."),
kong.Configuration(kongdotenv.ENVFileReader, ".env", filepath.Join(userConfigDir, "gobs-cli", "config.env")),
) )
client, err := connectObs(cli.ObsConfig) client, err := connectObs(cli.ObsConfig)

136
main_test.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"os"
"testing"
"github.com/andreykaipov/goobs"
"github.com/andreykaipov/goobs/api/requests/config"
"github.com/andreykaipov/goobs/api/requests/filters"
"github.com/andreykaipov/goobs/api/requests/inputs"
"github.com/andreykaipov/goobs/api/requests/scenes"
"github.com/andreykaipov/goobs/api/requests/ui"
typedefs "github.com/andreykaipov/goobs/api/typedefs"
)
func getClient(t *testing.T) (*goobs.Client, func()) {
t.Helper()
client, err := connectObs(ObsConfig{
Host: os.Getenv("OBS_HOST"),
Port: 4455,
Password: os.Getenv("OBS_PASSWORD"),
Timeout: 5,
})
if err != nil {
t.Fatalf("Failed to connect to OBS: %v", err)
}
return client, func() {
if err := client.Disconnect(); err != nil {
t.Fatalf("Failed to disconnect from OBS: %v", err)
}
}
}
func TestMain(m *testing.M) {
client, err := connectObs(ObsConfig{
Host: os.Getenv("OBS_HOST"),
Port: 4455,
Password: os.Getenv("OBS_PASSWORD"),
Timeout: 5,
})
if err != nil {
os.Exit(1)
}
defer client.Disconnect()
setup(client)
// Run the tests
exitCode := m.Run()
teardown(client)
// Exit with the appropriate code
os.Exit(exitCode)
}
func setup(client *goobs.Client) {
client.Config.SetStreamServiceSettings(config.NewSetStreamServiceSettingsParams().
WithStreamServiceType("rtmp_common").
WithStreamServiceSettings(&typedefs.StreamServiceSettings{
Server: "auto",
Key: os.Getenv("OBS_STREAM_KEY"),
}))
client.Config.SetCurrentSceneCollection(config.NewSetCurrentSceneCollectionParams().
WithSceneCollectionName("test-collection"))
client.Scenes.CreateScene(scenes.NewCreateSceneParams().
WithSceneName("gobs-test"))
client.Inputs.CreateInput(inputs.NewCreateInputParams().
WithSceneName("gobs-test").
WithInputName("gobs-test-input").
WithInputKind("color_source_v3").
WithInputSettings(map[string]any{
"color": 3279460728,
"width": 1920,
"height": 1080,
"visible": true,
}).
WithSceneItemEnabled(true))
client.Inputs.CreateInput(inputs.NewCreateInputParams().
WithSceneName("gobs-test").
WithInputName("gobs-test-input-2").
WithInputKind("color_source_v3").
WithInputSettings(map[string]any{
"color": 1789347616,
"width": 720,
"height": 480,
"visible": true,
}).
WithSceneItemEnabled(true))
// Create source filter on an audio input
client.Filters.CreateSourceFilter(filters.NewCreateSourceFilterParams().
WithSourceName("Mic/Aux").
WithFilterName("test_filter").
WithFilterKind("compressor_filter").
WithFilterSettings(map[string]any{
"threshold": -20,
"ratio": 4,
"attack_time": 10,
"release_time": 100,
"output_gain": -3.6,
"sidechain_source": nil,
}))
// Create source filter on a scene
client.Filters.CreateSourceFilter(filters.NewCreateSourceFilterParams().
WithSourceName("gobs-test").
WithFilterName("test_filter").
WithFilterKind("luma_key_filter_v2").
WithFilterSettings(map[string]any{
"luma": 0.5,
}))
}
func teardown(client *goobs.Client) {
client.Filters.RemoveSourceFilter(filters.NewRemoveSourceFilterParams().
WithSourceName("Mic/Aux").
WithFilterName("test_filter"))
client.Filters.RemoveSourceFilter(filters.NewRemoveSourceFilterParams().
WithSourceName("gobs-test").
WithFilterName("test_filter"))
client.Scenes.RemoveScene(scenes.NewRemoveSceneParams().
WithSceneName("gobs-test"))
client.Config.SetCurrentSceneCollection(config.NewSetCurrentSceneCollectionParams().
WithSceneCollectionName("default"))
client.Stream.StopStream()
client.Record.StopRecord()
client.Outputs.StopReplayBuffer()
client.Ui.SetStudioModeEnabled(ui.NewSetStudioModeEnabledParams().
WithStudioModeEnabled(false))
}

View File

@@ -5,39 +5,50 @@ import (
"slices" "slices"
"github.com/andreykaipov/goobs/api/requests/config" "github.com/andreykaipov/goobs/api/requests/config"
"github.com/aquasecurity/table"
) )
// ProfileCmd provides commands to manage profiles in OBS Studio. // ProfileCmd provides commands to manage profiles in OBS Studio.
type ProfileCmd struct { type ProfileCmd struct {
List ListProfileCmd `help:"List profiles." cmd:"" aliases:"ls"` List ProfileListCmd `help:"List profiles." cmd:"" aliases:"ls"`
Current CurrentProfileCmd `help:"Get current profile." cmd:"" aliases:"c"` Current ProfileCurrentCmd `help:"Get current profile." cmd:"" aliases:"c"`
Switch SwitchProfileCmd `help:"Switch profile." cmd:"" aliases:"sw"` Switch ProfileSwitchCmd `help:"Switch profile." cmd:"" aliases:"sw"`
Create CreateProfileCmd `help:"Create profile." cmd:"" aliases:"new"` Create ProfileCreateCmd `help:"Create profile." cmd:"" aliases:"new"`
Remove RemoveProfileCmd `help:"Remove profile." cmd:"" aliases:"rm"` Remove ProfileRemoveCmd `help:"Remove profile." cmd:"" aliases:"rm"`
} }
// ListProfileCmd provides a command to list all profiles. // ProfileListCmd provides a command to list all profiles.
type ListProfileCmd struct{} // size = 0x0 type ProfileListCmd struct{} // size = 0x0
// Run executes the command to list all profiles. // Run executes the command to list all profiles.
func (cmd *ListProfileCmd) Run(ctx *context) error { func (cmd *ProfileListCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList() profiles, err := ctx.Client.Config.GetProfileList()
if err != nil { if err != nil {
return err return err
} }
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignLeft, table.AlignCenter)
t.SetHeaders("Profile Name", "Current")
for _, profile := range profiles.Profiles { for _, profile := range profiles.Profiles {
fmt.Fprintln(ctx.Out, profile) var enabledMark string
if profile == profiles.CurrentProfileName {
enabledMark = getEnabledMark(true)
} }
t.AddRow(profile, enabledMark)
}
t.Render()
return nil return nil
} }
// CurrentProfileCmd provides a command to get the current profile. // ProfileCurrentCmd provides a command to get the current profile.
type CurrentProfileCmd struct{} // size = 0x0 type ProfileCurrentCmd struct{} // size = 0x0
// Run executes the command to get the current profile. // Run executes the command to get the current profile.
func (cmd *CurrentProfileCmd) Run(ctx *context) error { func (cmd *ProfileCurrentCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList() profiles, err := ctx.Client.Config.GetProfileList()
if err != nil { if err != nil {
return err return err
@@ -47,13 +58,13 @@ func (cmd *CurrentProfileCmd) Run(ctx *context) error {
return nil return nil
} }
// SwitchProfileCmd provides a command to switch to a different profile. // ProfileSwitchCmd provides a command to switch to a different profile.
type SwitchProfileCmd struct { type ProfileSwitchCmd struct {
Name string `arg:"" help:"Name of the profile to switch to." required:""` Name string `arg:"" help:"Name of the profile to switch to." required:""`
} }
// Run executes the command to switch to a different profile. // Run executes the command to switch to a different profile.
func (cmd *SwitchProfileCmd) Run(ctx *context) error { func (cmd *ProfileSwitchCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList() profiles, err := ctx.Client.Config.GetProfileList()
if err != nil { if err != nil {
return err return err
@@ -74,13 +85,13 @@ func (cmd *SwitchProfileCmd) Run(ctx *context) error {
return nil return nil
} }
// CreateProfileCmd provides a command to create a new profile. // ProfileCreateCmd provides a command to create a new profile.
type CreateProfileCmd struct { type ProfileCreateCmd struct {
Name string `arg:"" help:"Name of the profile to create." required:""` Name string `arg:"" help:"Name of the profile to create." required:""`
} }
// Run executes the command to create a new profile. // Run executes the command to create a new profile.
func (cmd *CreateProfileCmd) Run(ctx *context) error { func (cmd *ProfileCreateCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList() profiles, err := ctx.Client.Config.GetProfileList()
if err != nil { if err != nil {
return err return err
@@ -100,13 +111,13 @@ func (cmd *CreateProfileCmd) Run(ctx *context) error {
return nil return nil
} }
// RemoveProfileCmd provides a command to remove an existing profile. // ProfileRemoveCmd provides a command to remove an existing profile.
type RemoveProfileCmd struct { type ProfileRemoveCmd struct {
Name string `arg:"" help:"Name of the profile to delete." required:""` Name string `arg:"" help:"Name of the profile to delete." required:""`
} }
// Run executes the command to remove an existing profile. // Run executes the command to remove an existing profile.
func (cmd *RemoveProfileCmd) Run(ctx *context) error { func (cmd *ProfileRemoveCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList() profiles, err := ctx.Client.Config.GetProfileList()
if err != nil { if err != nil {
return err return err

66
projector.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/ui"
"github.com/aquasecurity/table"
)
// ProjectorCmd provides a command to manage projectors in OBS.
type ProjectorCmd struct {
ListMonitors ProjectorListMonitorsCmd `cmd:"" help:"List available monitors." aliases:"ls-m"`
Open ProjectorOpenCmd `cmd:"" help:"Open a fullscreen projector for a source on a specific monitor." aliases:"o"`
}
// ProjectorListMonitorsCmd provides a command to list all monitors available for projectors.
type ProjectorListMonitorsCmd struct{} // size = 0x0
// Run executes the command to list all monitors available for projectors.
func (cmd *ProjectorListMonitorsCmd) Run(ctx *context) error {
monitors, err := ctx.Client.Ui.GetMonitorList()
if err != nil {
return err
}
if len(monitors.Monitors) == 0 {
ctx.Out.Write([]byte("No monitors found for projectors.\n"))
return nil
}
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignCenter, table.AlignLeft)
t.SetHeaders("Monitor ID", "Monitor Name")
for _, monitor := range monitors.Monitors {
t.AddRow(fmt.Sprintf("%d", monitor.MonitorIndex), monitor.MonitorName)
}
t.Render()
return nil
}
// ProjectorOpenCmd provides a command to open a fullscreen projector for a specific source.
type ProjectorOpenCmd struct {
MonitorIndex int `flag:"" help:"Index of the monitor to open the projector on." default:"0"`
SourceName string ` help:"Name of the source to project." default:"" arg:""`
}
// Run executes the command to show details of a specific projector.
func (cmd *ProjectorOpenCmd) Run(ctx *context) error {
if cmd.SourceName == "" {
currentScene, err := ctx.Client.Scenes.GetCurrentProgramScene()
if err != nil {
return fmt.Errorf("failed to get current program scene: %w", err)
}
cmd.SourceName = currentScene.SceneName
}
ctx.Client.Ui.OpenSourceProjector(ui.NewOpenSourceProjectorParams().
WithSourceName(cmd.SourceName).
WithMonitorIndex(cmd.MonitorIndex))
fmt.Fprintf(ctx.Out, "Opened projector for source '%s' on monitor index %d.\n", cmd.SourceName, cmd.MonitorIndex)
return nil
}

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"fmt" "fmt"
"github.com/andreykaipov/goobs/api/requests/config"
) )
// RecordCmd handles the recording commands. // RecordCmd handles the recording commands.
@@ -9,8 +11,10 @@ type RecordCmd struct {
Start RecordStartCmd `cmd:"" help:"Start recording." aliases:"s"` Start RecordStartCmd `cmd:"" help:"Start recording." aliases:"s"`
Stop RecordStopCmd `cmd:"" help:"Stop recording." aliases:"st"` Stop RecordStopCmd `cmd:"" help:"Stop recording." aliases:"st"`
Toggle RecordToggleCmd `cmd:"" help:"Toggle recording." aliases:"tg"` Toggle RecordToggleCmd `cmd:"" help:"Toggle recording." aliases:"tg"`
Status RecordStatusCmd `cmd:"" help:"Show recording status." aliases:"ss"`
Pause RecordPauseCmd `cmd:"" help:"Pause recording." aliases:"p"` Pause RecordPauseCmd `cmd:"" help:"Pause recording." aliases:"p"`
Resume RecordResumeCmd `cmd:"" help:"Resume recording." aliases:"r"` Resume RecordResumeCmd `cmd:"" help:"Resume recording." aliases:"r"`
Directory RecordDirectoryCmd `cmd:"" help:"Get/Set recording directory." aliases:"d"`
} }
// RecordStartCmd starts the recording. // RecordStartCmd starts the recording.
@@ -18,7 +22,19 @@ type RecordStartCmd struct{} // size = 0x0
// Run executes the command to start recording. // Run executes the command to start recording.
func (cmd *RecordStartCmd) Run(ctx *context) error { func (cmd *RecordStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Record.StartRecord() status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
if status.OutputActive {
if status.OutputPaused {
return fmt.Errorf("recording is already in progress and paused")
}
return fmt.Errorf("recording is already in progress")
}
_, err = ctx.Client.Record.StartRecord()
if err != nil { if err != nil {
return err return err
} }
@@ -31,11 +47,20 @@ type RecordStopCmd struct{} // size = 0x0
// Run executes the command to stop recording. // Run executes the command to stop recording.
func (cmd *RecordStopCmd) Run(ctx *context) error { func (cmd *RecordStopCmd) Run(ctx *context) error {
_, err := ctx.Client.Record.StopRecord() status, err := ctx.Client.Record.GetRecordStatus()
if err != nil { if err != nil {
return err return err
} }
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
if !status.OutputActive {
return fmt.Errorf("recording is not in progress")
}
resp, err := ctx.Client.Record.StopRecord()
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "%s", fmt.Sprintf("Recording stopped successfully. Output file: %s\n", resp.OutputPath))
return nil return nil
} }
@@ -44,25 +69,39 @@ type RecordToggleCmd struct{} // size = 0x0
// Run executes the command to toggle recording. // Run executes the command to toggle recording.
func (cmd *RecordToggleCmd) Run(ctx *context) error { func (cmd *RecordToggleCmd) Run(ctx *context) error {
// Check if recording is in progress status, err := ctx.Client.Record.ToggleRecord()
if err != nil {
return err
}
if status.OutputActive {
fmt.Fprintln(ctx.Out, "Recording started successfully.")
} else {
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
}
return nil
}
// RecordStatusCmd shows the recording status.
type RecordStatusCmd struct{} // size = 0x0
// Run executes the command to show recording status.
func (cmd *RecordStatusCmd) Run(ctx *context) error {
status, err := ctx.Client.Record.GetRecordStatus() status, err := ctx.Client.Record.GetRecordStatus()
if err != nil { if err != nil {
return err return err
} }
if status.OutputActive { if status.OutputActive {
_, err = ctx.Client.Record.StopRecord() if status.OutputPaused {
if err != nil { fmt.Fprintln(ctx.Out, "Recording is paused.")
return err
}
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
} else { } else {
_, err = ctx.Client.Record.StartRecord() fmt.Fprintln(ctx.Out, "Recording is in progress.")
if err != nil {
return err
} }
fmt.Fprintln(ctx.Out, "Recording started successfully.") } else {
fmt.Fprintln(ctx.Out, "Recording is not in progress.")
} }
return nil return nil
} }
@@ -117,3 +156,30 @@ func (cmd *RecordResumeCmd) Run(ctx *context) error {
fmt.Fprintln(ctx.Out, "Recording resumed successfully.") fmt.Fprintln(ctx.Out, "Recording resumed successfully.")
return nil return nil
} }
// RecordDirectoryCmd sets the recording directory.
type RecordDirectoryCmd struct {
RecordDirectory string `arg:"" help:"Directory to save recordings." default:""`
}
// Run executes the command to set the recording directory.
func (cmd *RecordDirectoryCmd) Run(ctx *context) error {
if cmd.RecordDirectory == "" {
resp, err := ctx.Client.Config.GetRecordDirectory()
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "Current recording directory: %s\n", resp.RecordDirectory)
return nil
}
_, err := ctx.Client.Config.SetRecordDirectory(
config.NewSetRecordDirectoryParams().WithRecordDirectory(cmd.RecordDirectory),
)
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "Recording directory set to: %s\n", cmd.RecordDirectory)
return nil
}

135
record_test.go Normal file
View File

@@ -0,0 +1,135 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
)
func TestRecordStart(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &RecordStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get recording status: %v", err)
}
var active bool
if out.String() == "Recording is in progress.\n" {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdStart := &RecordStartCmd{}
err = cmdStart.Run(context)
if active {
if err == nil {
t.Fatalf("Expected error when starting recording while active, got nil")
}
if !strings.Contains(err.Error(), "recording is already in progress") {
t.Fatalf("Expected error message to contain 'recording is already in progress', got '%s'", err.Error())
}
return
}
if err != nil {
t.Fatalf("Failed to start recording: %v", err)
}
if out.String() != "Recording started successfully.\n" {
t.Fatalf("Expected output to contain 'Recording started successfully.', got '%s'", out.String())
}
time.Sleep(1 * time.Second) // Wait for the recording to start
}
func TestRecordStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &RecordStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get recording status: %v", err)
}
var active bool
if out.String() == "Recording is in progress.\n" {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdStop := &RecordStopCmd{}
err = cmdStop.Run(context)
if !active {
if err == nil {
t.Fatalf("Expected error when stopping recording while inactive, got nil")
}
if !strings.Contains(err.Error(), "recording is not in progress") {
t.Fatalf("Expected error message to contain 'recording is not in progress', got '%s'", err.Error())
}
return
}
if err != nil {
t.Fatalf("Failed to stop recording: %v", err)
}
if !strings.Contains(out.String(), "Recording stopped successfully. Output file: ") {
t.Fatalf("Expected output to contain 'Recording stopped successfully. Output file: ', got '%s'", out.String())
}
time.Sleep(1 * time.Second) // Wait for the recording to stop
}
func TestRecordToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &RecordStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get recording status: %v", err)
}
var active bool
if out.String() == "Recording is in progress.\n" {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdToggle := &RecordToggleCmd{}
err = cmdToggle.Run(context)
if err != nil {
t.Fatalf("Failed to toggle recording: %v", err)
}
time.Sleep(1 * time.Second) // Wait for a second to ensure toggle has taken effect
if active {
if out.String() != "Recording stopped successfully.\n" {
t.Fatalf("Expected output to be 'Recording stopped successfully.', got '%s'", out.String())
}
} else {
if out.String() != "Recording started successfully.\n" {
t.Fatalf("Expected output to be 'Recording started successfully.', got '%s'", out.String())
}
}
}

View File

@@ -8,6 +8,7 @@ import (
type ReplayBufferCmd struct { type ReplayBufferCmd struct {
Start ReplayBufferStartCmd `help:"Start replay buffer." cmd:"" aliases:"s"` Start ReplayBufferStartCmd `help:"Start replay buffer." cmd:"" aliases:"s"`
Stop ReplayBufferStopCmd `help:"Stop replay buffer." cmd:"" aliases:"st"` Stop ReplayBufferStopCmd `help:"Stop replay buffer." cmd:"" aliases:"st"`
Toggle ReplayBufferToggleCmd `help:"Toggle replay buffer." cmd:"" aliases:"tg"`
Status ReplayBufferStatusCmd `help:"Get replay buffer status." cmd:"" aliases:"ss"` Status ReplayBufferStatusCmd `help:"Get replay buffer status." cmd:"" aliases:"ss"`
Save ReplayBufferSaveCmd `help:"Save replay buffer." cmd:"" aliases:"sv"` Save ReplayBufferSaveCmd `help:"Save replay buffer." cmd:"" aliases:"sv"`
} }
@@ -18,7 +19,11 @@ type ReplayBufferStartCmd struct{} // size = 0x0
// Run executes the command to start the replay buffer. // Run executes the command to start the replay buffer.
func (cmd *ReplayBufferStartCmd) Run(ctx *context) error { func (cmd *ReplayBufferStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StartReplayBuffer() _, err := ctx.Client.Outputs.StartReplayBuffer()
return err if err != nil {
return fmt.Errorf("failed to start replay buffer: %w", err)
}
fmt.Fprintln(ctx.Out, "Replay buffer started.")
return nil
} }
// ReplayBufferStopCmd stops the replay buffer. // ReplayBufferStopCmd stops the replay buffer.
@@ -27,7 +32,29 @@ type ReplayBufferStopCmd struct{} // size = 0x0
// Run executes the command to stop the replay buffer. // Run executes the command to stop the replay buffer.
func (cmd *ReplayBufferStopCmd) Run(ctx *context) error { func (cmd *ReplayBufferStopCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StopReplayBuffer() _, err := ctx.Client.Outputs.StopReplayBuffer()
if err != nil {
return fmt.Errorf("failed to stop replay buffer: %w", err)
}
fmt.Fprintln(ctx.Out, "Replay buffer stopped.")
return nil
}
// ReplayBufferToggleCmd toggles the replay buffer state.
type ReplayBufferToggleCmd struct{} // size = 0x0
// Run executes the command to toggle the replay buffer.
func (cmd *ReplayBufferToggleCmd) Run(ctx *context) error {
status, err := ctx.Client.Outputs.ToggleReplayBuffer()
if err != nil {
return err return err
}
if status.OutputActive {
fmt.Fprintln(ctx.Out, "Replay buffer started.")
} else {
fmt.Fprintln(ctx.Out, "Replay buffer stopped.")
}
return nil
} }
// ReplayBufferStatusCmd retrieves the status of the replay buffer. // ReplayBufferStatusCmd retrieves the status of the replay buffer.

85
replaybuffer_test.go Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestReplayBufferStart(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &ReplayBufferStartCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to start replay buffer: %v", err)
}
if out.String() != "Replay buffer started.\n" {
t.Fatalf("Expected output to be 'Replay buffer started', got '%s'", out.String())
}
}
func TestReplayBufferStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &ReplayBufferStopCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to stop replay buffer: %v", err)
}
if out.String() != "Replay buffer stopped.\n" {
t.Fatalf("Expected output to be 'Replay buffer stopped.', got '%s'", out.String())
}
}
func TestReplayBufferToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &ReplayBufferStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get replay buffer status: %v", err)
}
var active bool
if strings.Contains(out.String(), "Replay buffer is active") {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdToggle := &ReplayBufferToggleCmd{}
err = cmdToggle.Run(context)
if err != nil {
t.Fatalf("Failed to toggle replay buffer: %v", err)
}
if active {
if out.String() != "Replay buffer stopped.\n" {
t.Fatalf("Expected output to be 'Replay buffer stopped.', got '%s'", out.String())
}
} else {
if out.String() != "Replay buffer started.\n" {
t.Fatalf("Expected output to be 'Replay buffer started.', got '%s'", out.String())
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"slices" "slices"
"github.com/andreykaipov/goobs/api/requests/scenes" "github.com/andreykaipov/goobs/api/requests/scenes"
"github.com/aquasecurity/table"
) )
// SceneCmd provides commands to manage scenes in OBS Studio. // SceneCmd provides commands to manage scenes in OBS Studio.
@@ -24,10 +25,16 @@ func (cmd *SceneListCmd) Run(ctx *context) error {
return err return err
} }
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignLeft, table.AlignLeft)
t.SetHeaders("Scene Name", "UUID")
slices.Reverse(scenes.Scenes) slices.Reverse(scenes.Scenes)
for _, scene := range scenes.Scenes { for _, scene := range scenes.Scenes {
fmt.Fprintln(ctx.Out, scene.SceneName) t.AddRow(scene.SceneName, scene.SceneUuid)
} }
t.Render()
return nil return nil
} }

58
scene_test.go Normal file
View File

@@ -0,0 +1,58 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestSceneList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &SceneListCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list scenes: %v", err)
}
if !strings.Contains(out.String(), "gobs-test") {
t.Fatalf("Expected output to contain 'gobs-test', got '%s'", out.String())
}
}
func TestSceneCurrent(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
// Set the current scene to "gobs-test"
cmdSwitch := &SceneSwitchCmd{
NewScene: "gobs-test",
}
err := cmdSwitch.Run(context)
if err != nil {
t.Fatalf("Failed to switch to scene: %v", err)
}
// Reset output buffer for the next command
out.Reset()
cmdCurrent := &SceneCurrentCmd{}
err = cmdCurrent.Run(context)
if err != nil {
t.Fatalf("Failed to get current scene: %v", err)
}
if out.String() != "gobs-test\n" {
t.Fatalf("Expected output to contain 'gobs-test', got '%s'", out.String())
}
}

View File

@@ -4,38 +4,44 @@ import (
"fmt" "fmt"
"github.com/andreykaipov/goobs/api/requests/config" "github.com/andreykaipov/goobs/api/requests/config"
"github.com/aquasecurity/table"
) )
// SceneCollectionCmd provides commands to manage scene collections in OBS Studio. // SceneCollectionCmd provides commands to manage scene collections in OBS Studio.
type SceneCollectionCmd struct { type SceneCollectionCmd struct {
List ListSceneCollectionCmd `help:"List scene collections." cmd:"" aliases:"ls"` List SceneCollectionListCmd `help:"List scene collections." cmd:"" aliases:"ls"`
Current CurrentSceneCollectionCmd `help:"Get current scene collection." cmd:"" aliases:"c"` Current SceneCollectionCurrentCmd `help:"Get current scene collection." cmd:"" aliases:"c"`
Switch SwitchSceneCollectionCmd `help:"Switch scene collection." cmd:"" aliases:"sw"` Switch SceneCollectionSwitchCmd `help:"Switch scene collection." cmd:"" aliases:"sw"`
Create CreateSceneCollectionCmd `help:"Create scene collection." cmd:"" aliases:"new"` Create SceneCollectionCreateCmd `help:"Create scene collection." cmd:"" aliases:"new"`
} }
// ListSceneCollectionCmd provides a command to list all scene collections. // SceneCollectionListCmd provides a command to list all scene collections.
type ListSceneCollectionCmd struct{} // size = 0x0 type SceneCollectionListCmd struct{} // size = 0x0
// Run executes the command to list all scene collections. // Run executes the command to list all scene collections.
func (cmd *ListSceneCollectionCmd) Run(ctx *context) error { func (cmd *SceneCollectionListCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList() collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil { if err != nil {
return fmt.Errorf("failed to get scene collection list: %w", err) return fmt.Errorf("failed to get scene collection list: %w", err)
} }
for _, collection := range collections.SceneCollections { t := table.New(ctx.Out)
fmt.Fprintln(ctx.Out, collection) t.SetPadding(3)
} t.SetAlignment(table.AlignLeft)
t.SetHeaders("Scene Collection Name")
for _, collection := range collections.SceneCollections {
t.AddRow(collection)
}
t.Render()
return nil return nil
} }
// CurrentSceneCollectionCmd provides a command to get the current scene collection. // SceneCollectionCurrentCmd provides a command to get the current scene collection.
type CurrentSceneCollectionCmd struct{} // size = 0x0 type SceneCollectionCurrentCmd struct{} // size = 0x0
// Run executes the command to get the current scene collection. // Run executes the command to get the current scene collection.
func (cmd *CurrentSceneCollectionCmd) Run(ctx *context) error { func (cmd *SceneCollectionCurrentCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList() collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil { if err != nil {
return fmt.Errorf("failed to get scene collection list: %w", err) return fmt.Errorf("failed to get scene collection list: %w", err)
@@ -45,13 +51,13 @@ func (cmd *CurrentSceneCollectionCmd) Run(ctx *context) error {
return nil return nil
} }
// SwitchSceneCollectionCmd provides a command to switch to a different scene collection. // SceneCollectionSwitchCmd provides a command to switch to a different scene collection.
type SwitchSceneCollectionCmd struct { type SceneCollectionSwitchCmd struct {
Name string `arg:"" help:"Name of the scene collection to switch to." required:""` Name string `arg:"" help:"Name of the scene collection to switch to." required:""`
} }
// Run executes the command to switch to a different scene collection. // Run executes the command to switch to a different scene collection.
func (cmd *SwitchSceneCollectionCmd) Run(ctx *context) error { func (cmd *SceneCollectionSwitchCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList() collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil { if err != nil {
return err return err
@@ -74,13 +80,13 @@ func (cmd *SwitchSceneCollectionCmd) Run(ctx *context) error {
return nil return nil
} }
// CreateSceneCollectionCmd provides a command to create a new scene collection. // SceneCollectionCreateCmd provides a command to create a new scene collection.
type CreateSceneCollectionCmd struct { type SceneCollectionCreateCmd struct {
Name string `arg:"" help:"Name of the scene collection to create." required:""` Name string `arg:"" help:"Name of the scene collection to create." required:""`
} }
// Run executes the command to create a new scene collection. // Run executes the command to create a new scene collection.
func (cmd *CreateSceneCollectionCmd) Run(ctx *context) error { func (cmd *SceneCollectionCreateCmd) Run(ctx *context) error {
_, err := ctx.Client.Config.CreateSceneCollection( _, err := ctx.Client.Config.CreateSceneCollection(
config.NewCreateSceneCollectionParams().WithSceneCollectionName(cmd.Name), config.NewCreateSceneCollectionParams().WithSceneCollectionName(cmd.Name),
) )

View File

@@ -2,9 +2,11 @@ package main
import ( import (
"fmt" "fmt"
"sort"
"github.com/andreykaipov/goobs" "github.com/andreykaipov/goobs"
"github.com/andreykaipov/goobs/api/requests/sceneitems" "github.com/andreykaipov/goobs/api/requests/sceneitems"
"github.com/aquasecurity/table"
) )
// SceneItemCmd provides commands to manage scene items in OBS Studio. // SceneItemCmd provides commands to manage scene items in OBS Studio.
@@ -19,37 +21,83 @@ type SceneItemCmd struct {
// SceneItemListCmd provides a command to list all scene items in a scene. // SceneItemListCmd provides a command to list all scene items in a scene.
type SceneItemListCmd struct { type SceneItemListCmd struct {
SceneName string `arg:"" help:"Scene name."` SceneName string `arg:"" help:"Name of the scene to list items from." default:""`
} }
// Run executes the command to list all scene items in a scene. // Run executes the command to list all scene items in a scene.
func (cmd *SceneItemListCmd) Run(ctx *context) error { func (cmd *SceneItemListCmd) Run(ctx *context) error {
if cmd.SceneName == "" {
currentScene, err := ctx.Client.Scenes.GetCurrentProgramScene()
if err != nil {
return fmt.Errorf("failed to get current program scene: %w", err)
}
cmd.SceneName = currentScene.SceneName
}
resp, err := ctx.Client.SceneItems.GetSceneItemList(sceneitems.NewGetSceneItemListParams(). resp, err := ctx.Client.SceneItems.GetSceneItemList(sceneitems.NewGetSceneItemListParams().
WithSceneName(cmd.SceneName)) WithSceneName(cmd.SceneName))
if err != nil { if err != nil {
return fmt.Errorf("failed to get scene item list: %w", err) return fmt.Errorf("failed to get scene item list: %w", err)
} }
for _, item := range resp.SceneItems {
fmt.Fprintf(ctx.Out, "Item ID: %d, Source Name: %s\n", item.SceneItemID, item.SourceName) if len(resp.SceneItems) == 0 {
fmt.Fprintf(ctx.Out, "No scene items found in scene '%s'.\n", cmd.SceneName)
return nil
} }
t := table.New(ctx.Out)
t.SetPadding(3)
t.SetAlignment(table.AlignCenter, table.AlignLeft, table.AlignCenter, table.AlignCenter)
t.SetHeaders("Item ID", "Item Name", "In Group", "Enabled")
sort.Slice(resp.SceneItems, func(i, j int) bool {
return resp.SceneItems[i].SceneItemID < resp.SceneItems[j].SceneItemID
})
for _, item := range resp.SceneItems {
if item.IsGroup {
resp, err := ctx.Client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams().
WithSceneName(item.SourceName))
if err != nil {
return fmt.Errorf("failed to get group scene item list for '%s': %w", item.SourceName, err)
}
sort.Slice(resp.SceneItems, func(i, j int) bool {
return resp.SceneItems[i].SceneItemID < resp.SceneItems[j].SceneItemID
})
for _, groupItem := range resp.SceneItems {
t.AddRow(
fmt.Sprintf("%d", groupItem.SceneItemID),
groupItem.SourceName,
item.SourceName,
fmt.Sprintf("%t", item.SceneItemEnabled && groupItem.SceneItemEnabled),
)
}
} else {
t.AddRow(fmt.Sprintf("%d", item.SceneItemID), item.SourceName, "", fmt.Sprintf("%t", item.SceneItemEnabled))
}
}
t.Render()
return nil return nil
} }
// getSceneNameAndItemID retrieves the scene name and item ID for a given item in a scene or group.
func getSceneNameAndItemID( func getSceneNameAndItemID(
client *goobs.Client, client *goobs.Client,
sceneName string, sceneName string,
itemName string, itemName string,
parent string, group string,
) (string, int, error) { ) (string, int, error) {
if parent != "" { if group != "" {
resp, err := client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams(). resp, err := client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams().
WithSceneName(parent)) WithSceneName(group))
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
for _, item := range resp.SceneItems { for _, item := range resp.SceneItems {
if item.SourceName == itemName { if item.SourceName == itemName {
return parent, int(item.SceneItemID), nil return group, int(item.SceneItemID), nil
} }
} }
return "", 0, fmt.Errorf("item '%s' not found in scene '%s'", itemName, sceneName) return "", 0, fmt.Errorf("item '%s' not found in scene '%s'", itemName, sceneName)
@@ -66,7 +114,7 @@ func getSceneNameAndItemID(
// SceneItemShowCmd provides a command to show a scene item. // SceneItemShowCmd provides a command to show a scene item.
type SceneItemShowCmd struct { type SceneItemShowCmd struct {
Parent string `flag:"" help:"Parent group name."` Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."` SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."` ItemName string `arg:"" help:"Item name."`
@@ -74,7 +122,7 @@ type SceneItemShowCmd struct {
// Run executes the command to show a scene item. // Run executes the command to show a scene item.
func (cmd *SceneItemShowCmd) Run(ctx *context) error { func (cmd *SceneItemShowCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent) sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil { if err != nil {
return err return err
} }
@@ -87,8 +135,8 @@ func (cmd *SceneItemShowCmd) Run(ctx *context) error {
return err return err
} }
if cmd.Parent != "" { if cmd.Group != "" {
fmt.Fprintf(ctx.Out, "Scene item '%s' in group '%s' is now visible.\n", cmd.ItemName, cmd.Parent) fmt.Fprintf(ctx.Out, "Scene item '%s' in group '%s' is now visible.\n", cmd.ItemName, cmd.Group)
} else { } else {
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is now visible.\n", cmd.ItemName, cmd.SceneName) fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is now visible.\n", cmd.ItemName, cmd.SceneName)
} }
@@ -98,7 +146,7 @@ func (cmd *SceneItemShowCmd) Run(ctx *context) error {
// SceneItemHideCmd provides a command to hide a scene item. // SceneItemHideCmd provides a command to hide a scene item.
type SceneItemHideCmd struct { type SceneItemHideCmd struct {
Parent string `flag:"" help:"Parent group name."` Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."` SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."` ItemName string `arg:"" help:"Item name."`
@@ -106,7 +154,7 @@ type SceneItemHideCmd struct {
// Run executes the command to hide a scene item. // Run executes the command to hide a scene item.
func (cmd *SceneItemHideCmd) Run(ctx *context) error { func (cmd *SceneItemHideCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent) sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil { if err != nil {
return err return err
} }
@@ -119,8 +167,8 @@ func (cmd *SceneItemHideCmd) Run(ctx *context) error {
return err return err
} }
if cmd.Parent != "" { if cmd.Group != "" {
fmt.Fprintf(ctx.Out, "Scene item '%s' in group '%s' is now hidden.\n", cmd.ItemName, cmd.Parent) fmt.Fprintf(ctx.Out, "Scene item '%s' in group '%s' is now hidden.\n", cmd.ItemName, cmd.Group)
} else { } else {
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is now hidden.\n", cmd.ItemName, cmd.SceneName) fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is now hidden.\n", cmd.ItemName, cmd.SceneName)
} }
@@ -141,7 +189,7 @@ func getItemEnabled(client *goobs.Client, sceneName string, itemID int) (bool, e
// SceneItemToggleCmd provides a command to toggle the visibility of a scene item. // SceneItemToggleCmd provides a command to toggle the visibility of a scene item.
type SceneItemToggleCmd struct { type SceneItemToggleCmd struct {
Parent string `flag:"" help:"Parent group name."` Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."` SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."` ItemName string `arg:"" help:"Item name."`
@@ -149,7 +197,7 @@ type SceneItemToggleCmd struct {
// Run executes the command to toggle the visibility of a scene item. // Run executes the command to toggle the visibility of a scene item.
func (cmd *SceneItemToggleCmd) Run(ctx *context) error { func (cmd *SceneItemToggleCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent) sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil { if err != nil {
return err return err
} }
@@ -178,7 +226,7 @@ func (cmd *SceneItemToggleCmd) Run(ctx *context) error {
// SceneItemVisibleCmd provides a command to check the visibility of a scene item. // SceneItemVisibleCmd provides a command to check the visibility of a scene item.
type SceneItemVisibleCmd struct { type SceneItemVisibleCmd struct {
Parent string `flag:"" help:"Parent group name."` Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."` SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."` ItemName string `arg:"" help:"Item name."`
@@ -186,7 +234,7 @@ type SceneItemVisibleCmd struct {
// Run executes the command to check the visibility of a scene item. // Run executes the command to check the visibility of a scene item.
func (cmd *SceneItemVisibleCmd) Run(ctx *context) error { func (cmd *SceneItemVisibleCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent) sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil { if err != nil {
return err return err
} }
@@ -209,7 +257,7 @@ type SceneItemTransformCmd struct {
SceneName string `arg:"" help:"Scene name."` SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."` ItemName string `arg:"" help:"Item name."`
Parent string `flag:"" help:"Parent group name."` Group string `flag:"" help:"Parent group name."`
Alignment float64 `flag:"" help:"Alignment of the scene item."` Alignment float64 `flag:"" help:"Alignment of the scene item."`
BoundsAlignment float64 `flag:"" help:"Bounds alignment of the scene item."` BoundsAlignment float64 `flag:"" help:"Bounds alignment of the scene item."`
@@ -230,7 +278,7 @@ type SceneItemTransformCmd struct {
// Run executes the command to transform a scene item. // Run executes the command to transform a scene item.
func (cmd *SceneItemTransformCmd) Run(ctx *context) error { func (cmd *SceneItemTransformCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent) sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil { if err != nil {
return err return err
} }
@@ -302,8 +350,8 @@ func (cmd *SceneItemTransformCmd) Run(ctx *context) error {
return err return err
} }
if cmd.Parent != "" { if cmd.Group != "" {
fmt.Fprintf(ctx.Out, "Scene item '%s' in group '%s' transformed.\n", cmd.ItemName, cmd.Parent) fmt.Fprintf(ctx.Out, "Scene item '%s' in group '%s' transformed.\n", cmd.ItemName, cmd.Group)
} else { } else {
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' transformed.\n", cmd.ItemName, cmd.SceneName) fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' transformed.\n", cmd.ItemName, cmd.SceneName)
} }

32
sceneitem_test.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestSceneItemList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &SceneItemListCmd{
SceneName: "gobs-test",
}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list scene items: %v", err)
}
if !strings.Contains(out.String(), "gobs-test-input") {
t.Fatalf("Expected output to contain 'gobs-test-input', got '%s'", out.String())
}
if !strings.Contains(out.String(), "gobs-test-input-2") {
t.Fatalf("Expected output to contain 'gobs-test-input-2', got '%s'", out.String())
}
}

View File

@@ -17,10 +17,21 @@ type StreamStartCmd struct{} // size = 0x0
// Run executes the command to start streaming. // Run executes the command to start streaming.
func (cmd *StreamStartCmd) Run(ctx *context) error { func (cmd *StreamStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Stream.StartStream() // Check if the stream is already active
status, err := ctx.Client.Stream.GetStreamStatus()
if err != nil { if err != nil {
return err return err
} }
if status.OutputActive {
return fmt.Errorf("stream is already in progress")
}
_, err = ctx.Client.Stream.StartStream()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Stream started successfully.")
return nil return nil
} }
@@ -29,10 +40,21 @@ type StreamStopCmd struct{} // size = 0x0
// Run executes the command to stop streaming. // Run executes the command to stop streaming.
func (cmd *StreamStopCmd) Run(ctx *context) error { func (cmd *StreamStopCmd) Run(ctx *context) error {
_, err := ctx.Client.Stream.StopStream() // Check if the stream is already inactive
status, err := ctx.Client.Stream.GetStreamStatus()
if err != nil { if err != nil {
return err return err
} }
if !status.OutputActive {
return fmt.Errorf("stream is not in progress")
}
_, err = ctx.Client.Stream.StopStream()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Stream stopped successfully.")
return nil return nil
} }
@@ -41,19 +63,15 @@ type StreamToggleCmd struct{} // size = 0x0
// Run executes the command to toggle streaming. // Run executes the command to toggle streaming.
func (cmd *StreamToggleCmd) Run(ctx *context) error { func (cmd *StreamToggleCmd) Run(ctx *context) error {
status, err := ctx.Client.Stream.GetStreamStatus() status, err := ctx.Client.Stream.ToggleStream()
if err != nil { if err != nil {
return err return err
} }
if status.OutputActive { if status.OutputActive {
_, err = ctx.Client.Stream.StopStream() fmt.Fprintln(ctx.Out, "Stream started successfully.")
fmt.Fprintf(ctx.Out, "Stopping stream...\n")
} else { } else {
_, err = ctx.Client.Stream.StartStream() fmt.Fprintln(ctx.Out, "Stream stopped successfully.")
fmt.Fprintf(ctx.Out, "Starting stream...\n")
}
if err != nil {
return err
} }
return nil return nil
} }

132
stream_test.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
)
func TestStreamStart(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &StreamStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get stream status: %v", err)
}
var active bool
if strings.Contains(out.String(), "Output active: true") {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdStart := &StreamStartCmd{}
err = cmdStart.Run(context)
if active {
if err == nil {
t.Fatalf("Expected error when starting stream while active, got nil")
}
if !strings.Contains(err.Error(), "stream is already in progress") {
t.Fatalf("Expected error message to contain 'stream is already in progress', got '%s'", err.Error())
}
return
}
if err != nil {
t.Fatalf("Failed to start stream: %v", err)
}
if out.String() != "Stream started successfully.\n" {
t.Fatalf("Expected output to contain 'Stream started successfully.', got '%s'", out.String())
}
time.Sleep(2 * time.Second) // Wait for the stream to start
}
func TestStreamStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &StreamStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get stream status: %v", err)
}
var active bool
if strings.Contains(out.String(), "Output active: true") {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdStop := &StreamStopCmd{}
err = cmdStop.Run(context)
if !active {
if err == nil {
t.Fatalf("Expected error when stopping stream while inactive, got nil")
}
if !strings.Contains(err.Error(), "stream is not in progress") {
t.Fatalf("Expected error message to contain 'stream is not in progress', got '%s'", err.Error())
}
return
}
if err != nil {
t.Fatalf("Failed to stop stream: %v", err)
}
if out.String() != "Stream stopped successfully.\n" {
t.Fatalf("Expected output to contain 'Stream stopped successfully.', got '%s'", out.String())
}
time.Sleep(2 * time.Second) // Wait for the stream to stop
}
func TestStreamToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdStatus := &StreamStatusCmd{}
err := cmdStatus.Run(context)
if err != nil {
t.Fatalf("Failed to get stream status: %v", err)
}
var active bool
if strings.Contains(out.String(), "Output active: true") {
active = true
}
// Reset output buffer for the next command
out.Reset()
cmdToggle := &StreamToggleCmd{}
err = cmdToggle.Run(context)
if err != nil {
t.Fatalf("Failed to toggle stream: %v", err)
}
if active {
if out.String() != "Stream stopped successfully.\n" {
t.Fatalf("Expected 'Stream stopped successfully.', got: %s", out.String())
}
} else {
if out.String() != "Stream started successfully.\n" {
t.Fatalf("Expected 'Stream started successfully.', got: %s", out.String())
}
}
time.Sleep(2 * time.Second) // Wait for the stream to toggle
}

View File

@@ -23,6 +23,8 @@ func (cmd *StudioModeEnableCmd) Run(ctx *context) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to enable studio mode: %w", err) return fmt.Errorf("failed to enable studio mode: %w", err)
} }
fmt.Fprintln(ctx.Out, "Studio mode is now enabled")
return nil return nil
} }
@@ -35,6 +37,8 @@ func (cmd *StudioModeDisableCmd) Run(ctx *context) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to disable studio mode: %w", err) return fmt.Errorf("failed to disable studio mode: %w", err)
} }
fmt.Fprintln(ctx.Out, "Studio mode is now disabled")
return nil return nil
} }

68
studiomode_test.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"bytes"
"testing"
)
func TestStudioModeEnable(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdEnable := &StudioModeEnableCmd{}
err := cmdEnable.Run(context)
if err != nil {
t.Fatalf("failed to enable studio mode: %v", err)
}
if out.String() != "Studio mode is now enabled\n" {
t.Fatalf("expected 'Studio mode is now enabled', got: %s", out.String())
}
// Reset output buffer for the next command
out.Reset()
cmdStatus := &StudioModeStatusCmd{}
err = cmdStatus.Run(context)
if err != nil {
t.Fatalf("failed to get studio mode status: %v", err)
}
if out.String() != "Studio mode is enabled\n" {
t.Fatalf("expected 'Studio mode is enabled', got: %s", out.String())
}
}
func TestStudioModeDisable(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmdDisable := &StudioModeDisableCmd{}
err := cmdDisable.Run(context)
if err != nil {
t.Fatalf("failed to disable studio mode: %v", err)
}
if out.String() != "Studio mode is now disabled\n" {
t.Fatalf("expected 'Studio mode is now disabled', got: %s", out.String())
}
// Reset output buffer for the next command
out.Reset()
cmdStatus := &StudioModeStatusCmd{}
err = cmdStatus.Run(context)
if err != nil {
t.Fatalf("failed to get studio mode status: %v", err)
}
if out.String() != "Studio mode is disabled\n" {
t.Fatalf("expected 'Studio mode is disabled', got: %s", out.String())
}
}

22
util.go Normal file
View File

@@ -0,0 +1,22 @@
// Package util provides utility functions for the application.
package main
import "strings"
func snakeCaseToTitleCase(snake string) string {
words := strings.Split(snake, "_")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + word[1:]
}
}
return strings.Join(words, " ")
}
func getEnabledMark(enabled bool) string {
if enabled {
return "\u2713" // green check mark
}
return "\u274c" // red cross mark
}

20
util_test.go Normal file
View File

@@ -0,0 +1,20 @@
package main
import "testing"
func TestSnakeCaseToTitleCase(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello_world", "Hello World"},
{"snake_case_to_title_case", "Snake Case To Title Case"},
}
for _, test := range tests {
result := snakeCaseToTitleCase(test.input)
if result != test.expected {
t.Errorf("Expected '%s' but got '%s'", test.expected, result)
}
}
}

View File

@@ -2,13 +2,43 @@ package main
import ( import (
"fmt" "fmt"
"runtime/debug"
"strings"
"github.com/alecthomas/kong"
) )
// VersionCmd handles the version command. var version string
type VersionCmd struct{} // size = 0x0
// VersionFlag is a custom flag type for displaying version information.
type VersionFlag string
// Decode implements the kong.Flag interface for VersionFlag.
func (v VersionFlag) Decode(_ *kong.DecodeContext) error { return nil }
// IsBool implements the kong.Flag interface for VersionFlag.
func (v VersionFlag) IsBool() bool { return true }
// BeforeApply implements the kong.Flag interface for VersionFlag.
func (v VersionFlag) BeforeApply(app *kong.Kong, _ kong.Vars) error { // nolint: unparam
if version == "" {
info, ok := debug.ReadBuildInfo()
if !ok {
return fmt.Errorf("failed to read build info")
}
version = strings.Split(info.Main.Version, "-")[0]
}
fmt.Printf("gobs-cli version: %s\n", version)
app.Exit(0) // Exit the application after printing the version
return nil
}
// ObsVersionCmd handles the version command.
type ObsVersionCmd struct{} // size = 0x0
// Run executes the command to get the OBS client version. // Run executes the command to get the OBS client version.
func (cmd *VersionCmd) Run(ctx *context) error { func (cmd *ObsVersionCmd) Run(ctx *context) error {
version, err := ctx.Client.General.GetVersion() version, err := ctx.Client.General.GetVersion()
if err != nil { if err != nil {
return err return err

30
version_test.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestVersion(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := &context{
Client: client,
Out: &out,
}
cmd := &ObsVersionCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to get version: %v", err)
}
if !strings.Contains(out.String(), "OBS Client Version:") {
t.Fatalf("Expected output to contain 'OBS Client Version:', got '%s'", out.String())
}
if !strings.Contains(out.String(), "with Websocket Version:") {
t.Fatalf("Expected output to contain 'with Websocket Version:', got '%s'", out.String())
}
}

View File

@@ -6,17 +6,17 @@ import (
// VirtualCamCmd handles the virtual camera commands. // VirtualCamCmd handles the virtual camera commands.
type VirtualCamCmd struct { type VirtualCamCmd struct {
Start StartVirtualCamCmd `help:"Start virtual camera." cmd:"" aliases:"s"` Start VirtualCamStartCmd `help:"Start virtual camera." cmd:"" aliases:"s"`
Stop StopVirtualCamCmd `help:"Stop virtual camera." cmd:"" aliases:"st"` Stop VirtualCamStopCmd `help:"Stop virtual camera." cmd:"" aliases:"st"`
Toggle ToggleVirtualCamCmd `help:"Toggle virtual camera." cmd:"" aliases:"tg"` Toggle VirtualCamToggleCmd `help:"Toggle virtual camera." cmd:"" aliases:"tg"`
Status StatusVirtualCamCmd `help:"Get virtual camera status." cmd:"" aliases:"ss"` Status VirtualCamStatusCmd `help:"Get virtual camera status." cmd:"" aliases:"ss"`
} }
// StartVirtualCamCmd starts the virtual camera. // VirtualCamStartCmd starts the virtual camera.
type StartVirtualCamCmd struct{} // size = 0x0 type VirtualCamStartCmd struct{} // size = 0x0
// Run executes the command to start the virtual camera. // Run executes the command to start the virtual camera.
func (c *StartVirtualCamCmd) Run(ctx *context) error { func (c *VirtualCamStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StartVirtualCam() _, err := ctx.Client.Outputs.StartVirtualCam()
if err != nil { if err != nil {
return fmt.Errorf("failed to start virtual camera: %w", err) return fmt.Errorf("failed to start virtual camera: %w", err)
@@ -25,11 +25,11 @@ func (c *StartVirtualCamCmd) Run(ctx *context) error {
return nil return nil
} }
// StopVirtualCamCmd stops the virtual camera. // VirtualCamStopCmd stops the virtual camera.
type StopVirtualCamCmd struct{} // size = 0x0 type VirtualCamStopCmd struct{} // size = 0x0
// Run executes the command to stop the virtual camera. // Run executes the command to stop the virtual camera.
func (c *StopVirtualCamCmd) Run(ctx *context) error { func (c *VirtualCamStopCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StopVirtualCam() _, err := ctx.Client.Outputs.StopVirtualCam()
if err != nil { if err != nil {
return fmt.Errorf("failed to stop virtual camera: %w", err) return fmt.Errorf("failed to stop virtual camera: %w", err)
@@ -38,23 +38,29 @@ func (c *StopVirtualCamCmd) Run(ctx *context) error {
return nil return nil
} }
// ToggleVirtualCamCmd toggles the virtual camera. // VirtualCamToggleCmd toggles the virtual camera.
type ToggleVirtualCamCmd struct{} // size = 0x0 type VirtualCamToggleCmd struct{} // size = 0x0
// Run executes the command to toggle the virtual camera. // Run executes the command to toggle the virtual camera.
func (c *ToggleVirtualCamCmd) Run(ctx *context) error { func (c *VirtualCamToggleCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.ToggleVirtualCam() resp, err := ctx.Client.Outputs.ToggleVirtualCam()
if err != nil { if err != nil {
return fmt.Errorf("failed to toggle virtual camera: %w", err) return fmt.Errorf("failed to toggle virtual camera: %w", err)
} }
if resp.OutputActive {
fmt.Fprintln(ctx.Out, "Virtual camera is now active.")
} else {
fmt.Fprintln(ctx.Out, "Virtual camera is now inactive.")
}
return nil return nil
} }
// StatusVirtualCamCmd retrieves the status of the virtual camera. // VirtualCamStatusCmd retrieves the status of the virtual camera.
type StatusVirtualCamCmd struct{} // size = 0x0 type VirtualCamStatusCmd struct{} // size = 0x0
// Run executes the command to get the status of the virtual camera. // Run executes the command to get the status of the virtual camera.
func (c *StatusVirtualCamCmd) Run(ctx *context) error { func (c *VirtualCamStatusCmd) Run(ctx *context) error {
status, err := ctx.Client.Outputs.GetVirtualCamStatus() status, err := ctx.Client.Outputs.GetVirtualCamStatus()
if err != nil { if err != nil {
return fmt.Errorf("failed to get virtual camera status: %w", err) return fmt.Errorf("failed to get virtual camera status: %w", err)