Compare commits

...

100 Commits
v0.1.0 ... main

Author SHA1 Message Date
9eb6c8a282 use red in example 2025-06-27 13:53:55 +01:00
eb30cae5b7 add 0.13.3 to CHANGELOG 2025-06-27 13:51:42 +01:00
e6c03a2c92 add style to ToC 2025-06-27 13:49:34 +01:00
f6b82383f9 add ToC to README
move style section.

add imgs
2025-06-27 13:48:17 +01:00
55f3b0c981 fix item id alignment 2025-06-27 13:38:51 +01:00
7da80a1ad2 fix patch number... 2025-06-26 12:15:36 +01:00
ea4ca2aeb9 fix patch number 2025-06-26 12:14:40 +01:00
d2f0a64180 changes to help:
- print help on error
- print in compact mode

move version + VersionFlag into main
store version in kong.Vars

add 0.13.3 to CHANGELOG
2025-06-26 12:13:32 +01:00
f01fd0ca84 check if output is paused before attempting record split/chapter.
add note to changelog about supported file format for record chapter.
2025-06-23 08:28:49 +01:00
10d50df445 add 0.13.0 to CHANGELOG 2025-06-23 08:03:52 +01:00
06cefe58ed add record split and record chapter commands 2025-06-23 08:02:31 +01:00
7cd1c78f6a add --no-border to 0.12.1 Added 2025-06-21 20:29:42 +01:00
842d98edd3 upd Style section in README
add example
2025-06-21 20:27:46 +01:00
930b387b85 newContext now requires a StyleConfig, update tests. 2025-06-21 20:27:17 +01:00
2ab1c5bfc3 if --no-border is set, disable the border styling 2025-06-21 20:25:48 +01:00
08f23fe47d add --no-border flag
use it to disable table border styling
2025-06-21 20:25:19 +01:00
bbc6aec230 add note about style env var in README
add 0.12.0 to CHANGELOG
2025-06-21 06:59:36 +01:00
5d0ed2a166 fix --style shortname
add Style section to README
2025-06-21 06:47:25 +01:00
62579b1c5e upd docstrings 2025-06-21 06:44:16 +01:00
9ed00cd67c return colourless check/cross marks if NO_COLOR is set 2025-06-21 06:44:08 +01:00
69bfaf694d add context factory method to tests
update scene_test outputs
2025-06-21 06:43:38 +01:00
7147c3f1ca style tables and add out/err highlights
improve projector open error message if an invalid monitor index is passed.
it now prints the monitor name and suggests `gobs-cli prj ls-m`

improve error message for sceneitem commands if a scene item in a group is queried without the --group flag.
2025-06-21 06:41:12 +01:00
d699939298 add --style flag to root command
define styles
2025-06-21 06:36:05 +01:00
82c0756dde add 0.11.0 to CHANGELOG 2025-06-20 04:41:26 +01:00
4395c981c6 update README with --uuid flags 2025-06-20 04:41:18 +01:00
dc043b5847 add --uuid flag to scene list, sceneitem list and input list 2025-06-20 04:40:56 +01:00
c8a055fa28 add group tags, makes --help more legible 2025-06-19 18:00:10 +01:00
d9c0e40d8f add 0.10.3 to CHANGELOG 2025-06-07 20:10:56 +01:00
42ab45b9fb upd flags for input list command 2025-06-07 19:19:31 +01:00
27c3c5369b add --fmpeg and --vlc flags to filter list
add Muted column to table
2025-06-07 19:19:10 +01:00
0a0c75ae51 sceneitem list now prints enabled mark
patch bump
2025-06-04 14:43:16 +01:00
cf5da68137 add filter list fixes to 0.10.1 section 2025-06-04 14:34:09 +01:00
14d9feb43e add missing SourceName arg to filter list 2025-06-04 14:32:32 +01:00
8204d6520d filter list SourceName arg now defaults to current scene
filter list now prints defaults for unchanged values
2025-06-04 14:32:08 +01:00
1d590eb788 add 0.10.0 to CHANGELOG 2025-06-04 12:57:32 +01:00
29fe6fedfb add ScreenshotCmd 2025-06-04 12:57:17 +01:00
ee47832cd6 add ScreenshotCmd to README 2025-06-04 12:56:00 +01:00
17b8e53da3 read version from build info if version was not injected at build time (go install)
inject version from tag at build time for local builds
2025-06-03 16:35:19 +01:00
92761ab1b3 upd link to --version 2025-06-03 13:12:22 +01:00
4446784709 add short names for root options
fix flag example in readme
2025-06-03 12:33:37 +01:00
89a5add7ad upd VersionCmd test 2025-06-02 18:12:43 +01:00
878ecbd33e add 0.9.0 to CHANGELOG 2025-06-02 18:12:06 +01:00
18a90e727f define main.version in local builds 2025-06-02 18:11:56 +01:00
95ebb2afb6 add VersionFlag to CLI struct
upd VersionCmd struct
2025-06-02 18:11:37 +01:00
666b4cf744 add VersionFlag
upd VersionCmd
2025-06-02 18:10:07 +01:00
9ee6fa9e34 typo 2025-05-29 20:03:37 +01:00
e5223fbdfd add 0.8.2 to CHANGELOG 2025-05-29 15:31:53 +01:00
c22ab4384d upd README to reflect changes to --parent option 2025-05-29 15:28:21 +01:00
93a3d3e49f print a more useful sceneitem list table
rename --parent option to --group
2025-05-29 15:28:02 +01:00
2228574837 fix err message test 2025-05-28 15:50:02 +01:00
8f1d42b677 ensure studio mode disabled at end of tests 2025-05-28 15:39:12 +01:00
620adf7e98 return errors if required
upd tests to reflect changes
2025-05-28 15:38:59 +01:00
4a7b8a074a check current active state before starting/stopping recording
return appropriate errors if required

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

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

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

9
.gitignore vendored
View File

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

View File

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

View File

@ -5,6 +5,151 @@ 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.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 Normal file
View File

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

312
README.md
View File

@ -4,40 +4,100 @@ 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
Pass `--host`, `--port` and `--password` as flags to the root command, for example:
- --host/-H: Websocket host
- --port/-P Websocket port
- --password/-p: Websocket password
- --timeout/-T: Websocket timeout
- --version/-v: Print the gobs-cli version
Pass `--host`, `--port` and `--password` as flags on the root command, for example:
```console
gobs-cli --host=localhost --port=4455 --password=<websocket password> --help
gobs-cli --host localhost --port 4455 --password 'websocket password' --help
```
#### Environment Variables
Load connection details from your environment:
Store and load environment variables from:
```bash
#!/usr/bin/env bash
- A `.env` file in the cwd
- $XDG_CONFIG_HOME / gobs-cli / config.env (see [os.UserConfigDir][userconfigdir])
export OBS_HOST=localhost
export OBS_PORT=4455
export OBS_PASSWORD=<websocket password>
export OBS_TIMEOUT=5
```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
```
## Commands
### VersionCmd
### ObsVersionCmd
- Print OBS client and websocket version.
```console
gobs-cli version
gobs-cli obs-version
```
### SceneCmd
- list: List all scenes.
- flags:
*optional*
- --UUID: Display UUIDs of scenes.
```console
gobs-cli scene list
@ -71,9 +131,18 @@ 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
```
@ -81,7 +150,7 @@ gobs-cli sceneitem list LIVE
- flags:
*optional*
- --parent: Parent group name.
- --group: Parent group name.
- args: SceneName ItemName
```console
@ -92,7 +161,7 @@ gobs-cli sceneitem show START "Colour Source"
- flags:
*optional*
- --parent: Parent group name.
- --group: Parent group name.
- args: SceneName ItemName
```console
@ -103,30 +172,65 @@ gobs-cli sceneitem hide START "Colour Source"
- flags:
*optional*
- --parent: Parent group name.
- --group: Parent group name.
- args: SceneName ItemName
```console
gobs-cli sceneitem toggle --parent=test_group START "Colour Source 3"
gobs-cli sceneitem toggle --group=test_group START "Colour Source 3"
```
- visible: Get scene item visibility.
- flags:
*optional*
- --parent: Parent group name.
- --group: Parent group name.
- args: SceneName ItemName
```console
gobs-cli sceneitem visible --parent=test_group START "Colour Source 4"
gobs-cli sceneitem visible --group=test_group START "Colour Source 4"
```
- transform: Transform scene item.
- 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"
```
### GroupCmd
- list: List all groups.
*optional*
- args: SceneName
- defaults to current scene
```console
gobs-cli group list
gobs-cli group list START
```
@ -167,6 +271,9 @@ 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
@ -233,6 +340,34 @@ 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.
@ -273,7 +408,7 @@ gobs-cli scenecollection list
gobs-cli scenecollection current
```
- switch: "Switch scene collection.
- switch: Switch scene collection.
- args: Name
```console
@ -305,21 +440,21 @@ gobs-cli profile current
- args: Name
```console
gobs-cli profile switch test-collection
gobs-cli profile switch test-profile
```
- create: Create profile.
- args: Name
```console
gobs-cli profile create test-collection
gobs-cli profile create test-profile
```
- remove: Remove profile.
- args: Name
```console
gobs-cli profile create test-collection
gobs-cli profile remove test-profile
```
### ReplayBufferCmd
@ -336,6 +471,12 @@ gobs-cli replaybuffer start
gobs-cli replaybuffer stop
```
- toggle: Toggle replay buffer.
```console
gobs-cli replaybuffer toggle
```
- status: Get replay buffer status.
```console
@ -399,3 +540,132 @@ 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/

17
Taskfile.man.yaml Normal file
View File

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

View File

@ -1,9 +1,14 @@
version: '3'
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:
@ -32,13 +37,13 @@ tasks:
build-windows:
desc: Build the gobs-cli project for Windows
cmds:
- GOOS=windows GOARCH=amd64 go build -o {{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe
- GOOS=windows GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_windows_amd64.exe
internal: true
build-linux:
desc: Build the gobs-cli project for Linux
cmds:
- GOOS=linux GOARCH=amd64 go build -o {{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64
- GOOS=linux GOARCH=amd64 go build -ldflags "-X 'main.version={{.VERSION}}'" -o {{.BIN_DIR}}/{{.PROGRAM}}_linux_amd64
internal: true
test:

218
filter.go Normal file
View File

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

67
filter_test.go Normal file
View File

@ -0,0 +1,67 @@
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",
}
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,14 +4,32 @@ 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,12 +2,30 @@ 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=
@ -16,15 +34,41 @@ 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,6 +4,8 @@ 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.
@ -17,21 +19,64 @@ 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."`
SceneName string `arg:"" help:"Name of the scene to list groups from." default:""`
}
// 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 {
fmt.Fprintf(ctx.Out, "Group ID: %d, Source Name: %s\n", item.SceneItemID, item.SourceName)
t.Row(fmt.Sprintf("%d", item.SceneItemID), item.SourceName, getEnabledMark(item.SceneItemEnabled))
found = true
}
}
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
}
@ -59,13 +104,17 @@ 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", cmd.GroupName)
fmt.Fprintf(ctx.Out, "Group %s is now shown.\n", ctx.Style.Highlight(cmd.GroupName))
found = true
break
}
}
if !found {
return fmt.Errorf("group '%s' not found", cmd.GroupName)
return fmt.Errorf(
"group %s not found in scene %s",
ctx.Style.Error(cmd.GroupName),
ctx.Style.Error(cmd.SceneName),
)
}
return nil
}
@ -94,13 +143,17 @@ 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", cmd.GroupName)
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", ctx.Style.Highlight(cmd.GroupName))
found = true
break
}
}
if !found {
return fmt.Errorf("group '%s' not found", cmd.GroupName)
return fmt.Errorf(
"group %s not found in scene %s",
ctx.Style.Error(cmd.GroupName),
ctx.Style.Error(cmd.SceneName),
)
}
return nil
}
@ -131,16 +184,20 @@ 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", cmd.GroupName)
fmt.Fprintf(ctx.Out, "Group %s is now shown.\n", ctx.Style.Highlight(cmd.GroupName))
} else {
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", cmd.GroupName)
fmt.Fprintf(ctx.Out, "Group %s is now hidden.\n", ctx.Style.Highlight(cmd.GroupName))
}
found = true
break
}
}
if !found {
return fmt.Errorf("group '%s' not found", cmd.GroupName)
return fmt.Errorf(
"group %s not found in scene %s",
ctx.Style.Error(cmd.GroupName),
ctx.Style.Error(cmd.SceneName),
)
}
return nil
@ -162,12 +219,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", cmd.GroupName)
fmt.Fprintf(ctx.Out, "Group %s is shown.\n", ctx.Style.Highlight(cmd.GroupName))
} else {
fmt.Fprintf(ctx.Out, "Group %s is hidden.\n", cmd.GroupName)
fmt.Fprintf(ctx.Out, "Group %s is hidden.\n", ctx.Style.Highlight(cmd.GroupName))
}
return nil
}
}
return fmt.Errorf("group '%s' not found", cmd.GroupName)
return fmt.Errorf("group %s not found in scene %s", ctx.Style.Error(cmd.GroupName), ctx.Style.Error(cmd.SceneName))
}

118
group_test.go Normal file
View File

@ -0,0 +1,118 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestGroupList(t *testing.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) {
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) {
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) {
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())
}
}

93
hotkey.go Normal file
View File

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

BIN
img/coloured-border.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
img/coloured-no-border.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
img/colourless.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

102
input.go
View File

@ -1,10 +1,14 @@
// 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.
@ -20,6 +24,9 @@ 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.
@ -28,21 +35,90 @@ 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 {
if cmd.Input && strings.Contains(input.InputKind, "input") {
fmt.Fprintln(ctx.Out, "Input:", input.InputName)
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.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)
} else {
muteMark = getEnabledMark(resp.InputMuted)
}
if !cmd.Input && !cmd.Output && !cmd.Colour {
fmt.Fprintln(ctx.Out, "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 !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
}
@ -60,7 +136,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", cmd.InputName)
fmt.Fprintf(ctx.Out, "Muted input: %s\n", ctx.Style.Highlight(cmd.InputName))
return nil
}
@ -78,7 +154,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", cmd.InputName)
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", ctx.Style.Highlight(cmd.InputName))
return nil
}
@ -106,9 +182,9 @@ func (cmd *InputToggleCmd) Run(ctx *context) error {
}
if newMuteState {
fmt.Fprintf(ctx.Out, "Muted input: %s\n", cmd.InputName)
fmt.Fprintf(ctx.Out, "Muted input: %s\n", ctx.Style.Highlight(cmd.InputName))
} else {
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", cmd.InputName)
fmt.Fprintf(ctx.Out, "Unmuted input: %s\n", ctx.Style.Highlight(cmd.InputName))
}
return nil
}

128
input_test.go Normal file
View File

@ -0,0 +1,128 @@
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",
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
output := out.String()
for _, input := range expectedInputs {
if !strings.Contains(output, input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, output)
}
}
}
func TestInputListFilterInput(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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",
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
for _, filteredOut := range expectedFilteredOut {
if strings.Contains(out.String(), filteredOut) {
t.Fatalf("Expected output to NOT contain '%s', got '%s'", filteredOut, out.String())
}
}
}
func TestInputListFilterOutput(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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",
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
for _, filteredOut := range expectedFilteredOut {
if strings.Contains(out.String(), filteredOut) {
t.Fatalf("Expected output to NOT contain '%s', got '%s'", filteredOut, out.String())
}
}
}
func TestInputListFilterColour(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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{
"Colour Source",
"Colour Source 2",
"Colour Source 3",
}
for _, input := range expectedInputs {
if !strings.Contains(out.String(), input) {
t.Fatalf("Expected output to contain '%s', got '%s'", input, out.String())
}
}
}

123
main.go
View File

@ -7,63 +7,120 @@ 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"`
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"`
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"`
}
// 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 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"`
}
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"`
// CLI is the main command line interface structure.
// It embeds ObsConfig and StyleConfig to provide configuration options.
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"`
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"`
}
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() {
var client *goobs.Client
cli := cli{}
ctx := kong.Parse(
&cli,
kong.Name("GOBS-CLI"),
kong.Description("A command line tool to interact with OBS Websocket."),
)
client, err := connectObs(cli.ObsConfig)
userConfigDir, err := os.UserConfigDir()
if err != nil {
ctx.FatalIfErrorf(err)
fmt.Fprintf(os.Stderr, "Error getting user config directory: %v\n", err)
os.Exit(1)
}
ctx.Bind(&context{
Client: client,
Out: os.Stdout,
var cli CLI
ctx := kong.Parse(
&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)
ctx.FatalIfErrorf(err)
ctx.Bind(newContext(client, os.Stdout, cli.StyleConfig))
ctx.FatalIfErrorf(run(ctx, client))
}

136
main_test.go Normal file
View File

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

View File

@ -5,55 +5,85 @@ 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 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"`
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"`
}
// ListProfileCmd provides a command to list all profiles.
type ListProfileCmd struct{} // size = 0x0
// ProfileListCmd provides a command to list all profiles.
type ProfileListCmd struct{} // size = 0x0
// Run executes the command to list all profiles.
func (cmd *ListProfileCmd) Run(ctx *context) error {
// nolint: misspell
func (cmd *ProfileListCmd) 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 {
fmt.Fprintln(ctx.Out, profile)
var enabledMark string
if profile == profiles.CurrentProfileName {
enabledMark = getEnabledMark(true)
}
t.Row(profile, enabledMark)
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
// CurrentProfileCmd provides a command to get the current profile.
type CurrentProfileCmd struct{} // size = 0x0
// ProfileCurrentCmd provides a command to get the current profile.
type ProfileCurrentCmd struct{} // size = 0x0
// Run executes the command to get the current profile.
func (cmd *CurrentProfileCmd) Run(ctx *context) error {
func (cmd *ProfileCurrentCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
fmt.Fprintf(ctx.Out, "Current profile: %s\n", profiles.CurrentProfileName)
fmt.Fprintf(ctx.Out, "Current profile: %s\n", ctx.Style.Highlight(profiles.CurrentProfileName))
return nil
}
// SwitchProfileCmd provides a command to switch to a different profile.
type SwitchProfileCmd struct {
// ProfileSwitchCmd provides a command to switch to a different profile.
type ProfileSwitchCmd 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 *SwitchProfileCmd) Run(ctx *context) error {
func (cmd *ProfileSwitchCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
@ -61,71 +91,78 @@ func (cmd *SwitchProfileCmd) Run(ctx *context) error {
current := profiles.CurrentProfileName
if current == cmd.Name {
return nil
return fmt.Errorf("already using profile %s", ctx.Style.Error(cmd.Name))
}
_, err = ctx.Client.Config.SetCurrentProfile(config.NewSetCurrentProfileParams().WithProfileName(cmd.Name))
if err != nil {
return err
return fmt.Errorf("failed to switch to profile %s: %w", ctx.Style.Error(cmd.Name), err)
}
fmt.Fprintf(ctx.Out, "Switched from profile %s to %s\n", current, cmd.Name)
fmt.Fprintf(
ctx.Out,
"Switched from profile %s to %s\n",
ctx.Style.Highlight(current),
ctx.Style.Highlight(cmd.Name),
)
return nil
}
// CreateProfileCmd provides a command to create a new profile.
type CreateProfileCmd struct {
// ProfileCreateCmd provides a command to create a new profile.
type ProfileCreateCmd struct {
Name string `arg:"" help:"Name of the profile to create." required:""`
}
// Run executes the command to create a new profile.
func (cmd *CreateProfileCmd) Run(ctx *context) error {
func (cmd *ProfileCreateCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
if slices.Contains(profiles.Profiles, cmd.Name) {
return fmt.Errorf("profile %s already exists", cmd.Name)
return fmt.Errorf("profile %s already exists", ctx.Style.Error(cmd.Name))
}
_, err = ctx.Client.Config.CreateProfile(config.NewCreateProfileParams().WithProfileName(cmd.Name))
if err != nil {
return err
return fmt.Errorf("failed to create profile %s: %w", ctx.Style.Error(cmd.Name), err)
}
fmt.Fprintf(ctx.Out, "Created profile: %s\n", cmd.Name)
fmt.Fprintf(ctx.Out, "Created profile: %s\n", ctx.Style.Highlight(cmd.Name))
return nil
}
// RemoveProfileCmd provides a command to remove an existing profile.
type RemoveProfileCmd struct {
// ProfileRemoveCmd provides a command to remove an existing profile.
type ProfileRemoveCmd struct {
Name string `arg:"" help:"Name of the profile to delete." required:""`
}
// Run executes the command to remove an existing profile.
func (cmd *RemoveProfileCmd) Run(ctx *context) error {
func (cmd *ProfileRemoveCmd) Run(ctx *context) error {
profiles, err := ctx.Client.Config.GetProfileList()
if err != nil {
return err
}
if !slices.Contains(profiles.Profiles, cmd.Name) {
return fmt.Errorf("profile %s does not exist", cmd.Name)
return fmt.Errorf("profile %s does not exist", ctx.Style.Error(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", cmd.Name)
return fmt.Errorf("cannot delete current profile %s", ctx.Style.Error(cmd.Name))
}
_, err = ctx.Client.Config.RemoveProfile(config.NewRemoveProfileParams().WithProfileName(cmd.Name))
if err != nil {
return err
return fmt.Errorf("failed to delete profile %s: %w", ctx.Style.Error(cmd.Name), err)
}
fmt.Fprintf(ctx.Out, "Deleted profile: %s\n", cmd.Name)
fmt.Fprintf(ctx.Out, "Deleted profile: %s\n", ctx.Style.Highlight(cmd.Name))
return nil
}

111
projector.go Normal file
View File

@ -0,0 +1,111 @@
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,6 +2,9 @@ package main
import (
"fmt"
"github.com/andreykaipov/goobs/api/requests/config"
"github.com/andreykaipov/goobs/api/requests/record"
)
// RecordCmd handles the recording commands.
@ -9,8 +12,12 @@ 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.
@ -18,7 +25,19 @@ type RecordStartCmd struct{} // size = 0x0
// Run executes the command to start recording.
func (cmd *RecordStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Record.StartRecord()
status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
if status.OutputActive {
if status.OutputPaused {
return fmt.Errorf("recording is already in progress and paused")
}
return fmt.Errorf("recording is already in progress")
}
_, err = ctx.Client.Record.StartRecord()
if err != nil {
return err
}
@ -31,11 +50,24 @@ type RecordStopCmd struct{} // size = 0x0
// Run executes the command to stop recording.
func (cmd *RecordStopCmd) Run(ctx *context) error {
_, err := ctx.Client.Record.StopRecord()
status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
if !status.OutputActive {
return fmt.Errorf("recording is not in progress")
}
resp, err := ctx.Client.Record.StopRecord()
if err != nil {
return err
}
fmt.Fprintf(
ctx.Out,
"%s",
fmt.Sprintf("Recording stopped successfully. Output file: %s\n", ctx.Style.Highlight(resp.OutputPath)),
)
return nil
}
@ -44,25 +76,39 @@ type RecordToggleCmd struct{} // size = 0x0
// Run executes the command to toggle recording.
func (cmd *RecordToggleCmd) Run(ctx *context) error {
// Check if recording is in progress
status, err := ctx.Client.Record.ToggleRecord()
if err != nil {
return err
}
if status.OutputActive {
fmt.Fprintln(ctx.Out, "Recording started successfully.")
} else {
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
}
return nil
}
// RecordStatusCmd shows the recording status.
type RecordStatusCmd struct{} // size = 0x0
// Run executes the command to show recording status.
func (cmd *RecordStatusCmd) Run(ctx *context) error {
status, err := ctx.Client.Record.GetRecordStatus()
if err != nil {
return err
}
if status.OutputActive {
_, err = ctx.Client.Record.StopRecord()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, "Recording stopped successfully.")
if status.OutputPaused {
fmt.Fprintln(ctx.Out, "Recording is paused.")
} else {
_, err = ctx.Client.Record.StartRecord()
if err != nil {
return err
fmt.Fprintln(ctx.Out, "Recording is in progress.")
}
fmt.Fprintln(ctx.Out, "Recording started successfully.")
} else {
fmt.Fprintln(ctx.Out, "Recording is not in progress.")
}
return nil
}
@ -117,3 +163,95 @@ 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
}

126
record_test.go Normal file
View File

@ -0,0 +1,126 @@
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(1 * time.Second) // Wait for the recording to start
}
func TestRecordStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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(1 * time.Second) // Wait for the recording to stop
}
func TestRecordToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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)
}
time.Sleep(1 * time.Second) // Wait for a second to ensure toggle has taken effect
if active {
if out.String() != "Recording stopped successfully.\n" {
t.Fatalf("Expected output to be 'Recording stopped successfully.', got '%s'", out.String())
}
} else {
if out.String() != "Recording started successfully.\n" {
t.Fatalf("Expected output to be 'Recording started successfully.', got '%s'", out.String())
}
}
}

View File

@ -8,6 +8,7 @@ import (
type ReplayBufferCmd struct {
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"`
}
@ -18,7 +19,11 @@ 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()
return err
if err != nil {
return fmt.Errorf("failed to start replay buffer: %w", err)
}
fmt.Fprintln(ctx.Out, "Replay buffer started.")
return nil
}
// ReplayBufferStopCmd stops the replay buffer.
@ -27,9 +32,31 @@ 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.
type ReplayBufferStatusCmd struct{} // size = 0x0

76
replaybuffer_test.go Normal file
View File

@ -0,0 +1,76 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestReplayBufferStart(t *testing.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())
}
}
func TestReplayBufferStop(t *testing.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())
}
}
func TestReplayBufferToggle(t *testing.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())
}
}
}

View File

@ -5,6 +5,8 @@ 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.
@ -15,19 +17,64 @@ type SceneCmd struct {
}
// SceneListCmd provides a command to list all scenes.
type SceneListCmd struct{} // size = 0x0
type SceneListCmd struct {
UUID bool `flag:"" help:"Display UUIDs of scenes."`
}
// 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 {
fmt.Fprintln(ctx.Out, scene.SceneName)
var activeMark string
if scene.SceneName == currentScene.SceneName {
activeMark = getEnabledMark(true)
}
if cmd.UUID {
t.Row(scene.SceneName, activeMark, scene.SceneUuid)
} else {
t.Row(scene.SceneName, activeMark)
}
}
fmt.Fprintln(ctx.Out, t.Render())
return nil
}
@ -43,13 +90,13 @@ func (cmd *SceneCurrentCmd) Run(ctx *context) error {
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, scene.SceneName)
fmt.Fprintf(ctx.Out, "Current preview scene: %s\n", ctx.Style.Highlight(scene.SceneName))
} else {
scene, err := ctx.Client.Scenes.GetCurrentProgramScene()
if err != nil {
return err
}
fmt.Fprintln(ctx.Out, scene.SceneName)
fmt.Fprintf(ctx.Out, "Current program scene: %s\n", ctx.Style.Highlight(scene.SceneName))
}
return nil
}
@ -69,7 +116,7 @@ func (cmd *SceneSwitchCmd) Run(ctx *context) error {
return err
}
fmt.Fprintln(ctx.Out, "Switched to preview scene:", cmd.NewScene)
fmt.Fprintf(ctx.Out, "Switched to preview scene: %s\n", ctx.Style.Highlight(cmd.NewScene))
} else {
_, err := ctx.Client.Scenes.SetCurrentProgramScene(scenes.NewSetCurrentProgramSceneParams().
WithSceneName(cmd.NewScene))
@ -77,7 +124,7 @@ func (cmd *SceneSwitchCmd) Run(ctx *context) error {
return err
}
fmt.Fprintln(ctx.Out, "Switched to program scene:", cmd.NewScene)
fmt.Fprintf(ctx.Out, "Switched to program scene: %s\n", ctx.Style.Highlight(cmd.NewScene))
}
return nil
}

51
scene_test.go Normal file
View File

@ -0,0 +1,51 @@
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\n" {
t.Fatalf("Expected output to be 'Current program scene: gobs-test', 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"
cmdSwitch := &SceneSwitchCmd{
NewScene: "gobs-test",
}
err := cmdSwitch.Run(context)
if err != nil {
t.Fatalf("Failed to switch to scene: %v", err)
}
// Reset output buffer for the next command
out.Reset()
cmdCurrent := &SceneCurrentCmd{}
err = cmdCurrent.Run(context)
if err != nil {
t.Fatalf("Failed to get current scene: %v", err)
}
if out.String() != "Current program scene: gobs-test\n" {
t.Fatalf("Expected output to be 'Current program scene: gobs-test', got '%s'", out.String())
}
}

View File

@ -4,38 +4,61 @@ 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 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"`
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"`
}
// ListSceneCollectionCmd provides a command to list all scene collections.
type ListSceneCollectionCmd struct{} // size = 0x0
// SceneCollectionListCmd provides a command to list all scene collections.
type SceneCollectionListCmd struct{} // size = 0x0
// Run executes the command to list all scene collections.
func (cmd *ListSceneCollectionCmd) Run(ctx *context) error {
// nolint: misspell
func (cmd *SceneCollectionListCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil {
return fmt.Errorf("failed to get scene collection list: %w", err)
}
for _, collection := range collections.SceneCollections {
fmt.Fprintln(ctx.Out, collection)
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, t.Render())
return nil
}
// CurrentSceneCollectionCmd provides a command to get the current scene collection.
type CurrentSceneCollectionCmd struct{} // size = 0x0
// SceneCollectionCurrentCmd provides a command to get the current scene collection.
type SceneCollectionCurrentCmd struct{} // size = 0x0
// Run executes the command to get the current scene collection.
func (cmd *CurrentSceneCollectionCmd) Run(ctx *context) error {
func (cmd *SceneCollectionCurrentCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil {
return fmt.Errorf("failed to get scene collection list: %w", err)
@ -45,13 +68,13 @@ func (cmd *CurrentSceneCollectionCmd) Run(ctx *context) error {
return nil
}
// SwitchSceneCollectionCmd provides a command to switch to a different scene collection.
type SwitchSceneCollectionCmd struct {
// SceneCollectionSwitchCmd provides a command to switch to a different scene collection.
type SceneCollectionSwitchCmd 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 *SwitchSceneCollectionCmd) Run(ctx *context) error {
func (cmd *SceneCollectionSwitchCmd) Run(ctx *context) error {
collections, err := ctx.Client.Config.GetSceneCollectionList()
if err != nil {
return err
@ -59,35 +82,35 @@ func (cmd *SwitchSceneCollectionCmd) Run(ctx *context) error {
current := collections.CurrentSceneCollectionName
if current == cmd.Name {
return fmt.Errorf("scene collection %s is already active", cmd.Name)
return fmt.Errorf("scene collection %s is already active", ctx.Style.Error(cmd.Name))
}
_, err = ctx.Client.Config.SetCurrentSceneCollection(
config.NewSetCurrentSceneCollectionParams().WithSceneCollectionName(cmd.Name),
)
if err != nil {
return fmt.Errorf("failed to switch scene collection: %w", err)
return fmt.Errorf("failed to switch scene collection %s: %w", ctx.Style.Error(cmd.Name), err)
}
fmt.Fprintf(ctx.Out, "Switched to scene collection: %s\n", cmd.Name)
fmt.Fprintf(ctx.Out, "Switched to scene collection: %s\n", ctx.Style.Highlight(cmd.Name))
return nil
}
// CreateSceneCollectionCmd provides a command to create a new scene collection.
type CreateSceneCollectionCmd struct {
// SceneCollectionCreateCmd provides a command to create a new scene collection.
type SceneCollectionCreateCmd 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 *CreateSceneCollectionCmd) Run(ctx *context) error {
func (cmd *SceneCollectionCreateCmd) 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: %w", err)
return fmt.Errorf("failed to create scene collection %s: %w", ctx.Style.Error(cmd.Name), err)
}
fmt.Fprintf(ctx.Out, "Created scene collection: %s\n", cmd.Name)
fmt.Fprintf(ctx.Out, "Created scene collection: %s\n", ctx.Style.Highlight(cmd.Name))
return nil
}

View File

@ -1,10 +1,14 @@
// 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.
@ -14,50 +18,153 @@ 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 {
SceneName string `arg:"" help:"Scene name."`
UUID bool `flag:"" help:"Display UUIDs of scene items."`
SceneName string ` help:"Name of the scene to list items from." arg:"" default:""`
}
// 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)
}
for _, item := range resp.SceneItems {
fmt.Fprintf(ctx.Out, "Item ID: %d, Source Name: %s\n", item.SceneItemID, item.SourceName)
}
if len(resp.SceneItems) == 0 {
fmt.Fprintf(ctx.Out, "No scene items found in scene %s.\n", 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,
)
}
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(
client *goobs.Client,
ctx *context,
sceneName string,
itemName string,
parent string,
group string,
) (string, int, error) {
if parent != "" {
resp, err := client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams().
WithSceneName(parent))
if group != "" {
resp, err := ctx.Client.SceneItems.GetGroupSceneItemList(sceneitems.NewGetGroupSceneItemListParams().
WithSceneName(group))
if err != nil {
return "", 0, err
}
for _, item := range resp.SceneItems {
if item.SourceName == itemName {
return parent, int(item.SceneItemID), nil
return group, int(item.SceneItemID), nil
}
}
return "", 0, fmt.Errorf("item '%s' not found in scene '%s'", itemName, sceneName)
return "", 0, fmt.Errorf("item %s not found in scene %s", ctx.Style.Error(itemName), ctx.Style.Error(sceneName))
}
itemID, err := client.SceneItems.GetSceneItemId(sceneitems.NewGetSceneItemIdParams().
itemID, err := ctx.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
@ -65,7 +172,7 @@ func getSceneNameAndItemID(
// SceneItemShowCmd provides a command to show a scene item.
type SceneItemShowCmd struct {
Parent string `flag:"" help:"Parent group name."`
Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -73,7 +180,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.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil {
return err
}
@ -85,12 +192,24 @@ 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 {
Parent string `flag:"" help:"Parent group name."`
Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -98,7 +217,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.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil {
return err
}
@ -110,6 +229,18 @@ 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
}
@ -126,7 +257,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 {
Parent string `flag:"" help:"Parent group name."`
Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -134,7 +265,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.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil {
return err
}
@ -151,12 +282,24 @@ 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 {
Parent string `flag:"" help:"Parent group name."`
Group string `flag:"" help:"Parent group name."`
SceneName string `arg:"" help:"Scene name."`
ItemName string `arg:"" help:"Item name."`
@ -164,7 +307,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.Client, cmd.SceneName, cmd.ItemName, cmd.Parent)
sceneName, sceneItemID, err := getSceneNameAndItemID(ctx, cmd.SceneName, cmd.ItemName, cmd.Group)
if err != nil {
return err
}
@ -175,9 +318,126 @@ func (cmd *SceneItemVisibleCmd) Run(ctx *context) error {
}
if itemEnabled {
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is visible.\n", cmd.ItemName, cmd.SceneName)
fmt.Fprintf(
ctx.Out,
"Scene item %s in scene %s is visible.\n",
ctx.Style.Highlight(cmd.ItemName),
ctx.Style.Highlight(cmd.SceneName),
)
} else {
fmt.Fprintf(ctx.Out, "Scene item '%s' in scene '%s' is hidden.\n", cmd.ItemName, cmd.SceneName)
fmt.Fprintf(ctx.Out, "Scene item %s in scene %s is hidden.\n", ctx.Style.Highlight(cmd.ItemName), ctx.Style.Highlight(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
}

29
sceneitem_test.go Normal file
View File

@ -0,0 +1,29 @@
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",
}
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())
}
}

41
screenshot.go Normal file
View File

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

123
stream_test.go Normal file
View File

@ -0,0 +1,123 @@
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(2 * time.Second) // Wait for the stream to start
}
func TestStreamStop(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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(2 * time.Second) // Wait for the stream to stop
}
func TestStreamToggle(t *testing.T) {
client, disconnect := getClient(t)
defer disconnect()
var out bytes.Buffer
context := 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(2 * time.Second) // Wait for the stream to toggle
}

View File

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

62
studiomode_test.go Normal file
View File

@ -0,0 +1,62 @@
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 Normal file
View File

@ -0,0 +1,192 @@
// 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
}

38
util.go Normal file
View File

@ -0,0 +1,38 @@
// 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
}

20
util_test.go Normal file
View File

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

View File

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

27
version_test.go Normal file
View File

@ -0,0 +1,27 @@
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 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"`
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"`
}
// StartVirtualCamCmd starts the virtual camera.
type StartVirtualCamCmd struct{} // size = 0x0
// VirtualCamStartCmd starts the virtual camera.
type VirtualCamStartCmd struct{} // size = 0x0
// Run executes the command to start the virtual camera.
func (c *StartVirtualCamCmd) Run(ctx *context) error {
func (c *VirtualCamStartCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StartVirtualCam()
if err != nil {
return fmt.Errorf("failed to start virtual camera: %w", err)
@ -25,11 +25,11 @@ func (c *StartVirtualCamCmd) Run(ctx *context) error {
return nil
}
// StopVirtualCamCmd stops the virtual camera.
type StopVirtualCamCmd struct{} // size = 0x0
// VirtualCamStopCmd stops the virtual camera.
type VirtualCamStopCmd struct{} // size = 0x0
// Run executes the command to stop the virtual camera.
func (c *StopVirtualCamCmd) Run(ctx *context) error {
func (c *VirtualCamStopCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.StopVirtualCam()
if err != nil {
return fmt.Errorf("failed to stop virtual camera: %w", err)
@ -38,23 +38,29 @@ func (c *StopVirtualCamCmd) Run(ctx *context) error {
return nil
}
// ToggleVirtualCamCmd toggles the virtual camera.
type ToggleVirtualCamCmd struct{} // size = 0x0
// VirtualCamToggleCmd toggles the virtual camera.
type VirtualCamToggleCmd struct{} // size = 0x0
// Run executes the command to toggle the virtual camera.
func (c *ToggleVirtualCamCmd) Run(ctx *context) error {
_, err := ctx.Client.Outputs.ToggleVirtualCam()
func (c *VirtualCamToggleCmd) Run(ctx *context) error {
resp, 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
}
// StatusVirtualCamCmd retrieves the status of the virtual camera.
type StatusVirtualCamCmd struct{} // size = 0x0
// VirtualCamStatusCmd retrieves the status of the virtual camera.
type VirtualCamStatusCmd struct{} // size = 0x0
// Run executes the command to get the status of the virtual camera.
func (c *StatusVirtualCamCmd) Run(ctx *context) error {
func (c *VirtualCamStatusCmd) Run(ctx *context) error {
status, err := ctx.Client.Outputs.GetVirtualCamStatus()
if err != nil {
return fmt.Errorf("failed to get virtual camera status: %w", err)