Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

44 changed files with 220 additions and 3344 deletions

9
.gitignore vendored
View File

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

View File

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

View File

@ -5,157 +5,6 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# [0.14.1] - 2025-07-14
### Added
- text command group, see [TextCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#textcmd)
# [0.13.3] - 2025-06-27
### Changed
- usage is now printed on errors.
- help is printed in compact mode. This should make it easier to page through help on the root command.
### Fixed
- Item ID alignment in sceneitem list table.
# [0.13.0] - 2025-06-23
### Added
- record split and record chapter commands, see [RecordCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#recordcmd)
- As of OBS 30.2.0, the only file format supporting *record chapter* is Hybrid MP4.
# [0.12.1] - 2025-06-21
### Added
- Various colouring styles, see [Style](https://github.com/onyx-and-iris/gobs-cli/tree/main?tab=readme-ov-file#style)
- colouring is applied to list tables as well as highlighted information in stdout/stderr output.
- table border styling may be optionally disabled with the --no-border flag.
### Changed
- if an itemName is passed to a sceneitem command that's in a group, without the --group flag, a friendlier error message is displayed.
- it will suggest using *gobs-cli si ls* to list sources in the scene.
- if an invalid --monitor-index is passed to projector open a friendlier error message is displayed.
- it will suggest using *gobs-cli prj ls-m* to list available monitors.
# [0.11.0] - 2025-06-20
### Added
- input list, scene list and sceneitem list now accept --uuid flag.
- Active column added to scene list table.
### Changed
- scene list no longer prints the UUIDs by default, enable it with the --uuid flag.
# [0.10.3] - 2025-06-07
### Added
- filter list:
- --ffmpeg, --vlc flags
- Muted column to list table
# [0.10.2] - 2025-06-04
### Added
- screenshot save command, see [ScreenshotCmd](https://github.com/onyx-and-iris/gobs-cli?tab=readme-ov-file#screenshotcmd)
### Fixed
- filter list:
- sourceName arg now defaults to current scene.
- defaults are printed for any unmodified values.
- sceneitem list:
- prints enabled mark instead of true/false
# [0.9.0] - 2025-06-02
### 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] - 2025-05-29
### 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
### Added

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Onyx and Iris
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

328
README.md
View File

@ -4,100 +4,40 @@ A command line interface for OBS Websocket v5
For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
-----
## Table of Contents
- [Installation](#installation)
- [Configuration](#configuration)
- [Style](#style)
- [Commands](#commands)
- [License](#license)
## Installation
```console
go install github.com/onyx-and-iris/gobs-cli@latest
```
## Configuration
#### Flags
- --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:
Pass `--host`, `--port` and `--password` as flags to the root command, for example:
```console
gobs-cli --host localhost --port 4455 --password 'websocket password' --help
gobs-cli --host=localhost --port=4455 --password=<websocket password> --help
```
#### Environment Variables
Store and load environment variables from:
Load connection details from your environment:
- A `.env` file in the cwd
- $XDG_CONFIG_HOME / gobs-cli / config.env (see [os.UserConfigDir][userconfigdir])
```bash
#!/usr/bin/env bash
```env
OBS_HOST=localhost
OBS_PORT=4455
OBS_PASSWORD=<websocket password>
OBS_TIMEOUT=5
```
## Style
Styling is opt-in, by default you will get a colourless output:
![colourless](./img/colourless.png)
You may enable styling with the --style/-s flag:
```console
gobs-cli --style="red" sceneitem list
```
Available styles: _red, magenta, purple, blue, cyan, green, yellow, orange, white, grey, navy, black_
![coloured](./img/coloured-border.png)
Optionally you may disable border colouring with the --no-border flag:
![coloured-no-border](./img/coloured-no-border.png)
```console
gobs-cli --style="red" --no-border sceneitem list
```
Or with environment variables:
```env
GOBS_STYLE=red
GOBS_STYLE_NO_BORDER=true
export OBS_HOST=localhost
export OBS_PORT=4455
export OBS_PASSWORD=<websocket password>
export OBS_TIMEOUT=5
```
## Commands
### ObsVersionCmd
- Print OBS client and websocket version.
### VersionCmd
```console
gobs-cli obs-version
gobs-cli version
```
### SceneCmd
- list: List all scenes.
- flags:
*optional*
- --UUID: Display UUIDs of scenes.
```console
gobs-cli scene list
@ -131,18 +71,9 @@ gobs-cli scene switch --preview LIVE
### SceneItemCmd
- list: List all scene items.
- flags:
*optional*
- --UUID: Display UUIDs of scene items.
*optional*
- args: SceneName
- defaults to current scene
```console
gobs-cli sceneitem list
gobs-cli sceneitem list LIVE
```
@ -150,7 +81,7 @@ gobs-cli sceneitem list LIVE
- flags:
*optional*
- --group: Parent group name.
- --parent: Parent group name.
- args: SceneName ItemName
```console
@ -161,7 +92,7 @@ gobs-cli sceneitem show START "Colour Source"
- flags:
*optional*
- --group: Parent group name.
- --parent: Parent group name.
- args: SceneName ItemName
```console
@ -172,65 +103,30 @@ gobs-cli sceneitem hide START "Colour Source"
- flags:
*optional*
- --group: Parent group name.
- --parent: Parent group name.
- args: SceneName ItemName
```console
gobs-cli sceneitem toggle --group=test_group START "Colour Source 3"
gobs-cli sceneitem toggle --parent=test_group START "Colour Source 3"
```
- visible: Get scene item visibility.
- flags:
*optional*
- --group: Parent group name.
- --parent: Parent group name.
- args: SceneName ItemName
```console
gobs-cli sceneitem visible --group=test_group START "Colour Source 4"
```
- transform: Transform scene item.
- flags:
*optional*
- --group: Parent group name.
- --alignment: Alignment of the scene item.
- --bounds-alignment: Bounds alignment of the scene item.
- --bounds-height: Bounds height of the scene item.
- --bounds-type: Bounds type of the scene item.
- --bounds-width: Bounds width of the scene item.
- --crop-to-bounds: Whether to crop the scene item to bounds.
- --crop-bottom: Crop bottom value of the scene item.
- --crop-left: Crop left value of the scene item.
- --crop-right: Crop right value of the scene item.
- --crop-top: Crop top value of the scene item.
- --position-x: X position of the scene item.
- --position-y: Y position of the scene item.
- --rotation: Rotation of the scene item.
- --scale-x: X scale of the scene item.
- --scale-y: Y scale of the scene item.
- args: SceneName ItemName
```console
gobs-cli sceneitem transform \
--rotation=5 \
--position-x=250.8 \
Scene "Colour Source 3"
gobs-cli sceneitem visible --parent=test_group START "Colour Source 4"
```
### GroupCmd
- list: List all groups.
*optional*
- args: SceneName
- defaults to current scene
```console
gobs-cli group list
gobs-cli group list START
```
@ -271,9 +167,6 @@ gobs-cli group status START "test_group"
- --input: List all inputs.
- --output: List all outputs.
- --colour: List all colour sources.
- --ffmpeg: List all ffmpeg sources.
- --vlc: List all VLC sources.
- --uuid: Display UUIDs of inputs.
```console
gobs-cli input list
@ -302,22 +195,6 @@ gobs-cli input unmute "Mic/Aux"
gobs-cli input toggle "Mic/Aux"
```
### TextCmd
- current: Display current text for a text input.
- args: InputName
```console
gobs-cli text current "My Text Input"
```
- update: Update the text of a text input.
- args: InputName NewText
```console
gobs-cli text update "My Text Input" "hi OBS!"
```
### RecordCmd
- start: Start recording.
@ -356,34 +233,6 @@ gobs-cli record pause
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"
```
- split: Split recording.
```console
gobs-cli record split
```
- chapter: Create a chapter in the recording.
*optional*
- arg: ChapterName
```console
gobs-cli record chapter "Chapter Name"
```
### StreamCmd
- start: Start streaming.
@ -424,7 +273,7 @@ gobs-cli scenecollection list
gobs-cli scenecollection current
```
- switch: Switch scene collection.
- switch: "Switch scene collection.
- args: Name
```console
@ -456,21 +305,21 @@ gobs-cli profile current
- args: Name
```console
gobs-cli profile switch test-profile
gobs-cli profile switch test-collection
```
- create: Create profile.
- args: Name
```console
gobs-cli profile create test-profile
gobs-cli profile create test-collection
```
- remove: Remove profile.
- args: Name
```console
gobs-cli profile remove test-profile
gobs-cli profile create test-collection
```
### ReplayBufferCmd
@ -487,12 +336,6 @@ gobs-cli replaybuffer start
gobs-cli replaybuffer stop
```
- toggle: Toggle replay buffer.
```console
gobs-cli replaybuffer toggle
```
- status: Get replay buffer status.
```console
@ -556,132 +399,3 @@ gobs-cli virtualcam toggle
```console
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.
*optional*
- args: SourceName
- defaults to current scene
```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: SourceName
- 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"
```
### ScreenshotCmd
- save: Take a screenshot and save it to a file.
- flags:
*optional*
- --width:
- defaults to 1920
- --height:
- defaults to 1080
- --quality:
- defaults to -1
- args: SourceName FilePath
```console
gobs-cli screenshot save --width=2560 --height=1440 "Scene" "C:\Users\me\Videos\screenshot.png"
```
## License
`gobs-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
[userconfigdir]: https://pkg.go.dev/os#UserConfigDir
[obs-keyids]: https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h
[no-colour]: https://no-color.org/

View File

@ -1,17 +0,0 @@
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,14 +1,9 @@
version: '3'
includes:
man: Taskfile.man.yaml
vars:
PROGRAM: gobs-cli
SHELL: '{{if eq .OS "Windows_NT"}}powershell{{end}}'
BIN_DIR: bin
VERSION:
sh: 'git describe --tags $(git rev-list --tags --max-count=1)'
tasks:
default:
@ -22,7 +17,6 @@ tasks:
cmds:
- task: build-windows
- task: build-linux
- task: build-macos
vet:
desc: Vet the code
@ -38,19 +32,13 @@ tasks:
build-windows:
desc: Build the gobs-cli project for Windows
cmds:
- GOOS=windows GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe
- GOOS=windows GOARCH=amd64 go build -o {{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe
internal: true
build-linux:
desc: Build the gobs-cli project for Linux
cmds:
- GOOS=linux GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64
internal: true
build-macos:
desc: Build the gobs-cli project for macOS
cmds:
- GOOS=darwin GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_darwin_amd64
- GOOS=linux GOARCH=amd64 go build -o {{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64
internal: true
test:

218
filter.go
View File

@ -1,218 +0,0 @@
package main
import (
"fmt"
"maps"
"sort"
"strings"
"github.com/andreykaipov/goobs/api/requests/filters"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/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." default:""`
}
// Run executes the command to list all filters in a scene.
// nolint: misspell
func (cmd *FilterListCmd) 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
}
sourceFilters, err := ctx.Client.Filters.GetSourceFilterList(
filters.NewGetSourceFilterListParams().WithSourceName(cmd.SourceName),
)
if err != nil {
return err
}
if len(sourceFilters.Filters) == 0 {
fmt.Fprintf(ctx.Out, "No filters found for source %s.\n", ctx.Style.Highlight(cmd.SourceName))
return nil
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border)).
Headers("Filter Name", "Kind", "Enabled", "Settings").
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Left)
case 1:
style = style.Align(lipgloss.Left)
case 2:
style = style.Align(lipgloss.Center)
case 3:
style = style.Align(lipgloss.Left)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
for _, filter := range sourceFilters.Filters {
defaultSettings, err := ctx.Client.Filters.GetSourceFilterDefaultSettings(
filters.NewGetSourceFilterDefaultSettingsParams().
WithFilterKind(filter.FilterKind),
)
if err != nil {
return fmt.Errorf("failed to get default settings for filter %s: %w",
ctx.Style.Error(filter.FilterName), err)
}
maps.Insert(defaultSettings.DefaultFilterSettings, maps.All(filter.FilterSettings))
var lines []string
for k, v := range defaultSettings.DefaultFilterSettings {
lines = append(lines, fmt.Sprintf("%s: %v", snakeCaseToTitleCase(k), v))
}
sort.Slice(lines, func(i, j int) bool {
return strings.ToLower(lines[i]) < strings.ToLower(lines[j])
})
t.Row(
filter.FilterName,
snakeCaseToTitleCase(filter.FilterKind),
getEnabledMark(filter.FilterEnabled),
strings.Join(lines, "\n"),
)
}
fmt.Fprintln(ctx.Out, 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",
ctx.Style.Error(cmd.FilterName), ctx.Style.Error(cmd.SourceName), err)
}
fmt.Fprintf(ctx.Out, "Filter %s enabled on source %s.\n",
ctx.Style.Highlight(cmd.FilterName), ctx.Style.Highlight(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",
ctx.Style.Error(cmd.FilterName), ctx.Style.Error(cmd.SourceName), err)
}
fmt.Fprintf(ctx.Out, "Filter %s disabled on source %s.\n",
ctx.Style.Highlight(cmd.FilterName), ctx.Style.Highlight(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",
ctx.Style.Error(cmd.FilterName), ctx.Style.Error(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",
ctx.Style.Error(cmd.FilterName), ctx.Style.Error(cmd.SourceName), err)
}
if newStatus {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is now enabled.\n",
ctx.Style.Highlight(cmd.FilterName), ctx.Style.Highlight(cmd.SourceName))
} else {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is now disabled.\n",
ctx.Style.Highlight(cmd.FilterName), ctx.Style.Highlight(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",
ctx.Style.Error(cmd.FilterName), ctx.Style.Error(cmd.SourceName), err)
}
if filter.FilterEnabled {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is enabled.\n",
ctx.Style.Highlight(cmd.FilterName), ctx.Style.Highlight(cmd.SourceName))
} else {
fmt.Fprintf(ctx.Out, "Filter %s on source %s is disabled.\n",
ctx.Style.Highlight(cmd.FilterName), ctx.Style.Highlight(cmd.SourceName))
}
return nil
}

View File

@ -1,67 +0,0 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestFilterList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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 := newContext(client, &out, StyleConfig{})
cmd := &FilterListCmd{
SourceName: "gobs-test-scene",
}
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 := newContext(client, &out, StyleConfig{})
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(),
)
}
}

18
go.mod
View File

@ -4,32 +4,14 @@ go 1.24.0
require (
github.com/alecthomas/kong v1.10.0
github.com/alecthomas/mango-kong v0.1.0
github.com/andreykaipov/goobs v1.5.6
github.com/charmbracelet/lipgloss v1.1.0
github.com/titusjaka/kong-dotenv-go v0.1.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/logutils v1.0.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // 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/muesli/termenv v0.16.0 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.30.0 // indirect
)

44
go.sum
View File

@ -2,30 +2,12 @@ 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/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw=
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/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
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/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@ -34,41 +16,15 @@ 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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/mmcloughlin/profile v0.1.1 h1:jhDmAqPyebOsVDOCICJoINoLb/AnLBaUw58nFzxWS2w=
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/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/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=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -4,8 +4,6 @@ import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/sceneitems"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
// GroupCmd provides commands to manage groups in OBS Studio.
@ -19,64 +17,21 @@ type GroupCmd struct {
// GroupListCmd provides a command to list all groups in a scene.
type GroupListCmd struct {
SceneName string `arg:"" help:"Name of the scene to list groups from." default:""`
SceneName string `arg:"" help:"Name of the scene to list groups from."`
}
// Run executes the command to list all groups in a scene.
// nolint: misspell
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().
WithSceneName(cmd.SceneName))
if err != nil {
return fmt.Errorf("failed to get scene item list: %w", err)
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border)).
Headers("ID", "Group Name", "Enabled").
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Center)
case 1:
style = style.Align(lipgloss.Left)
case 2:
style = style.Align(lipgloss.Center)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
var found bool
for _, item := range resp.SceneItems {
if item.IsGroup {
t.Row(fmt.Sprintf("%d", item.SceneItemID), item.SourceName, getEnabledMark(item.SceneItemEnabled))
found = true
fmt.Fprintf(ctx.Out, "Group ID: %d, Source Name: %s\n", item.SceneItemID, item.SourceName)
}
}
if !found {
fmt.Fprintf(ctx.Out, "No groups found in scene %s.\n", ctx.Style.Highlight(cmd.SceneName))
return nil
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
@ -104,17 +59,13 @@ func (cmd *GroupShowCmd) Run(ctx *context) error {
if err != nil {
return fmt.Errorf("failed to set scene item enabled: %w", err)
}
fmt.Fprintf(ctx.Out, "Group %s is now shown.\n", ctx.Style.Highlight(cmd.GroupName))
fmt.Fprintf(ctx.Out, "Group %s is now shown.\n", cmd.GroupName)
found = true
break
}
}
if !found {
return fmt.Errorf(
"group %s not found in scene %s",
ctx.Style.Error(cmd.GroupName),
ctx.Style.Error(cmd.SceneName),
)
return fmt.Errorf("group '%s' not found", cmd.GroupName)
}
return nil
}
@ -143,17 +94,13 @@ func (cmd *GroupHideCmd) Run(ctx *context) error {
if err != nil {
return fmt.Errorf("failed to set scene item enabled: %w", err)
}
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", ctx.Style.Highlight(cmd.GroupName))
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", cmd.GroupName)
found = true
break
}
}
if !found {
return fmt.Errorf(
"group %s not found in scene %s",
ctx.Style.Error(cmd.GroupName),
ctx.Style.Error(cmd.SceneName),
)
return fmt.Errorf("group '%s' not found", cmd.GroupName)
}
return nil
}
@ -184,20 +131,16 @@ func (cmd *GroupToggleCmd) Run(ctx *context) error {
return fmt.Errorf("failed to set scene item enabled: %w", err)
}
if newState {
fmt.Fprintf(ctx.Out, "Group %s is now shown.\n", ctx.Style.Highlight(cmd.GroupName))
fmt.Fprintf(ctx.Out, "Group %s is now shown.\n", cmd.GroupName)
} else {
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", ctx.Style.Highlight(cmd.GroupName))
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", cmd.GroupName)
}
found = true
break
}
}
if !found {
return fmt.Errorf(
"group %s not found in scene %s",
ctx.Style.Error(cmd.GroupName),
ctx.Style.Error(cmd.SceneName),
)
return fmt.Errorf("group '%s' not found", cmd.GroupName)
}
return nil
@ -219,12 +162,12 @@ func (cmd *GroupStatusCmd) Run(ctx *context) error {
for _, item := range resp.SceneItems {
if item.IsGroup && item.SourceName == cmd.GroupName {
if item.SceneItemEnabled {
fmt.Fprintf(ctx.Out, "Group %s is shown.\n", ctx.Style.Highlight(cmd.GroupName))
fmt.Fprintf(ctx.Out, "Group %s is shown.\n", cmd.GroupName)
} else {
fmt.Fprintf(ctx.Out, "Group %s is hidden.\n", ctx.Style.Highlight(cmd.GroupName))
fmt.Fprintf(ctx.Out, "Group %s is hidden.\n", cmd.GroupName)
}
return nil
}
}
return fmt.Errorf("group %s not found in scene %s", ctx.Style.Error(cmd.GroupName), ctx.Style.Error(cmd.SceneName))
return fmt.Errorf("group '%s' not found", cmd.GroupName)
}

View File

@ -1,133 +0,0 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
func skipIfSkipGroupTests(t *testing.T) {
if os.Getenv("GOBS_TEST_SKIP_GROUP_TESTS") != "" {
t.Skip("Skipping group tests due to GOBS_TEST_SKIP_GROUP_TESTS environment variable")
}
}
func TestGroupList(t *testing.T) {
skipIfSkipGroupTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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) {
skipIfSkipGroupTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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) {
skipIfSkipGroupTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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) {
skipIfSkipGroupTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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())
}
}

View File

@ -1,93 +0,0 @@
package main
import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/general"
"github.com/andreykaipov/goobs/api/typedefs"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/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().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border)).
Headers("Hotkey Name").
StyleFunc(func(row, _ int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center) // nolint: misspell
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
for _, hotkey := range resp.Hotkeys {
t.Row(hotkey)
}
fmt.Fprintln(ctx.Out, 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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

102
input.go
View File

@ -1,14 +1,10 @@
// nolint: misspell
package main
import (
"fmt"
"sort"
"strings"
"github.com/andreykaipov/goobs/api/requests/inputs"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
// InputCmd provides commands to manage inputs in OBS Studio.
@ -24,9 +20,6 @@ type InputListCmd struct {
Input bool `flag:"" help:"List all inputs." aliases:"i"`
Output bool `flag:"" help:"List all outputs." aliases:"o"`
Colour bool `flag:"" help:"List all colour sources." aliases:"c"`
Ffmpeg bool `flag:"" help:"List all ffmpeg sources." aliases:"f"`
Vlc bool `flag:"" help:"List all VLC sources." aliases:"v"`
UUID bool `flag:"" help:"Display UUIDs of inputs." aliases:"u"`
}
// Run executes the command to list all inputs.
@ -35,90 +28,21 @@ func (cmd *InputListCmd) Run(ctx *context) error {
if err != nil {
return err
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border))
if cmd.UUID {
t.Headers("Input Name", "Kind", "Muted", "UUID")
} else {
t.Headers("Input Name", "Kind", "Muted")
}
t.StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Left)
case 1:
style = style.Align(lipgloss.Left)
case 2:
style = style.Align(lipgloss.Center)
case 3:
style = style.Align(lipgloss.Left)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
sort.Slice(resp.Inputs, func(i, j int) bool {
return resp.Inputs[i].InputName < resp.Inputs[j].InputName
})
for _, input := range resp.Inputs {
var muteMark string
resp, err := ctx.Client.Inputs.GetInputMute(
inputs.NewGetInputMuteParams().WithInputName(input.InputName),
)
if err != nil {
if err.Error() == "request GetInputMute: InvalidResourceState (604): The specified input does not support audio." {
muteMark = "N/A"
} else {
return fmt.Errorf("failed to get input mute state: %w", err)
if cmd.Input && strings.Contains(input.InputKind, "input") {
fmt.Fprintln(ctx.Out, "Input:", input.InputName)
}
} else {
muteMark = getEnabledMark(resp.InputMuted)
if cmd.Output && strings.Contains(input.InputKind, "output") {
fmt.Fprintln(ctx.Out, "Output:", input.InputName)
}
if cmd.Colour && strings.Contains(input.InputKind, "color") { // nolint
fmt.Fprintln(ctx.Out, "Colour Source:", input.InputName)
}
type filter struct {
enabled bool
keyword string
}
filters := []filter{
{cmd.Input, "input"},
{cmd.Output, "output"},
{cmd.Colour, "color"}, // nolint: misspell
{cmd.Ffmpeg, "ffmpeg"},
{cmd.Vlc, "vlc"},
}
var added bool
for _, f := range filters {
if f.enabled && strings.Contains(input.InputKind, f.keyword) {
if cmd.UUID {
t.Row(input.InputName, input.InputKind, muteMark, input.InputUuid)
} else {
t.Row(input.InputName, input.InputKind, muteMark)
}
added = true
break
if !cmd.Input && !cmd.Output && !cmd.Colour {
fmt.Fprintln(ctx.Out, "Source:", input.InputName)
}
}
if !added && (!cmd.Input && !cmd.Output && !cmd.Colour && !cmd.Ffmpeg && !cmd.Vlc) {
if cmd.UUID {
t.Row(input.InputName, snakeCaseToTitleCase(input.InputKind), muteMark, input.InputUuid)
} else {
t.Row(input.InputName, snakeCaseToTitleCase(input.InputKind), muteMark)
}
}
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
@ -136,7 +60,7 @@ func (cmd *InputMuteCmd) Run(ctx *context) error {
return fmt.Errorf("failed to mute input: %w", err)
}
fmt.Fprintf(ctx.Out, "Muted input: %s\n", ctx.Style.Highlight(cmd.InputName))
fmt.Fprintf(ctx.Out, "Muted input: %s\n", cmd.InputName)
return nil
}
@ -154,7 +78,7 @@ func (cmd *InputUnmuteCmd) Run(ctx *context) error {
return fmt.Errorf("failed to unmute input: %w", err)
}
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", ctx.Style.Highlight(cmd.InputName))
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", cmd.InputName)
return nil
}
@ -182,9 +106,9 @@ func (cmd *InputToggleCmd) Run(ctx *context) error {
}
if newMuteState {
fmt.Fprintf(ctx.Out, "Muted input: %s\n", ctx.Style.Highlight(cmd.InputName))
fmt.Fprintf(ctx.Out, "Muted input: %s\n", cmd.InputName)
} else {
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", ctx.Style.Highlight(cmd.InputName))
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", cmd.InputName)
}
return nil
}

View File

@ -1,124 +0,0 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestInputList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
cmd := &InputListCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list inputs: %v", err)
}
expectedInputs := []string{
"Desktop Audio",
"Mic/Aux",
"gobs-test-input",
"gobs-test-input-2",
}
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 := newContext(client, &out, StyleConfig{})
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",
"gobs-test-input",
"gobs-test-input-2",
}
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 := newContext(client, &out, StyleConfig{})
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",
"gobs-test-input",
"gobs-test-input-2",
}
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 := newContext(client, &out, StyleConfig{})
cmd := &InputListCmd{Colour: true}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list colour inputs with filter: %v", err)
}
expectedInputs := []string{
"gobs-test-input",
"gobs-test-input-2",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
}

116
main.go
View File

@ -7,120 +7,62 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
"github.com/alecthomas/kong"
mangokong "github.com/alecthomas/mango-kong"
"github.com/andreykaipov/goobs"
kongdotenv "github.com/titusjaka/kong-dotenv-go"
)
var version string // Version of the CLI, set at build time.
// VersionFlag is a custom flag type that prints the version and exits.
type VersionFlag string
func (v VersionFlag) Decode(_ *kong.DecodeContext) error { return nil } // nolint: revive
func (v VersionFlag) IsBool() bool { return true } // nolint: revive
func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { // nolint: revive, unparam
fmt.Printf("gobs-cli version: %s\n", vars["version"])
app.Exit(0)
return nil
}
// ObsConfig holds the configuration for connecting to the OBS WebSocket server.
type ObsConfig struct {
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" short:"P"`
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" short:"T"`
Host string `flag:"host" help:"Host to connect to." default:"localhost" env:"OBS_HOST"`
Port int `flag:"port" help:"Port to connect to." default:"4455" env:"OBS_PORT"`
Password string `flag:"password" help:"Password for authentication." default:"" env:"OBS_PASSWORD"`
Timeout int `flag:"timeout" help:"Timeout in seconds." default:"5" env:"OBS_TIMEOUT"`
}
// StyleConfig holds the configuration for styling the CLI output.
type StyleConfig struct {
Style string `help:"Style used in output." flag:"style" default:"" env:"GOBS_STYLE" short:"s" enum:",red,magenta,purple,blue,cyan,green,yellow,orange,white,grey,navy,black"`
NoBorder bool `help:"Disable table border styling in output." flag:"no-border" default:"false" env:"GOBS_STYLE_NO_BORDER" short:"b"`
}
// CLI is the main command line interface structure.
// It embeds ObsConfig and StyleConfig to provide configuration options.
type CLI struct {
// cli is the main command line interface structure.
// It embeds the ObsConfig struct to inherit its fields and flags.
type cli struct {
ObsConfig `embed:"" help:"OBS WebSocket configuration."`
StyleConfig `embed:"" help:"Style configuration."`
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" group:"Scene"`
Sceneitem SceneItemCmd `help:"Manage scene items." cmd:"" aliases:"si" group:"Scene Item"`
Group GroupCmd `help:"Manage groups." cmd:"" aliases:"g" group:"Group"`
Input InputCmd `help:"Manage inputs." cmd:"" aliases:"i" group:"Input"`
Text TextCmd `help:"Manage text inputs." cmd:"" aliases:"t" group:"Text Input"`
Record RecordCmd `help:"Manage recording." cmd:"" aliases:"rec" group:"Recording"`
Stream StreamCmd `help:"Manage streaming." cmd:"" aliases:"st" group:"Streaming"`
Scenecollection SceneCollectionCmd `help:"Manage scene collections." cmd:"" aliases:"scn" group:"Scene Collection"`
Profile ProfileCmd `help:"Manage profiles." cmd:"" aliases:"p" group:"Profile"`
Replaybuffer ReplayBufferCmd `help:"Manage replay buffer." cmd:"" aliases:"rb" group:"Replay Buffer"`
Studiomode StudioModeCmd `help:"Manage studio mode." cmd:"" aliases:"sm" group:"Studio Mode"`
Virtualcam VirtualCamCmd `help:"Manage virtual camera." cmd:"" aliases:"vc" group:"Virtual Camera"`
Hotkey HotkeyCmd `help:"Manage hotkeys." cmd:"" aliases:"hk" group:"Hotkey"`
Filter FilterCmd `help:"Manage filters." cmd:"" aliases:"f" group:"Filter"`
Projector ProjectorCmd `help:"Manage projectors." cmd:"" aliases:"prj" group:"Projector"`
Screenshot ScreenshotCmd `help:"Take screenshots." cmd:"" aliases:"ss" group:"Screenshot"`
Version VersionCmd `help:"Show version." cmd:"" aliases:"v"`
Scene SceneCmd `help:"Manage scenes." cmd:"" aliases:"sc"`
Sceneitem SceneItemCmd `help:"Manage scene items." cmd:"" aliases:"si"`
Group GroupCmd `help:"Manage groups." cmd:"" aliases:"g"`
Input InputCmd `help:"Manage inputs." cmd:"" aliases:"i"`
Record RecordCmd `help:"Manage recording." cmd:"" aliases:"rec"`
Stream StreamCmd `help:"Manage streaming." cmd:"" aliases:"st"`
Scenecollection SceneCollectionCmd `help:"Manage scene collections." cmd:"" aliases:"scn"`
Profile ProfileCmd `help:"Manage profiles." cmd:"" aliases:"p"`
Replaybuffer ReplayBufferCmd `help:"Manage replay buffer." cmd:"" aliases:"rb"`
Studiomode StudioModeCmd `help:"Manage studio mode." cmd:"" aliases:"sm"`
Virtualcam VirtualCamCmd `help:"Manage virtual camera." cmd:"" aliases:"vc"`
}
type context struct {
Client *goobs.Client
Out io.Writer
Style *Style
}
func newContext(client *goobs.Client, out io.Writer, styleCfg StyleConfig) *context {
return &context{
Client: client,
Out: out,
Style: styleFromFlag(styleCfg),
}
}
func main() {
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
var client *goobs.Client
cli := cli{}
ctx := kong.Parse(
&cli,
kong.Name("gobs-cli"),
kong.Name("GOBS-CLI"),
kong.Description("A command line tool to interact with OBS Websocket."),
kong.Configuration(kongdotenv.ENVFileReader, ".env", filepath.Join(userConfigDir, "gobs-cli", "config.env")),
kong.UsageOnError(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
}),
kong.Vars{
"version": func() string {
if version == "" {
info, ok := debug.ReadBuildInfo()
if !ok {
return "(unable to read build info)"
}
version = strings.Split(info.Main.Version, "-")[0]
}
return version
}(),
})
)
client, err := connectObs(cli.ObsConfig)
if err != nil {
ctx.FatalIfErrorf(err)
}
ctx.Bind(newContext(client, os.Stdout, cli.StyleConfig))
ctx.Bind(&context{
Client: client,
Out: os.Stdout,
})
ctx.FatalIfErrorf(run(ctx, client))
}

View File

@ -1,182 +0,0 @@
package main
import (
"os"
"runtime"
"testing"
"time"
"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.CreateProfile(config.NewCreateProfileParams().
WithProfileName("gobs-test-profile"))
time.Sleep(100 * time.Millisecond) // Wait for the profile to be created
client.Config.SetProfileParameter(config.NewSetProfileParameterParams().
WithParameterCategory("SimpleOutput").
WithParameterName("RecRB").
WithParameterValue("true"))
// hack to ensure the Replay Buffer setting is applied
client.Config.SetCurrentProfile(config.NewSetCurrentProfileParams().
WithProfileName("Untitled"))
client.Config.SetCurrentProfile(config.NewSetCurrentProfileParams().
WithProfileName("gobs-test-profile"))
client.Scenes.CreateScene(scenes.NewCreateSceneParams().
WithSceneName("gobs-test-scene"))
client.Inputs.CreateInput(inputs.NewCreateInputParams().
WithSceneName("gobs-test-scene").
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-scene").
WithInputName("gobs-test-input-2").
WithInputKind("color_source_v3").
WithInputSettings(map[string]any{
"color": 1789347616,
"width": 720,
"height": 480,
"visible": true,
}).
WithSceneItemEnabled(true))
// ensure Desktop Audio input is created
desktopAudioKinds := map[string]string{
"windows": "wasapi_output_capture",
"linux": "pulse_output_capture",
"darwin": "coreaudio_output_capture",
}
platform := os.Getenv("GOBS_TEST_PLATFORM")
if platform == "" {
platform = runtime.GOOS
}
client.Inputs.CreateInput(inputs.NewCreateInputParams().
WithSceneName("gobs-test-scene").
WithInputName("Desktop Audio").
WithInputKind(desktopAudioKinds[platform]).
WithInputSettings(map[string]any{
"device_id": "default",
}))
// ensure Mic/Aux input is created
micKinds := map[string]string{
"windows": "wasapi_input_capture",
"linux": "pulse_input_capture",
"darwin": "coreaudio_input_capture",
}
client.Inputs.CreateInput(inputs.NewCreateInputParams().
WithSceneName("gobs-test-scene").
WithInputName("Mic/Aux").
WithInputKind(micKinds[platform]).
WithInputSettings(map[string]any{
"device_id": "default",
}))
// 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-scene").
WithFilterName("test_filter").
WithFilterKind("luma_key_filter_v2").
WithFilterSettings(map[string]any{
"luma": 0.5,
}))
}
func teardown(client *goobs.Client) {
client.Config.RemoveProfile(config.NewRemoveProfileParams().
WithProfileName("gobs-test-profile"))
client.Filters.RemoveSourceFilter(filters.NewRemoveSourceFilterParams().
WithSourceName("Mic/Aux").
WithFilterName("test_filter"))
client.Filters.RemoveSourceFilter(filters.NewRemoveSourceFilterParams().
WithSourceName("gobs-test-scene").
WithFilterName("test_filter"))
client.Scenes.RemoveScene(scenes.NewRemoveSceneParams().
WithSceneName("gobs-test-scene"))
client.Config.SetCurrentSceneCollection(config.NewSetCurrentSceneCollectionParams().
WithSceneCollectionName("Untitled"))
client.Stream.StopStream()
client.Record.StopRecord()
client.Outputs.StopReplayBuffer()
client.Ui.SetStudioModeEnabled(ui.NewSetStudioModeEnabledParams().
WithStudioModeEnabled(false))
}

View File

@ -5,85 +5,55 @@ import (
"slices"
"github.com/andreykaipov/goobs/api/requests/config"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
// ProfileCmd provides commands to manage profiles in OBS Studio.
type ProfileCmd struct {
List ProfileListCmd `help:"List profiles." cmd:"" aliases:"ls"`
Current ProfileCurrentCmd `help:"Get current profile." cmd:"" aliases:"c"`
Switch ProfileSwitchCmd `help:"Switch profile." cmd:"" aliases:"sw"`
Create ProfileCreateCmd `help:"Create profile." cmd:"" aliases:"new"`
Remove ProfileRemoveCmd `help:"Remove profile." cmd:"" aliases:"rm"`
List ListProfileCmd `help:"List profiles." cmd:"" aliases:"ls"`
Current CurrentProfileCmd `help:"Get current profile." cmd:"" aliases:"c"`
Switch SwitchProfileCmd `help:"Switch profile." cmd:"" aliases:"sw"`
Create CreateProfileCmd `help:"Create profile." cmd:"" aliases:"cr"`
Remove RemoveProfileCmd `help:"Remove profile." cmd:"" aliases:"rm"`
}
// ProfileListCmd provides a command to list all profiles.
type ProfileListCmd struct{} // size = 0x0
// ListProfileCmd provides a command to list all profiles.
type ListProfileCmd struct{} // size = 0x0
// Run executes the command to list all profiles.
// nolint: misspell
func (cmd *ProfileListCmd) Run(ctx *context) error {
func (cmd *ListProfileCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border)).
Headers("Profile Name", "Current").
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Left)
case 1:
style = style.Align(lipgloss.Center)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
for _, profile := range profiles.Profiles {
var enabledMark string
if profile == profiles.CurrentProfileName {
enabledMark = getEnabledMark(true)
fmt.Fprintln(ctx.Out, profile)
}
t.Row(profile, enabledMark)
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
// ProfileCurrentCmd provides a command to get the current profile.
type ProfileCurrentCmd struct{} // size = 0x0
// CurrentProfileCmd provides a command to get the current profile.
type CurrentProfileCmd struct{} // size = 0x0
// Run executes the command to get the current profile.
func (cmd *ProfileCurrentCmd) Run(ctx *context) error {
func (cmd *CurrentProfileCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "Current profile: %s\n", ctx.Style.Highlight(profiles.CurrentProfileName))
fmt.Fprintf(ctx.Out, "Current profile: %s\n", profiles.CurrentProfileName)
return nil
}
// ProfileSwitchCmd provides a command to switch to a different profile.
type ProfileSwitchCmd struct {
// SwitchProfileCmd provides a command to switch to a different profile.
type SwitchProfileCmd struct {
Name string `arg:"" help:"Name of the profile to switch to." required:""`
}
// Run executes the command to switch to a different profile.
func (cmd *ProfileSwitchCmd) Run(ctx *context) error {
func (cmd *SwitchProfileCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
@ -91,78 +61,71 @@ func (cmd *ProfileSwitchCmd) Run(ctx *context) error {
current := profiles.CurrentProfileName
if current == cmd.Name {
return fmt.Errorf("already using profile %s", ctx.Style.Error(cmd.Name))
return nil
}
_, err = ctx.Client.Config.SetCurrentProfile(config.NewSetCurrentProfileParams().WithProfileName(cmd.Name))
if err != nil {
return fmt.Errorf("failed to switch to profile %s: %w", ctx.Style.Error(cmd.Name), err)
return err
}
fmt.Fprintf(
ctx.Out,
"Switched from profile %s to %s\n",
ctx.Style.Highlight(current),
ctx.Style.Highlight(cmd.Name),
)
fmt.Fprintf(ctx.Out, "Switched from profile %s to %s\n", current, cmd.Name)
return nil
}
// ProfileCreateCmd provides a command to create a new profile.
type ProfileCreateCmd struct {
// CreateProfileCmd provides a command to create a new profile.
type CreateProfileCmd struct {
Name string `arg:"" help:"Name of the profile to create." required:""`
}
// Run executes the command to create a new profile.
func (cmd *ProfileCreateCmd) Run(ctx *context) error {
func (cmd *CreateProfileCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
if slices.Contains(profiles.Profiles, cmd.Name) {
return fmt.Errorf("profile %s already exists", ctx.Style.Error(cmd.Name))
return fmt.Errorf("profile %s already exists", cmd.Name)
}
_, err = ctx.Client.Config.CreateProfile(config.NewCreateProfileParams().WithProfileName(cmd.Name))
if err != nil {
return fmt.Errorf("failed to create profile %s: %w", ctx.Style.Error(cmd.Name), err)
return err
}
fmt.Fprintf(ctx.Out, "Created profile: %s\n", ctx.Style.Highlight(cmd.Name))
fmt.Fprintf(ctx.Out, "Created profile: %s\n", cmd.Name)
return nil
}
// ProfileRemoveCmd provides a command to remove an existing profile.
type ProfileRemoveCmd struct {
// RemoveProfileCmd provides a command to remove an existing profile.
type RemoveProfileCmd struct {
Name string `arg:"" help:"Name of the profile to delete." required:""`
}
// Run executes the command to remove an existing profile.
func (cmd *ProfileRemoveCmd) Run(ctx *context) error {
func (cmd *RemoveProfileCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
if !slices.Contains(profiles.Profiles, cmd.Name) {
return fmt.Errorf("profile %s does not exist", ctx.Style.Error(cmd.Name))
return fmt.Errorf("profile %s does not exist", cmd.Name)
}
// Prevent deletion of the current profile
// This is allowed in OBS Studio (with a confirmation prompt), but we want to prevent it here
if profiles.CurrentProfileName == cmd.Name {
return fmt.Errorf("cannot delete current profile %s", ctx.Style.Error(cmd.Name))
return fmt.Errorf("cannot delete current profile %s", cmd.Name)
}
_, err = ctx.Client.Config.RemoveProfile(config.NewRemoveProfileParams().WithProfileName(cmd.Name))
if err != nil {
return fmt.Errorf("failed to delete profile %s: %w", ctx.Style.Error(cmd.Name), err)
return err
}
fmt.Fprintf(ctx.Out, "Deleted profile: %s\n", ctx.Style.Highlight(cmd.Name))
fmt.Fprintf(ctx.Out, "Deleted profile: %s\n", cmd.Name)
return nil
}

View File

@ -1,111 +0,0 @@
package main
import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/ui"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/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.
// nolint: misspell
func (cmd *ProjectorListMonitorsCmd) Run(ctx *context) error {
monitors, err := ctx.Client.Ui.GetMonitorList()
if err != nil {
return err
}
if len(monitors.Monitors) == 0 {
fmt.Fprintf(ctx.Out, "No monitors found.\n")
return nil
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border)).
Headers("Monitor ID", "Monitor Name").
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Center)
case 1:
style = style.Align(lipgloss.Left)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
for _, monitor := range monitors.Monitors {
t.Row(fmt.Sprintf("%d", monitor.MonitorIndex), monitor.MonitorName)
}
fmt.Fprintln(ctx.Out, 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
}
monitors, err := ctx.Client.Ui.GetMonitorList()
if err != nil {
return err
}
var monitorName string
for _, monitor := range monitors.Monitors {
if monitor.MonitorIndex == cmd.MonitorIndex {
monitorName = monitor.MonitorName
break
}
}
if monitorName == "" {
return fmt.Errorf(
"monitor with index %s not found. use %s to list available monitors",
ctx.Style.Error(fmt.Sprintf("%d", cmd.MonitorIndex)),
ctx.Style.Error("gobs-cli prj ls-m"),
)
}
ctx.Client.Ui.OpenSourceProjector(ui.NewOpenSourceProjectorParams().
WithSourceName(cmd.SourceName).
WithMonitorIndex(cmd.MonitorIndex))
fmt.Fprintf(
ctx.Out,
"Opened projector for source %s on monitor %s.\n",
ctx.Style.Highlight(cmd.SourceName),
ctx.Style.Highlight(monitorName),
)
return nil
}

164
record.go
View File

@ -2,9 +2,6 @@ package main
import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/config"
"github.com/andreykaipov/goobs/api/requests/record"
)
// RecordCmd handles the recording commands.
@ -12,12 +9,8 @@ type RecordCmd struct {
Start RecordStartCmd `cmd:"" help:"Start recording." aliases:"s"`
Stop RecordStopCmd `cmd:"" help:"Stop recording." aliases:"st"`
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"`
Resume RecordResumeCmd `cmd:"" help:"Resume recording." aliases:"r"`
Directory RecordDirectoryCmd `cmd:"" help:"Get/Set recording directory." aliases:"d"`
Split RecordSplitCmd `cmd:"" help:"Split recording." aliases:"sp"`
Chapter RecordChapterCmd `cmd:"" help:"Create a chapter in the recording." aliases:"c"`
}
// RecordStartCmd starts the recording.
@ -25,19 +18,7 @@ type RecordStartCmd struct{} // size = 0x0
// Run executes the command to start recording.
func (cmd *RecordStartCmd) Run(ctx *context) error {
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()
_, err := ctx.Client.Record.StartRecord()
if err != nil {
return err
}
@ -50,24 +31,11 @@ type RecordStopCmd struct{} // size = 0x0
// Run executes the command to stop recording.
func (cmd *RecordStopCmd) Run(ctx *context) error {
status, err := ctx.Client.Record.GetRecordStatus()
_, err := ctx.Client.Record.StopRecord()
if err != nil {
return err
}
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", ctx.Style.Highlight(resp.OutputPath)),
)
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
return nil
}
@ -76,39 +44,25 @@ type RecordToggleCmd struct{} // size = 0x0
// Run executes the command to toggle recording.
func (cmd *RecordToggleCmd) Run(ctx *context) error {
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 {
// Check if recording is in progress
status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
if status.OutputActive {
if status.OutputPaused {
fmt.Fprintln(ctx.Out, "Recording is paused.")
} else {
fmt.Fprintln(ctx.Out, "Recording is in progress.")
_, err = ctx.Client.Record.StopRecord()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
} else {
fmt.Fprintln(ctx.Out, "Recording is not in progress.")
_, err = ctx.Client.Record.StartRecord()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Recording started successfully.")
}
return nil
}
@ -163,95 +117,3 @@ func (cmd *RecordResumeCmd) Run(ctx *context) error {
fmt.Fprintln(ctx.Out, "Recording resumed successfully.")
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", ctx.Style.Highlight(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", ctx.Style.Highlight(cmd.RecordDirectory))
return nil
}
// RecordSplitCmd splits the current recording.
type RecordSplitCmd struct{} // size = 0x0
// Run executes the command to split the recording.
func (cmd *RecordSplitCmd) Run(ctx *context) error {
status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
if !status.OutputActive {
return fmt.Errorf("recording is not in progress")
}
if status.OutputPaused {
return fmt.Errorf("recording is paused, cannot split")
}
_, err = ctx.Client.Record.SplitRecordFile()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Recording split successfully.")
return nil
}
// RecordChapterCmd creates a chapter in the recording.
type RecordChapterCmd struct {
ChapterName string `arg:"" help:"Name of the chapter to create." default:""`
}
// Run executes the command to create a chapter in the recording.
func (cmd *RecordChapterCmd) Run(ctx *context) error {
status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
if !status.OutputActive {
return fmt.Errorf("recording is not in progress")
}
if status.OutputPaused {
return fmt.Errorf("recording is paused, cannot create chapter")
}
var params *record.CreateRecordChapterParams
if cmd.ChapterName == "" {
params = record.NewCreateRecordChapterParams()
} else {
params = record.NewCreateRecordChapterParams().WithChapterName(cmd.ChapterName)
}
_, err = ctx.Client.Record.CreateRecordChapter(params)
if err != nil {
return err
}
if cmd.ChapterName == "" {
cmd.ChapterName = "unnamed"
}
fmt.Fprintf(ctx.Out, "Chapter %s created successfully.\n", ctx.Style.Highlight(cmd.ChapterName))
return nil
}

View File

@ -1,125 +0,0 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
)
func TestRecordStart(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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(500 * time.Millisecond) // Wait for the recording to start
}
func TestRecordStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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(500 * time.Millisecond) // Wait for the recording to stop
}
func TestRecordToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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)
}
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())
}
}
time.Sleep(500 * time.Millisecond) // Wait for the toggle to take effect
}

View File

@ -8,7 +8,6 @@ import (
type ReplayBufferCmd struct {
Start ReplayBufferStartCmd `help:"Start replay buffer." cmd:"" aliases:"s"`
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"`
Save ReplayBufferSaveCmd `help:"Save replay buffer." cmd:"" aliases:"sv"`
}
@ -19,11 +18,7 @@ type ReplayBufferStartCmd struct{} // size = 0x0
// Run executes the command to start the replay buffer.
func (cmd *ReplayBufferStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StartReplayBuffer()
if err != nil {
return fmt.Errorf("failed to start replay buffer: %w", err)
}
fmt.Fprintln(ctx.Out, "Replay buffer started.")
return nil
return err
}
// ReplayBufferStopCmd stops the replay buffer.
@ -32,29 +27,7 @@ type ReplayBufferStopCmd struct{} // size = 0x0
// Run executes the command to stop the replay buffer.
func (cmd *ReplayBufferStopCmd) Run(ctx *context) error {
_, 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
}
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.

View File

@ -1,93 +0,0 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
"time"
)
func skipIfSkipReplayBufferTests(t *testing.T) {
if os.Getenv("GOBS_TEST_SKIP_REPLAYBUFFER_TESTS") != "" {
t.Skip("Skipping replay buffer tests due to GOBS_TEST_SKIP_REPLAYBUFFER_TESTS environment variable")
}
}
func TestReplayBufferStart(t *testing.T) {
skipIfSkipReplayBufferTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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())
}
time.Sleep(500 * time.Millisecond) // Wait for the replay buffer to start
}
func TestReplayBufferStop(t *testing.T) {
skipIfSkipReplayBufferTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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())
}
time.Sleep(500 * time.Millisecond) // Wait for the replay buffer to stop
}
func TestReplayBufferToggle(t *testing.T) {
skipIfSkipReplayBufferTests(t)
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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())
}
}
time.Sleep(500 * time.Millisecond) // Wait for the toggle to take effect
}

View File

@ -5,8 +5,6 @@ import (
"slices"
"github.com/andreykaipov/goobs/api/requests/scenes"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
// SceneCmd provides commands to manage scenes in OBS Studio.
@ -17,64 +15,19 @@ type SceneCmd struct {
}
// SceneListCmd provides a command to list all scenes.
type SceneListCmd struct {
UUID bool `flag:"" help:"Display UUIDs of scenes."`
}
type SceneListCmd struct{} // size = 0x0
// Run executes the command to list all scenes.
// nolint: misspell
func (cmd *SceneListCmd) Run(ctx *context) error {
scenes, err := ctx.Client.Scenes.GetSceneList()
if err != nil {
return err
}
currentScene, err := ctx.Client.Scenes.GetCurrentProgramScene()
if err != nil {
return err
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border))
if cmd.UUID {
t.Headers("Scene Name", "Active", "UUID")
} else {
t.Headers("Scene Name", "Active")
}
t.StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Left)
case 1:
style = style.Align(lipgloss.Center)
case 2:
style = style.Align(lipgloss.Left)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
slices.Reverse(scenes.Scenes)
for _, scene := range scenes.Scenes {
var activeMark string
if scene.SceneName == currentScene.SceneName {
activeMark = getEnabledMark(true)
fmt.Fprintln(ctx.Out, scene.SceneName)
}
if cmd.UUID {
t.Row(scene.SceneName, activeMark, scene.SceneUuid)
} else {
t.Row(scene.SceneName, activeMark)
}
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
@ -90,13 +43,13 @@ func (cmd *SceneCurrentCmd) Run(ctx *context) error {
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "Current preview scene: %s\n", ctx.Style.Highlight(scene.SceneName))
fmt.Fprintln(ctx.Out, scene.SceneName)
} else {
scene, err := ctx.Client.Scenes.GetCurrentProgramScene()
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "Current program scene: %s\n", ctx.Style.Highlight(scene.SceneName))
fmt.Fprintln(ctx.Out, scene.SceneName)
}
return nil
}
@ -116,7 +69,7 @@ func (cmd *SceneSwitchCmd) Run(ctx *context) error {
return err
}
fmt.Fprintf(ctx.Out, "Switched to preview scene: %s\n", ctx.Style.Highlight(cmd.NewScene))
fmt.Fprintln(ctx.Out, "Switched to preview scene:", cmd.NewScene)
} else {
_, err := ctx.Client.Scenes.SetCurrentProgramScene(scenes.NewSetCurrentProgramSceneParams().
WithSceneName(cmd.NewScene))
@ -124,7 +77,7 @@ func (cmd *SceneSwitchCmd) Run(ctx *context) error {
return err
}
fmt.Fprintf(ctx.Out, "Switched to program scene: %s\n", ctx.Style.Highlight(cmd.NewScene))
fmt.Fprintln(ctx.Out, "Switched to program scene:", cmd.NewScene)
}
return nil
}

View File

@ -1,51 +0,0 @@
package main
import (
"bytes"
"testing"
)
func TestSceneList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
cmd := &SceneListCmd{}
err := cmd.Run(context)
if err != nil {
t.Fatalf("Failed to list scenes: %v", err)
}
if out.String() == "Current program scene: gobs-test-scene\n" {
t.Fatalf("Expected output to be 'Current program scene: gobs-test-scene', got '%s'", out.String())
}
}
func TestSceneCurrent(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
// Set the current scene to "gobs-test-scene"
cmdSwitch := &SceneSwitchCmd{
NewScene: "gobs-test-scene",
}
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() != "Current program scene: gobs-test-scene\n" {
t.Fatalf("Expected output to be 'Current program scene: gobs-test-scene', got '%s'", out.String())
}
}

View File

@ -4,61 +4,38 @@ import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/config"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
// SceneCollectionCmd provides commands to manage scene collections in OBS Studio.
type SceneCollectionCmd struct {
List SceneCollectionListCmd `help:"List scene collections." cmd:"" aliases:"ls"`
Current SceneCollectionCurrentCmd `help:"Get current scene collection." cmd:"" aliases:"c"`
Switch SceneCollectionSwitchCmd `help:"Switch scene collection." cmd:"" aliases:"sw"`
Create SceneCollectionCreateCmd `help:"Create scene collection." cmd:"" aliases:"new"`
List ListSceneCollectionCmd `help:"List scene collections." cmd:"" aliases:"ls"`
Current CurrentSceneCollectionCmd `help:"Get current scene collection." cmd:"" aliases:"c"`
Switch SwitchSceneCollectionCmd `help:"Switch scene collection." cmd:"" aliases:"sw"`
Create CreateSceneCollectionCmd `help:"Create scene collection." cmd:"" aliases:"cr"`
}
// SceneCollectionListCmd provides a command to list all scene collections.
type SceneCollectionListCmd struct{} // size = 0x0
// ListSceneCollectionCmd provides a command to list all scene collections.
type ListSceneCollectionCmd struct{} // size = 0x0
// Run executes the command to list all scene collections.
// nolint: misspell
func (cmd *SceneCollectionListCmd) Run(ctx *context) error {
func (cmd *ListSceneCollectionCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil {
return fmt.Errorf("failed to get scene collection list: %w", err)
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border)).
Headers("Scene Collection Name").
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Left)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
for _, collection := range collections.SceneCollections {
t.Row(collection)
fmt.Fprintln(ctx.Out, collection)
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
// SceneCollectionCurrentCmd provides a command to get the current scene collection.
type SceneCollectionCurrentCmd struct{} // size = 0x0
// CurrentSceneCollectionCmd provides a command to get the current scene collection.
type CurrentSceneCollectionCmd struct{} // size = 0x0
// Run executes the command to get the current scene collection.
func (cmd *SceneCollectionCurrentCmd) Run(ctx *context) error {
func (cmd *CurrentSceneCollectionCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil {
return fmt.Errorf("failed to get scene collection list: %w", err)
@ -68,13 +45,13 @@ func (cmd *SceneCollectionCurrentCmd) Run(ctx *context) error {
return nil
}
// SceneCollectionSwitchCmd provides a command to switch to a different scene collection.
type SceneCollectionSwitchCmd struct {
// SwitchSceneCollectionCmd provides a command to switch to a different scene collection.
type SwitchSceneCollectionCmd struct {
Name string `arg:"" help:"Name of the scene collection to switch to." required:""`
}
// Run executes the command to switch to a different scene collection.
func (cmd *SceneCollectionSwitchCmd) Run(ctx *context) error {
func (cmd *SwitchSceneCollectionCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil {
return err
@ -82,35 +59,35 @@ func (cmd *SceneCollectionSwitchCmd) Run(ctx *context) error {
current := collections.CurrentSceneCollectionName
if current == cmd.Name {
return fmt.Errorf("scene collection %s is already active", ctx.Style.Error(cmd.Name))
return fmt.Errorf("scene collection %s is already active", cmd.Name)
}
_, err = ctx.Client.Config.SetCurrentSceneCollection(
config.NewSetCurrentSceneCollectionParams().WithSceneCollectionName(cmd.Name),
)
if err != nil {
return fmt.Errorf("failed to switch scene collection %s: %w", ctx.Style.Error(cmd.Name), err)
return fmt.Errorf("failed to switch scene collection: %w", err)
}
fmt.Fprintf(ctx.Out, "Switched to scene collection: %s\n", ctx.Style.Highlight(cmd.Name))
fmt.Fprintf(ctx.Out, "Switched to scene collection: %s\n", cmd.Name)
return nil
}
// SceneCollectionCreateCmd provides a command to create a new scene collection.
type SceneCollectionCreateCmd struct {
// CreateSceneCollectionCmd provides a command to create a new scene collection.
type CreateSceneCollectionCmd struct {
Name string `arg:"" help:"Name of the scene collection to create." required:""`
}
// Run executes the command to create a new scene collection.
func (cmd *SceneCollectionCreateCmd) Run(ctx *context) error {
func (cmd *CreateSceneCollectionCmd) Run(ctx *context) error {
_, err := ctx.Client.Config.CreateSceneCollection(
config.NewCreateSceneCollectionParams().WithSceneCollectionName(cmd.Name),
)
if err != nil {
return fmt.Errorf("failed to create scene collection %s: %w", ctx.Style.Error(cmd.Name), err)
return fmt.Errorf("failed to create scene collection: %w", err)
}
fmt.Fprintf(ctx.Out, "Created scene collection: %s\n", ctx.Style.Highlight(cmd.Name))
fmt.Fprintf(ctx.Out, "Created scene collection: %s\n", cmd.Name)
return nil
}

View File

@ -1,14 +1,10 @@
// nolint: misspell
package main
import (
"fmt"
"sort"
"github.com/andreykaipov/goobs"
"github.com/andreykaipov/goobs/api/requests/sceneitems"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
// SceneItemCmd provides commands to manage scene items in OBS Studio.
@ -18,153 +14,50 @@ type SceneItemCmd struct {
Hide SceneItemHideCmd `cmd:"" help:"Hide scene item." aliases:"h"`
Toggle SceneItemToggleCmd `cmd:"" help:"Toggle scene item." aliases:"tg"`
Visible SceneItemVisibleCmd `cmd:"" help:"Get scene item visibility." aliases:"v"`
Transform SceneItemTransformCmd `cmd:"" help:"Transform scene item." aliases:"t"`
}
// SceneItemListCmd provides a command to list all scene items in a scene.
type SceneItemListCmd struct {
UUID bool `flag:"" help:"Display UUIDs of scene items."`
SceneName string ` help:"Name of the scene to list items from." arg:"" default:""`
SceneName string `arg:"" help:"Scene name."`
}
// Run executes the command to list all scene items in a scene.
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().
WithSceneName(cmd.SceneName))
if err != nil {
return fmt.Errorf("failed to get scene item list: %w", err)
}
if len(resp.SceneItems) == 0 {
fmt.Fprintf(ctx.Out, "No scene items found in scene %s.\n", ctx.Style.Highlight(cmd.SceneName))
return nil
}
t := table.New().Border(lipgloss.RoundedBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(ctx.Style.border))
if cmd.UUID {
t.Headers("Item ID", "Item Name", "In Group", "Enabled", "UUID")
} else {
t.Headers("Item ID", "Item Name", "In Group", "Enabled")
}
t.StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().Padding(0, 3)
switch col {
case 0:
style = style.Align(lipgloss.Center)
case 1:
style = style.Align(lipgloss.Left)
case 2:
style = style.Align(lipgloss.Center)
case 3:
style = style.Align(lipgloss.Center)
case 4:
style = style.Align(lipgloss.Left)
}
switch {
case row == table.HeaderRow:
style = style.Bold(true).Align(lipgloss.Center)
case row%2 == 0:
style = style.Foreground(ctx.Style.evenRows)
default:
style = style.Foreground(ctx.Style.oddRows)
}
return style
})
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 group %s: %w",
ctx.Style.Error(item.SourceName),
err,
)
fmt.Fprintf(ctx.Out, "Item ID: %d, Source Name: %s\n", item.SceneItemID, item.SourceName)
}
sort.Slice(resp.SceneItems, func(i, j int) bool {
return resp.SceneItems[i].SceneItemID < resp.SceneItems[j].SceneItemID
})
for _, groupItem := range resp.SceneItems {
if cmd.UUID {
t.Row(
fmt.Sprintf("%d", groupItem.SceneItemID),
groupItem.SourceName,
item.SourceName,
getEnabledMark(item.SceneItemEnabled && groupItem.SceneItemEnabled),
groupItem.SourceUuid,
)
} else {
t.Row(
fmt.Sprintf("%d", groupItem.SceneItemID),
groupItem.SourceName,
item.SourceName,
getEnabledMark(item.SceneItemEnabled && groupItem.SceneItemEnabled),
)
}
}
} else {
if cmd.UUID {
t.Row(fmt.Sprintf("%d", item.SceneItemID), item.SourceName, "",
getEnabledMark(item.SceneItemEnabled), item.SourceUuid)
} else {
t.Row(fmt.Sprintf("%d", item.SceneItemID), item.SourceName, "", getEnabledMark(item.SceneItemEnabled))
}
}
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
// getSceneNameAndItemID retrieves the scene name and item ID for a given item in a scene or group.
func getSceneNameAndItemID(
ctx *context,
client *goobs.Client,
sceneName string,
itemName string,
group string,
parent string,
) (string, int, error) {
if group != "" {
resp, err := ctx.Client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams().
WithSceneName(group))
if parent != "" {
resp, err := client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams().
WithSceneName(parent))
if err != nil {
return "", 0, err
}
for _, item := range resp.SceneItems {
if item.SourceName == itemName {
return group, int(item.SceneItemID), nil
return parent, int(item.SceneItemID), nil
}
}
return "", 0, fmt.Errorf("item %s not found in scene %s", ctx.Style.Error(itemName), ctx.Style.Error(sceneName))
return "", 0, fmt.Errorf("item '%s' not found in scene '%s'", itemName, sceneName)
}
itemID, err := ctx.Client.SceneItems.GetSceneItemId(sceneitems.NewGetSceneItemIdParams().
itemID, err := client.SceneItems.GetSceneItemId(sceneitems.NewGetSceneItemIdParams().
WithSceneName(sceneName).
WithSourceName(itemName))
if err != nil {
if err.Error() == "request GetSceneItemId: ResourceNotFound (600): No scene items were found in the specified scene by that name or offset." {
return "", 0, fmt.Errorf(
"item %s not found in scene %s. is it in a group? if so use the %s flag to specify the parent group\nuse %s for a list of items in the scene",
ctx.Style.Error(itemName),
ctx.Style.Error(sceneName),
ctx.Style.Error("--group"),
ctx.Style.Error("gobs-cli si ls"),
)
}
return "", 0, err
}
return sceneName, int(itemID.SceneItemId), nil
@ -172,7 +65,7 @@ func getSceneNameAndItemID(
// SceneItemShowCmd provides a command to show a scene item.
type SceneItemShowCmd struct {
Group string `flag:"" help:"Parent group name."`
Parent string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -180,7 +73,7 @@ type SceneItemShowCmd struct {
// Run executes the command to show a scene item.
func (cmd *SceneItemShowCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
if err != nil {
return err
}
@ -192,24 +85,12 @@ func (cmd *SceneItemShowCmd) Run(ctx *context) error {
if err != nil {
return err
}
if cmd.Group != "" {
fmt.Fprintf(
ctx.Out,
"Scene item %s in group %s is now visible.\n",
ctx.Style.Highlight(cmd.ItemName),
ctx.Style.Highlight(cmd.Group),
)
} else {
fmt.Fprintf(ctx.Out, "Scene item %s in scene %s is now visible.\n", ctx.Style.Highlight(cmd.ItemName), ctx.Style.Highlight(cmd.SceneName))
}
return nil
}
// SceneItemHideCmd provides a command to hide a scene item.
type SceneItemHideCmd struct {
Group string `flag:"" help:"Parent group name."`
Parent string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -217,7 +98,7 @@ type SceneItemHideCmd struct {
// Run executes the command to hide a scene item.
func (cmd *SceneItemHideCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
if err != nil {
return err
}
@ -229,18 +110,6 @@ func (cmd *SceneItemHideCmd) Run(ctx *context) error {
if err != nil {
return err
}
if cmd.Group != "" {
fmt.Fprintf(
ctx.Out,
"Scene item %s in group %s is now hidden.\n",
ctx.Style.Highlight(cmd.ItemName),
ctx.Style.Highlight(cmd.Group),
)
} else {
fmt.Fprintf(ctx.Out, "Scene item %s in scene %s is now hidden.\n", ctx.Style.Highlight(cmd.ItemName), ctx.Style.Highlight(cmd.SceneName))
}
return nil
}
@ -257,7 +126,7 @@ func getItemEnabled(client *goobs.Client, sceneName string, itemID int) (bool, e
// SceneItemToggleCmd provides a command to toggle the visibility of a scene item.
type SceneItemToggleCmd struct {
Group string `flag:"" help:"Parent group name."`
Parent string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -265,7 +134,7 @@ type SceneItemToggleCmd struct {
// Run executes the command to toggle the visibility of a scene item.
func (cmd *SceneItemToggleCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
if err != nil {
return err
}
@ -282,24 +151,12 @@ func (cmd *SceneItemToggleCmd) Run(ctx *context) error {
if err != nil {
return err
}
if itemEnabled {
fmt.Fprintf(
ctx.Out,
"Scene item %s in scene %s is now hidden.\n",
ctx.Style.Highlight(cmd.ItemName),
ctx.Style.Highlight(cmd.SceneName),
)
} else {
fmt.Fprintf(ctx.Out, "Scene item %s in scene %s is now visible.\n", ctx.Style.Highlight(cmd.ItemName), ctx.Style.Highlight(cmd.SceneName))
}
return nil
}
// SceneItemVisibleCmd provides a command to check the visibility of a scene item.
type SceneItemVisibleCmd struct {
Group string `flag:"" help:"Parent group name."`
Parent string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -307,7 +164,7 @@ type SceneItemVisibleCmd struct {
// Run executes the command to check the visibility of a scene item.
func (cmd *SceneItemVisibleCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
if err != nil {
return err
}
@ -318,126 +175,9 @@ func (cmd *SceneItemVisibleCmd) Run(ctx *context) error {
}
if itemEnabled {
fmt.Fprintf(
ctx.Out,
"Scene item %s in scene %s is visible.\n",
ctx.Style.Highlight(cmd.ItemName),
ctx.Style.Highlight(cmd.SceneName),
)
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is visible.\n", cmd.ItemName, cmd.SceneName)
} else {
fmt.Fprintf(ctx.Out, "Scene item %s in scene %s is hidden.\n", ctx.Style.Highlight(cmd.ItemName), ctx.Style.Highlight(cmd.SceneName))
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is hidden.\n", cmd.ItemName, cmd.SceneName)
}
return nil
}
// SceneItemTransformCmd provides a command to transform a scene item.
type SceneItemTransformCmd struct {
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
Group string `flag:"" help:"Parent group name."`
Alignment float64 `flag:"" help:"Alignment of the scene item."`
BoundsAlignment float64 `flag:"" help:"Bounds alignment of the scene item."`
BoundsHeight float64 `flag:"" help:"Bounds height of the scene item." default:"1.0"`
BoundsType string `flag:"" help:"Bounds type of the scene item." default:"OBS_BOUNDS_NONE"`
BoundsWidth float64 `flag:"" help:"Bounds width of the scene item." default:"1.0"`
CropToBounds bool `flag:"" help:"Whether to crop the scene item to bounds."`
CropBottom float64 `flag:"" help:"Crop bottom value of the scene item."`
CropLeft float64 `flag:"" help:"Crop left value of the scene item."`
CropRight float64 `flag:"" help:"Crop right value of the scene item."`
CropTop float64 `flag:"" help:"Crop top value of the scene item."`
PositionX float64 `flag:"" help:"X position of the scene item."`
PositionY float64 `flag:"" help:"Y position of the scene item."`
Rotation float64 `flag:"" help:"Rotation of the scene item."`
ScaleX float64 `flag:"" help:"X scale of the scene item."`
ScaleY float64 `flag:"" help:"Y scale of the scene item."`
}
// Run executes the command to transform a scene item.
func (cmd *SceneItemTransformCmd) Run(ctx *context) error {
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil {
return err
}
// Get the current transform of the scene item
resp, err := ctx.Client.SceneItems.GetSceneItemTransform(sceneitems.NewGetSceneItemTransformParams().
WithSceneName(sceneName).
WithSceneItemId(sceneItemID))
if err != nil {
return err
}
// Update the transform with the provided values
transform := resp.SceneItemTransform
if cmd.Alignment != 0 {
transform.Alignment = cmd.Alignment
}
if cmd.BoundsAlignment != 0 {
transform.BoundsAlignment = cmd.BoundsAlignment
}
if cmd.BoundsHeight != 0 {
transform.BoundsHeight = cmd.BoundsHeight
}
if cmd.BoundsType != "" {
transform.BoundsType = cmd.BoundsType
}
if cmd.BoundsWidth != 0 {
transform.BoundsWidth = cmd.BoundsWidth
}
if cmd.CropToBounds {
transform.CropToBounds = cmd.CropToBounds
}
if cmd.CropBottom != 0 {
transform.CropBottom = cmd.CropBottom
}
if cmd.CropLeft != 0 {
transform.CropLeft = cmd.CropLeft
}
if cmd.CropRight != 0 {
transform.CropRight = cmd.CropRight
}
if cmd.CropTop != 0 {
transform.CropTop = cmd.CropTop
}
if cmd.PositionX != 0 {
transform.PositionX = cmd.PositionX
}
if cmd.PositionY != 0 {
transform.PositionY = cmd.PositionY
}
if cmd.Rotation != 0 {
transform.Rotation = cmd.Rotation
}
if cmd.ScaleX != 0 {
transform.ScaleX = cmd.ScaleX
}
if cmd.ScaleY != 0 {
transform.ScaleY = cmd.ScaleY
}
_, err = ctx.Client.SceneItems.SetSceneItemTransform(sceneitems.NewSetSceneItemTransformParams().
WithSceneName(sceneName).
WithSceneItemId(sceneItemID).
WithSceneItemTransform(transform))
if err != nil {
return err
}
if cmd.Group != "" {
fmt.Fprintf(
ctx.Out,
"Scene item %s in group %s transformed.\n",
ctx.Style.Highlight(cmd.ItemName),
ctx.Style.Highlight(cmd.Group),
)
} else {
fmt.Fprintf(ctx.Out, "Scene item %s in scene %s transformed.\n", ctx.Style.Highlight(cmd.ItemName), ctx.Style.Highlight(cmd.SceneName))
}
return nil
}

View File

@ -1,29 +0,0 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestSceneItemList(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
cmd := &SceneItemListCmd{
SceneName: "gobs-test-scene",
}
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

@ -1,41 +0,0 @@
package main
import (
"fmt"
"path/filepath"
"github.com/andreykaipov/goobs/api/requests/sources"
)
// ScreenshotCmd provides commands to manage screenshots in OBS Studio.
type ScreenshotCmd struct {
Save ScreenshotSaveCmd `cmd:"" help:"Take a screenshot and save it to a file." aliases:"sv"`
}
// ScreenshotSaveCmd represents the command to save a screenshot of a source in OBS.
type ScreenshotSaveCmd struct {
SourceName string `arg:"" help:"Name of the source to take a screenshot of."`
FilePath string `arg:"" help:"Path to the file where the screenshot will be saved."`
Width float64 ` help:"Width of the screenshot in pixels." flag:"" default:"1920"`
Height float64 ` help:"Height of the screenshot in pixels." flag:"" default:"1080"`
Quality float64 ` help:"Quality of the screenshot (1-100)." flag:"" default:"-1"`
}
// Run executes the command to take a screenshot and save it to a file.
func (cmd *ScreenshotSaveCmd) Run(ctx *context) error {
_, err := ctx.Client.Sources.SaveSourceScreenshot(
sources.NewSaveSourceScreenshotParams().
WithSourceName(cmd.SourceName).
WithImageFormat(trimPrefix(filepath.Ext(cmd.FilePath), ".")).
WithImageFilePath(cmd.FilePath).
WithImageWidth(cmd.Width).
WithImageHeight(cmd.Height).
WithImageCompressionQuality(cmd.Quality),
)
if err != nil {
return fmt.Errorf("failed to take screenshot: %w", err)
}
fmt.Fprintf(ctx.Out, "Screenshot saved to %s.\n", ctx.Style.Highlight(cmd.FilePath))
return nil
}

View File

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

View File

@ -1,123 +0,0 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
)
func TestStreamStart(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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(500 * time.Millisecond) // Wait for the stream to start
}
func TestStreamStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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(500 * time.Millisecond) // Wait for the stream to stop
}
func TestStreamToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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(500 * time.Millisecond) // Wait for the stream to toggle
}

View File

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

View File

@ -1,62 +0,0 @@
package main
import (
"bytes"
"testing"
)
func TestStudioModeEnable(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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 := newContext(client, &out, StyleConfig{})
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())
}
}

192
style.go
View File

@ -1,192 +0,0 @@
// nolint: misspell
package main
import (
"fmt"
"os"
"github.com/charmbracelet/lipgloss"
)
// Style defines colours for the table styles.
type Style struct {
name string
border lipgloss.Color
oddRows lipgloss.Color
evenRows lipgloss.Color
highlight lipgloss.Color
}
// Highlight applies the highlight style to the given text.
func (s *Style) Highlight(text string) string {
return lipgloss.NewStyle().Foreground(s.highlight).Render(text)
}
func (s *Style) Error(text string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Render(text) // Red for errors
}
func newRedStyle() Style {
return Style{
name: "red",
border: lipgloss.Color("#D32F2F"), // Strong red for border
oddRows: lipgloss.Color("#FFCDD2"), // Very light red for odd rows
evenRows: lipgloss.Color("#EF9A9A"), // Light red for even rows
highlight: lipgloss.Color("#EF9A9A"), // Highlight in light red
}
}
func newMagentaStyle() Style {
return Style{
name: "magenta",
border: lipgloss.Color("#C2185B"), // Strong magenta for border
oddRows: lipgloss.Color("#F8BBD0"), // Very light magenta/pink for odd rows
evenRows: lipgloss.Color("#F48FB1"), // Light magenta/pink for even rows
highlight: lipgloss.Color("#F48FB1"), // Highlight in light magenta/pink
}
}
func newPurpleStyle() Style {
return Style{
name: "purple",
border: lipgloss.Color("#7B1FA2"), // Strong purple for border
oddRows: lipgloss.Color("#E1BEE7"), // Very light purple for odd rows
evenRows: lipgloss.Color("#CE93D8"), // Light purple for even rows
highlight: lipgloss.Color("#CE93D8"), // Highlight in light purple
}
}
func newBlueStyle() Style {
return Style{
name: "blue",
border: lipgloss.Color("#1976D2"), // Medium blue for border
oddRows: lipgloss.Color("#E3F2FD"), // Very light blue for odd rows
evenRows: lipgloss.Color("#BBDEFB"), // Light blue for even rows
highlight: lipgloss.Color("#1976D2"), // Highlight in medium blue
}
}
func newCyanStyle() Style {
return Style{
name: "cyan",
border: lipgloss.Color("#00BFCF"), // A strong cyan for border
oddRows: lipgloss.Color("#E0F7FA"), // Very light cyan for odd rows
evenRows: lipgloss.Color("#B2EBF2"), // Slightly darker light cyan for even rows
highlight: lipgloss.Color("#00BFCF"), // Highlight in strong cyan
}
}
func newGreenStyle() Style {
return Style{
name: "green",
border: lipgloss.Color("#43A047"), // Medium green for border
oddRows: lipgloss.Color("#E8F5E9"), // Very light green for odd rows
evenRows: lipgloss.Color("#C8E6C9"), // Light green for even rows
highlight: lipgloss.Color("#43A047"), // Highlight in medium green
}
}
func newYellowStyle() Style {
return Style{
name: "yellow",
border: lipgloss.Color("#FBC02D"), // Strong yellow for border
oddRows: lipgloss.Color("#FFF9C4"), // Very light yellow for odd rows
evenRows: lipgloss.Color("#FFF59D"), // Light yellow for even rows
highlight: lipgloss.Color("#FBC02D"), // Highlight in strong yellow
}
}
func newOrangeStyle() Style {
return Style{
name: "orange",
border: lipgloss.Color("#F57C00"), // Strong orange for border
oddRows: lipgloss.Color("#FFF3E0"), // Very light orange for odd rows
evenRows: lipgloss.Color("#FFE0B2"), // Light orange for even rows
highlight: lipgloss.Color("#F57C00"), // Highlight in strong orange
}
}
func newWhiteStyle() Style {
return Style{
name: "white",
border: lipgloss.Color("#FFFFFF"), // White for border
oddRows: lipgloss.Color("#F0F0F0"), // Very light grey for odd rows
evenRows: lipgloss.Color("#E0E0E0"), // Light grey for even rows
highlight: lipgloss.Color("#FFFFFF"), // Highlight in white
}
}
func newGreyStyle() Style {
return Style{
name: "grey",
border: lipgloss.Color("#9E9E9E"), // Medium grey for border
oddRows: lipgloss.Color("#F5F5F5"), // Very light grey for odd rows
evenRows: lipgloss.Color("#EEEEEE"), // Light grey for even rows
highlight: lipgloss.Color("#9E9E9E"), // Highlight in medium grey
}
}
func newNavyBlueStyle() Style {
return Style{
name: "navy",
border: lipgloss.Color("#001F3F"), // Navy blue for border
oddRows: lipgloss.Color("#CFE2F3"), // Very light blue for odd rows
evenRows: lipgloss.Color("#A9CCE3"), // Light blue for even rows
highlight: lipgloss.Color("#001F3F"), // Highlight in navy blue
}
}
func newBlackStyle() Style {
return Style{
name: "black",
border: lipgloss.Color("#000000"), // Black for border
oddRows: lipgloss.Color("#333333"), // Dark grey for odd rows
evenRows: lipgloss.Color("#444444"), // Slightly lighter dark grey for even rows
highlight: lipgloss.Color("#000000"), // Highlight in black
}
}
func styleFromFlag(cfg StyleConfig) *Style {
var style Style
switch cfg.Style {
case "red":
style = newRedStyle()
case "magenta":
style = newMagentaStyle()
case "purple":
style = newPurpleStyle()
case "blue":
style = newBlueStyle()
case "cyan":
style = newCyanStyle()
case "green":
style = newGreenStyle()
case "yellow":
style = newYellowStyle()
case "orange":
style = newOrangeStyle()
case "white":
style = newWhiteStyle()
case "grey":
style = newGreyStyle()
case "navy":
style = newNavyBlueStyle()
case "black":
style = newBlackStyle()
default:
err := os.Setenv("NO_COLOR", "1") // nolint: misspell
if err != nil {
// If we can't set NO_COLOR, we just log the error and continue
// This is a fallback to ensure that the application can still run
fmt.Fprintf(os.Stderr, "Error setting NO_COLOR: %v\n", err)
}
}
// If noBorder is true, we disable the border styling
if cfg.NoBorder {
style.border = ""
}
return &style
}

85
text.go
View File

@ -1,85 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/andreykaipov/goobs/api/requests/inputs"
)
// TextCmd provides commands for managing text inputs in OBS.
type TextCmd struct {
Current TextCurrentCmd `cmd:"current" help:"Display current text for a text input." aliases:"c"`
Update TextUpdateCmd `cmd:"update" help:"Update the text of a text input." aliases:"u"`
}
// TextCurrentCmd provides a command to display the current text of a text input.
type TextCurrentCmd struct {
InputName string `arg:"" help:"Name of the text source."`
}
// Run executes the command to display the current text of a text input.
func (cmd *TextCurrentCmd) Run(ctx *context) error {
resp, err := ctx.Client.Inputs.GetInputSettings(
inputs.NewGetInputSettingsParams().WithInputName(cmd.InputName),
)
if err != nil {
return fmt.Errorf("failed to get input settings: %w", err)
}
// Check if the input is a text input
kind := resp.InputKind
if !strings.HasPrefix(kind, "text_") {
return fmt.Errorf("input %s is of %s", cmd.InputName, kind)
}
currentText, ok := resp.InputSettings["text"]
if !ok {
return fmt.Errorf("input %s does not have a 'text' setting", cmd.InputName)
}
if currentText == "" {
currentText = "(empty)"
}
fmt.Fprintf(
ctx.Out,
"Current text for source %s: %s\n",
ctx.Style.Highlight(cmd.InputName),
currentText,
)
return nil
}
// TextUpdateCmd provides a command to update the text of a text input.
type TextUpdateCmd struct {
InputName string `arg:"" help:"Name of the text source."`
NewText string `arg:"" help:"New text to set for the source." default:""`
}
// Run executes the command to update the text of a text input.
func (cmd *TextUpdateCmd) Run(ctx *context) error {
resp, err := ctx.Client.Inputs.GetInputSettings(
inputs.NewGetInputSettingsParams().WithInputName(cmd.InputName),
)
if err != nil {
return fmt.Errorf("failed to get input settings: %w", err)
}
// Check if the input is a text input
kind := resp.InputKind
if !strings.HasPrefix(kind, "text_") {
return fmt.Errorf("input %s is of %s", cmd.InputName, kind)
}
if _, err := ctx.Client.Inputs.SetInputSettings(&inputs.SetInputSettingsParams{
InputName: &cmd.InputName,
InputSettings: map[string]any{"text": &cmd.NewText},
}); err != nil {
return fmt.Errorf("failed to update text for source %s: %w", cmd.InputName, err)
}
if cmd.NewText == "" {
cmd.NewText = "(empty)"
}
fmt.Fprintf(ctx.Out, "Updated text for source %s to: %s\n", ctx.Style.Highlight(cmd.InputName), cmd.NewText)
return nil
}

38
util.go
View File

@ -1,38 +0,0 @@
// Package util provides utility functions for the application.
package main
import (
"os"
"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 {
if os.Getenv("NO_COLOR") != "" { // nolint: misspell
return "✓"
}
return "✅"
}
if os.Getenv("NO_COLOR") != "" { // nolint: misspell
return "✗"
}
return "❌"
}
func trimPrefix(s, prefix string) string {
if strings.HasPrefix(s, prefix) {
return s[len(prefix):]
}
return s
}

View File

@ -1,20 +0,0 @@
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

@ -4,11 +4,11 @@ import (
"fmt"
)
// ObsVersionCmd handles the version command.
type ObsVersionCmd struct{} // size = 0x0
// VersionCmd handles the version command.
type VersionCmd struct{} // size = 0x0
// Run executes the command to get the OBS client version.
func (cmd *ObsVersionCmd) Run(ctx *context) error {
func (cmd *VersionCmd) Run(ctx *context) error {
version, err := ctx.Client.General.GetVersion()
if err != nil {
return err

View File

@ -1,27 +0,0 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestVersion(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := newContext(client, &out, StyleConfig{})
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.
type VirtualCamCmd struct {
Start VirtualCamStartCmd `help:"Start virtual camera." cmd:"" aliases:"s"`
Stop VirtualCamStopCmd `help:"Stop virtual camera." cmd:"" aliases:"st"`
Toggle VirtualCamToggleCmd `help:"Toggle virtual camera." cmd:"" aliases:"tg"`
Status VirtualCamStatusCmd `help:"Get virtual camera status." cmd:"" aliases:"ss"`
Start StartVirtualCamCmd `help:"Start virtual camera." cmd:"" aliases:"s"`
Stop StopVirtualCamCmd `help:"Stop virtual camera." cmd:"" aliases:"st"`
Toggle ToggleVirtualCamCmd `help:"Toggle virtual camera." cmd:"" aliases:"tg"`
Status StatusVirtualCamCmd `help:"Get virtual camera status." cmd:"" aliases:"ss"`
}
// VirtualCamStartCmd starts the virtual camera.
type VirtualCamStartCmd struct{} // size = 0x0
// StartVirtualCamCmd starts the virtual camera.
type StartVirtualCamCmd struct{} // size = 0x0
// Run executes the command to start the virtual camera.
func (c *VirtualCamStartCmd) Run(ctx *context) error {
func (c *StartVirtualCamCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StartVirtualCam()
if err != nil {
return fmt.Errorf("failed to start virtual camera: %w", err)
@ -25,11 +25,11 @@ func (c *VirtualCamStartCmd) Run(ctx *context) error {
return nil
}
// VirtualCamStopCmd stops the virtual camera.
type VirtualCamStopCmd struct{} // size = 0x0
// StopVirtualCamCmd stops the virtual camera.
type StopVirtualCamCmd struct{} // size = 0x0
// Run executes the command to stop the virtual camera.
func (c *VirtualCamStopCmd) Run(ctx *context) error {
func (c *StopVirtualCamCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StopVirtualCam()
if err != nil {
return fmt.Errorf("failed to stop virtual camera: %w", err)
@ -38,29 +38,23 @@ func (c *VirtualCamStopCmd) Run(ctx *context) error {
return nil
}
// VirtualCamToggleCmd toggles the virtual camera.
type VirtualCamToggleCmd struct{} // size = 0x0
// ToggleVirtualCamCmd toggles the virtual camera.
type ToggleVirtualCamCmd struct{} // size = 0x0
// Run executes the command to toggle the virtual camera.
func (c *VirtualCamToggleCmd) Run(ctx *context) error {
resp, err := ctx.Client.Outputs.ToggleVirtualCam()
func (c *ToggleVirtualCamCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.ToggleVirtualCam()
if err != nil {
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
}
// VirtualCamStatusCmd retrieves the status of the virtual camera.
type VirtualCamStatusCmd struct{} // size = 0x0
// StatusVirtualCamCmd retrieves the status of the virtual camera.
type StatusVirtualCamCmd struct{} // size = 0x0
// Run executes the command to get the status of the virtual camera.
func (c *VirtualCamStatusCmd) Run(ctx *context) error {
func (c *StatusVirtualCamCmd) Run(ctx *context) error {
status, err := ctx.Client.Outputs.GetVirtualCamStatus()
if err != nil {
return fmt.Errorf("failed to get virtual camera status: %w", err)