diff --git a/.gitignore b/.gitignore index e3200e0..11fa61c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,8 @@ build-iPhoneSimulator/ # Used by RuboCop. Remote config files pulled in from inherit_from directive. # .rubocop-https?--* + +# config +obs.toml +# quick test +quick.rb \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6f4e9f9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +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). + +Before any major/minor/patch bump all unit tests will be run to verify they pass. + +## [0.0.1] - 2021-10-22 + +### Added + +- Initial Commit +- Base class, Request and Event client classes. +- Unit tests +- README.md diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..63098bf --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# gem "rails" + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..8a9f863 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,31 @@ +PATH + remote: . + specs: + obsws (0.0.1) + observer (~> 0.1.1) + waitutil (~> 0.2.1) + websocket-driver (~> 0.7.5) + +GEM + remote: https://rubygems.org/ + specs: + minitest (5.16.3) + observer (0.1.1) + perfect_toml (0.9.0) + rake (11.3.0) + waitutil (0.2.1) + websocket-driver (0.7.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + +PLATFORMS + x64-mingw-ucrt + +DEPENDENCIES + minitest (~> 5.16, >= 5.16.3) + obsws! + perfect_toml (~> 0.9.0) + rake (~> 11.2, >= 11.2.2) + +BUNDLED WITH + 2.3.22 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5468ef --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/onyx-and-iris/voicemeeter-api-ruby/blob/dev/LICENSE) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/plugin-ruby) + +# A Ruby wrapper around OBS Studio WebSocket v5.0 + +## Requirements + +- [OBS Studio](https://obsproject.com/) +- [OBS Websocket v5 Plugin](https://github.com/obsproject/obs-websocket/releases/tag/5.0.0) + - With the release of OBS Studio version 28, Websocket plugin is included by default. But it should be manually installed for earlier versions of OBS. +- Ruby 3.0 or greater + +## `Use` + +#### Example `main.rb` + +pass `host`, `port` and `password` as keyword arguments. + +```ruby +require_relative "lib/obsws" + +def main + r_client = + OBSWS::Requests::Client.new( + host: "localhost", + port: 4455, + password: "strongpassword" + ) + + r_client.run do + # Toggle the mute state of your Mic input + r_client.toggle_input_mute("Mic/Aux") + end +end + +main if $0 == __FILE__ +``` + +### Requests + +Method names for requests match the API calls but snake cased. `run` accepts a block that closes the socket once you are done. + +example: + +```ruby +r_client.run do + # GetVersion + resp = r_client.get_version + + # SetCurrentProgramScene + r_client.set_current_program_scene("BRB") +end +``` + +For a full list of requests refer to [Requests](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requests) + +### Events + +Register an observer class and define `on_` methods for events. Method names should match the api event but snake cased. + +example: + +```ruby +class Observer + def initialize + @e_client = OBSWS::Events::Client.new(**kwargs) + # register class with the event client + @e_client.add_observer(self) + end + + # define "on_" event methods. + def on_current_program_scene_changed + ... + end + def on_input_mute_state_changed + ... + end + ... +end +``` + +For a full list of events refer to [Events](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#events) + +### Attributes + +For both request responses and event data you may inspect the available attributes using `attrs`. + +example: + +```ruby +resp = cl.get_version +p resp.attrs + +def on_scene_created(data): + p data.attrs +``` + +### Errors + +If a request fails an `OBSWSError` will be raised with a status code. + +For a full list of status codes refer to [Codes](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requeststatus) + +### Tests + +To run all tests: + +``` +bundle exec rake -v +``` + +### Official Documentation + +For the full documentation: + +- [OBS Websocket SDK](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#obs-websocket-501-protocol) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6cb6cf9 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require "minitest/test_task" + +Minitest::TestTask.create(:test) do |t| + t.libs << "test" + t.warning = false + t.test_globs = ["test/**/*_test.rb"] +end + +task default: :test diff --git a/examples/events/main.rb b/examples/events/main.rb new file mode 100644 index 0000000..fdda688 --- /dev/null +++ b/examples/events/main.rb @@ -0,0 +1,59 @@ +require "perfect_toml" + +require_relative "../../lib/obsws" + +OBSWS::LOGGER.debug! + +class Observer + attr_reader :running + + def initialize(**kwargs) + @r_client = OBSWS::Requests::Client.new(**kwargs) + @e_client = OBSWS::Events::Client.new(**kwargs) + @e_client.add_observer(self) + + puts info.join("\n") + @running = true + end + + def info + resp = @r_client.get_version + [ + "Using obs version:", + resp.obs_version, + "With websocket version:", + resp.obs_web_socket_version + ] + end + + def on_current_program_scene_changed(data) + puts "Switched to scene #{data.scene_name}" + end + + def on_scene_created(data) + puts "scene #{data.scene_name} has been created" + end + + def on_input_mute_state_changed(data) + puts "#{data.input_name} mute toggled" + end + + def on_exit_started + puts "OBS closing!" + @r_client.close + @e_client.close + @running = false + end +end + +def conn_from_toml + PerfectTOML.load_file("obs.toml", symbolize_names: true)[:connection] +end + +def main + o = Observer.new(**conn_from_toml) + + sleep(0.1) while o.running +end + +main if $0 == __FILE__ diff --git a/lib/obsws.rb b/lib/obsws.rb new file mode 100644 index 0000000..2cd0dd9 --- /dev/null +++ b/lib/obsws.rb @@ -0,0 +1,11 @@ +require "logger" + +require_relative "obsws/req" +require_relative "obsws/event" + +module OBSWS + include Logger::Severity + + LOGGER = Logger.new(STDOUT) + LOGGER.level = WARN +end diff --git a/lib/obsws/base.rb b/lib/obsws/base.rb new file mode 100644 index 0000000..3a703cb --- /dev/null +++ b/lib/obsws/base.rb @@ -0,0 +1,117 @@ +require "socket" +require "websocket/driver" +require "digest/sha2" +require "json" +require "observer" +require "waitutil" + +require_relative "mixin" +require_relative "error" + +module OBSWS + class Socket + attr_reader :url + + def initialize(url, socket) + @url = url + @socket = socket + end + + def write(s) + @socket.write(s) + end + end + + class Base + include Observable + include Mixin::OPCodes + + attr_reader :id, :driver, :closed + + def initialize(**kwargs) + host = kwargs[:host] || "localhost" + port = kwargs[:port] || 4455 + @password = kwargs[:password] || "" + @subs = kwargs[:subs] || 0 + + @socket = TCPSocket.new(host, port) + @driver = + WebSocket::Driver.client(Socket.new("ws://#{host}:#{port}", @socket)) + @ready = false + @closed = false + @driver.on :open do |msg| + LOGGER.debug("driver socket open") + @ready = true + end + @driver.on :close do |msg| + LOGGER.debug("driver socket closed") + @closed = true + end + @driver.on :message do |msg| + LOGGER.debug("received [#{msg}] passing to handler") + msg_handler(JSON.parse(msg.data, symbolize_names: true)) + end + Thread.new { start_driver } + WaitUtil.wait_for_condition( + "driver socket ready", + delay_sec: 0.01, + timeout_sec: 0.5 + ) { @ready } + end + + def start_driver + @driver.start + + loop do + @driver.parse(@socket.readpartial(4096)) + rescue EOFError + break + end + end + + def auth_token(salt:, challenge:) + Digest::SHA256.base64digest( + Digest::SHA256.base64digest(@password + salt) + challenge + ) + end + + def authenticate(auth) + token = auth_token(**auth) + payload = { + op: Mixin::OPCodes::IDENTIFY, + d: { + rpcVersion: 1, + authentication: token, + eventSubscriptions: @subs + } + } + @driver.text(JSON.generate(payload)) + end + + def msg_handler(data) + op_code = data[:op] + case op_code + when Mixin::OPCodes::HELLO + authenticate(data[:d][:authentication]) + when Mixin::OPCodes::IDENTIFIED + LOGGER.debug("Authentication successful") + when Mixin::OPCodes::EVENT, Mixin::OPCodes::REQUESTRESPONSE + changed + notify_observers(op_code, data[:d]) + end + end + + def req(id, type_, data = nil) + payload = { + op: Mixin::OPCodes::REQUEST, + d: { + requestType: type_, + requestId: id + } + } + payload[:d][:requestData] = data if data + queued = @driver.text(JSON.generate(payload)) + LOGGER.debug("request with id #{id} queued? #{queued}") + end + end +end diff --git a/lib/obsws/error.rb b/lib/obsws/error.rb new file mode 100644 index 0000000..25cc8e9 --- /dev/null +++ b/lib/obsws/error.rb @@ -0,0 +1,6 @@ +module OBSWS + module Error + class OBSWSError < StandardError + end + end +end diff --git a/lib/obsws/event.rb b/lib/obsws/event.rb new file mode 100644 index 0000000..879fa2c --- /dev/null +++ b/lib/obsws/event.rb @@ -0,0 +1,92 @@ +require "json" + +require_relative "util" +require_relative "mixin" + +module OBSWS + module Events + module SUBS + NONE = 0 + GENERAL = (1 << 0) + CONFIG = (1 << 1) + SCENES = (1 << 2) + INPUTS = (1 << 3) + TRANSITIONS = (1 << 4) + FILTERS = (1 << 5) + OUTPUTS = (1 << 6) + SCENEITEMS = (1 << 7) + MEDIAINPUTS = (1 << 8) + VENDORS = (1 << 9) + UI = (1 << 10) + + def low_volume + GENERAL | CONFIG | SCENES | INPUTS | TRANSITIONS | FILTERS | OUTPUTS | + SCENEITEMS | MEDIAINPUTS | VENDORS | UI + end + + INPUTVOLUMEMETERS = (1 << 16) + INPUTACTIVESTATECHANGED = (1 << 17) + INPUTSHOWSTATECHANGED = (1 << 18) + SCENEITEMTRANSFORMCHANGED = (1 << 19) + + def high_volume + INPUTVOLUMEMETERS | INPUTACTIVESTATECHANGED | INPUTSHOWSTATECHANGED | + SCENEITEMTRANSFORMCHANGED + end + + def all + low_volume | high_volume + end + + module_function :low_volume, :high_volume, :all + end + + module Callbacks + include Util + + def add_observer(observer) + @observers = [] unless defined?(@observers) + observer = [observer] if !observer.respond_to? :each + observer.each { |o| @observers.append(o) } + end + + def remove_observer(observer) + @observers.delete(observer) + end + + def notify_observers(event, data) + if defined?(@observers) + @observers.each do |o| + if o.respond_to? "on_#{event.to_snake}" + if data.empty? + o.send("on_#{event.to_snake}") + else + o.send("on_#{event.to_snake}", data) + end + end + end + end + end + end + + class Client + include Callbacks + include Mixin::TearDown + include Mixin::OPCodes + + def initialize(**kwargs) + kwargs[:subs] = SUBS.low_volume + @base_client = Base.new(**kwargs) + @base_client.add_observer(self) + end + + def update(op_code, data) + if op_code == Mixin::OPCodes::EVENT + event = data[:eventType] + data = data.key?(:eventData) ? data[:eventData] : {} + notify_observers(event, Mixin::Data.new(data, data.keys)) + end + end + end + end +end diff --git a/lib/obsws/mixin.rb b/lib/obsws/mixin.rb new file mode 100644 index 0000000..c9f0eb8 --- /dev/null +++ b/lib/obsws/mixin.rb @@ -0,0 +1,53 @@ +require_relative "util" + +module OBSWS + module Mixin + module Meta + include Util + + def make_response_methods(*params) + params.each do |param| + define_singleton_method(param.to_s.to_snake) { @resp[param] } + end + end + end + + class MetaObject + include Mixin::Meta + + def initialize(resp, fields) + @resp = resp + @fields = fields + self.make_response_methods *fields + end + + def empty? = @fields.empty? + + def attrs = @fields.map { |f| f.to_s.to_snake } + end + + class Response < MetaObject + end + + class Data < MetaObject + end + + module TearDown + def close + @base_client.driver.close + end + end + + module OPCodes + HELLO = 0 + IDENTIFY = 1 + IDENTIFIED = 2 + REIDENTIFY = 3 + EVENT = 5 + REQUEST = 6 + REQUESTRESPONSE = 7 + REQUESTBATCH = 8 + REQUESTBATCHRESPONSE = 9 + end + end +end diff --git a/lib/obsws/req.rb b/lib/obsws/req.rb new file mode 100644 index 0000000..6d0a0cd --- /dev/null +++ b/lib/obsws/req.rb @@ -0,0 +1,888 @@ +require "waitutil" + +require_relative "base" +require_relative "error" +require_relative "util" +require_relative "mixin" + +module OBSWS + module Requests + class Client + include Error + include Mixin::TearDown + include Mixin::OPCodes + + def initialize(**kwargs) + @base_client = Base.new(**kwargs) + @base_client.add_observer(self) + @response = { requestId: 0 } + end + + def run + yield + ensure + close + WaitUtil.wait_for_condition( + "driver has closed", + delay_sec: 0.01, + timeout_sec: 1 + ) { @base_client.closed } + end + + def update(op_code, data) + @response = data if op_code == Mixin::OPCodes::REQUESTRESPONSE + end + + def call(req, data = nil) + id = rand(1..1000) + @base_client.req(id, req, data) + WaitUtil.wait_for_condition( + "reponse id matches request id", + delay_sec: 0.001, + timeout_sec: 3 + ) { @response[:requestId] == id } + if !@response[:requestStatus][:result] + error = [ + "Request #{@response[:requestType]} returned code #{@response[:requestStatus][:code]}" + ] + if @response[:requestStatus].key?(:comment) + error += ["With message: #{@response[:requestStatus][:comment]}"] + end + raise OBSWSError.new(error.join("\n")) + end + @response[:responseData] + end + + def get_version + resp = call("GetVersion") + Mixin::Response.new(resp, resp.keys) + end + + def get_stats + resp = call("GetStats") + Mixin::Response.new(resp, resp.keys) + end + + def broadcast_custom_event(data) + call("BroadcastCustomEvent", data) + end + + def call_vendor_request(name, type_, data = nil) + call(requestType, requestData) + end + + def get_hotkey_list + resp = call("GetHotkeyList") + Mixin::Response.new(resp, resp.keys) + end + + def trigger_hotkey_by_name(name) + payload = { hotkeyName: name } + call("TriggerHotkeyByName", payload) + end + + def trigger_hotkey_by_key_sequence( + key_id, + press_shift, + press_ctrl, + press_alt, + press_cmd + ) + payload = { + keyId: key_id, + keyModifiers: { + shift: press_shift, + control: press_ctrl, + alt: press_alt, + cmd: press_cmd + } + } + call("TriggerHotkeyByKeySequence", payload) + end + + def sleep(sleep_millis = nil, sleep_frames = nil) + payload = { sleepMillis: sleep_millis, sleepFrames: sleep_frames } + call("Sleep", payload) + end + + def get_persistent_data(realm, slot_name) + payload = { realm: realm, slotName: slot_name } + resp = call("GetPersistentData", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_persistent_data(realm, slot_name, slot_value) + payload = { realm: realm, slotName: slot_name, slotValue: slot_value } + call("SetPersistentData", payload) + end + + def get_scene_collection_list + resp = call("GetSceneCollectionList") + Mixin::Response.new(resp, resp.keys) + end + + def set_current_scene_collection(name) + payload = { sceneCollectionName: name } + call("SetCurrentSceneCollection", payload) + end + + def create_scene_collection(name) + payload = { sceneCollectionName: name } + call("CreateSceneCollection", payload) + end + + def get_profile_list + resp = call("GetProfileList") + Mixin::Response.new(resp, resp.keys) + end + + def set_current_profile(name) + payload = { profileName: name } + call("SetCurrentProfile", payload) + end + + def create_profile(name) + payload = { profileName: name } + call("CreateProfile", payload) + end + + def remove_profile(name) + payload = { profileName: name } + call("RemoveProfile", payload) + end + + def get_profile_parameter(category, name) + payload = { parameterCategory: category, parameterName: name } + resp = call("GetProfileParameter", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_profile_parameter(category, name, value) + payload = { + parameterCategory: category, + parameterName: name, + parameterValue: value + } + call("SetProfileParameter", payload) + end + + def get_video_settings + resp = call("GetVideoSettings") + Mixin::Response.new(resp, resp.keys) + end + + def set_video_settings( + numerator, + denominator, + base_width, + base_height, + out_width, + out_height + ) + payload = { + fpsNumerator: numerator, + fpsDenominator: denominator, + baseWidth: base_width, + baseHeight: base_height, + outputWidth: out_width, + outputHeight: out_height + } + call("SetVideoSettings", payload) + end + + def get_stream_service_settings + resp = call("GetStreamServiceSettings") + Mixin::Response.new(resp, resp.keys) + end + + def set_stream_service_settings(ss_type, ss_settings) + payload = { + streamServiceType: ss_type, + streamServiceSettings: ss_settings + } + call("SetStreamServiceSettings", payload) + end + + def get_record_directory + resp = call("GetRecordDirectory") + Mixin::Response.new(resp, resp.keys) + end + + def get_source_active(name) + payload = { sourceName: name } + resp = call("GetSourceActive", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_source_screenshot(name, img_format, width, height, quality) + payload = { + sourceName: name, + imageFormat: img_format, + imageWidth: width, + imageHeight: height, + imageCompressionQuality: quality + } + resp = call("GetSourceScreenshot", payload) + Mixin::Response.new(resp, resp.keys) + end + + def save_source_screenshot( + name, + img_format, + file_path, + width, + height, + quality + ) + payload = { + sourceName: name, + imageFormat: img_format, + imageFilePath: file_path, + imageWidth: width, + imageHeight: height, + imageCompressionQuality: quality + } + resp = call("SaveSourceScreenshot", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_scene_list + resp = call("GetSceneList") + Mixin::Response.new(resp, resp.keys) + end + + def get_group_list + resp = call("GetSceneList") + Mixin::Response.new(resp, resp.keys) + end + + def get_current_program_scene + resp = call("GetCurrentProgramScene") + Mixin::Response.new(resp, resp.keys) + end + + def set_current_program_scene(name) + payload = { sceneName: name } + call("SetCurrentProgramScene", payload) + end + + def get_current_preview_scene + resp = call("GetCurrentPreviewScene") + Mixin::Response.new(resp, resp.keys) + end + + def set_current_preview_scene(name) + payload = { sceneName: name } + call("SetCurrentPreviewScene", payload) + end + + def create_scene(name) + payload = { sceneName: name } + call("CreateScene", payload) + end + + def remove_scene(name) + payload = { sceneName: name } + call("RemoveScene", payload) + end + + def set_scene_name(old_name, new_name) + payload = { sceneName: old_name, newSceneName: new_name } + call("SetSceneName", payload) + end + + def get_scene_scene_transition_override(name) + payload = { sceneName: name } + resp = call("GetSceneSceneTransitionOverride", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_scene_scene_transition_override(scene_name, tr_name, tr_duration) + payload = { + sceneName: scene_name, + transitionName: tr_name, + transitionDuration: tr_duration + } + call("SetSceneSceneTransitionOverride", payload) + end + + def get_input_list(kind = nil) + payload = { inputKind: kind } + resp = call("GetInputList", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_input_kind_list(unversioned) + payload = { unversioned: unversioned } + resp = call("GetInputKindList", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_special_inputs + resp = call("GetSpecialInputs") + end + + def create_input( + scene_name, + input_name, + input_kind, + input_settings, + scene_item_enabled + ) + payload = { + sceneName: scene_name, + inputName: input_name, + inputKind: input_kind, + inputSettings: input_settings, + sceneItemEnabled: scene_item_enabled + } + resp = call("CreateInput", payload) + end + + def remove_input(name) + payload = { inputName: name } + call("RemoveInput", payload) + end + + def set_input_name(old_name, new_name) + payload = { inputName: old_name, newInputName: new_name } + call("SetInputName", payload) + end + + def get_input_default_settings(kind) + payload = { inputKind: kind } + resp = call("GetInputDefaultSettings", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_input_settings(name) + payload = { inputName: name } + resp = call("GetInputSettings", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_settings(name, settings, overlay) + payload = { inputName: name, inputSettings: settings, overlay: overlay } + call("SetInputSettings", payload) + end + + def get_input_mute(name) + payload = { inputName: name } + resp = call("GetInputMute", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_mute(name, muted) + payload = { inputName: name, inputMuted: muted } + call("SetInputMute", payload) + end + + def toggle_input_mute(name) + payload = { inputName: name } + resp = call("ToggleInputMute", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_input_volume(name) + payload = { inputName: name } + resp = call("GetInputVolume", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_volume(name, vol_mul = nil, vol_db = nil) + payload = { + inputName: name, + inputVolumeMul: vol_mul, + inputVolumeDb: vol_db + } + call("SetInputVolume", payload) + end + + def get_input_audio_balance(name) + payload = { inputName: name } + resp = call("GetInputAudioBalance", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_audio_balance(name, balance) + payload = { inputName: name, inputAudioBalance: balance } + call("SetInputAudioBalance", payload) + end + + def get_input_audio_sync_offset(name) + payload = { inputName: name } + resp = call("GetInputAudioSyncOffset", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_audio_sync_offset(name, offset) + payload = { inputName: name, inputAudioSyncOffset: offset } + call("SetInputAudioSyncOffset", payload) + end + + def get_input_audio_monitor_type(name) + payload = { inputName: name } + resp = call("GetInputAudioMonitorType", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_audio_monitor_type(name, mon_type) + payload = { inputName: name, monitorType: mon_type } + call("SetInputAudioMonitorType", payload) + end + + def get_input_audio_tracks(name) + payload = { inputName: name } + resp = call("GetInputAudioTracks", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_input_audio_tracks(name, track) + payload = { inputName: name, inputAudioTracks: track } + call("SetInputAudioTracks", payload) + end + + def get_input_properties_list_property_items(input_name, prop_name) + payload = { inputName: input_name, propertyName: prop_name } + resp = call("GetInputPropertiesListPropertyItems", payload) + Mixin::Response.new(resp, resp.keys) + end + + def press_input_properties_button(input_name, prop_name) + payload = { inputName: input_name, propertyName: prop_name } + call("PressInputPropertiesButton", payload) + end + + def get_transition_kind_list + resp = call("GetTransitionKindList") + Mixin::Response.new(resp, resp.keys) + end + + def get_scene_transition_list + resp = call("GetSceneTransitionList") + Mixin::Response.new(resp, resp.keys) + end + + def get_current_scene_transition + resp = call("GetCurrentSceneTransition") + Mixin::Response.new(resp, resp.keys) + end + + def set_current_scene_transition(name) + payload = { transitionName: name } + call("SetCurrentSceneTransition", payload) + end + + def set_current_scene_transition_duration(duration) + payload = { transitionDuration: duration } + call("SetCurrentSceneTransitionDuration", payload) + end + + def set_current_scene_transition_settings(settings, overlay = nil) + payload = { transitionSettings: settings, overlay: overlay } + call("SetCurrentSceneTransitionSettings", payload) + end + + def get_current_scene_transition_cursor + resp = call("GetCurrentSceneTransitionCursor") + Mixin::Response.new(resp, resp.keys) + end + + def trigger_studio_mode_transition + call("TriggerStudioModeTransition") + end + + def set_t_bar_position(pos, release = nil) + payload = { position: pos, release: release } + call("SetTBarPosition", payload) + end + + def get_source_filter_list(name) + payload = { sourceName: name } + resp = call("GetSourceFilterList", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_source_filter_default_settings(kind) + payload = { filterKind: kind } + resp = call("GetSourceFilterDefaultSettings", payload) + Mixin::Response.new(resp, resp.keys) + end + + def create_source_filter( + source_name, + filter_name, + filter_kind, + filter_settings = nil + ) + payload = { + sourceName: source_name, + filterName: filter_name, + filterKind: filter_kind, + filterSettings: filter_settings + } + call("CreateSourceFilter", payload) + end + + def remove_source_filter(source_name, filter_name) + payload = { sourceName: source_name, filterName: filter_name } + call("RemoveSourceFilter", payload) + end + + def set_source_filter_name(source_name, old_filter_name, new_filter_name) + payload = { + sourceName: source_name, + filterName: old_filter_name, + newFilterName: new_filter_name + } + call("SetSourceFilterName", payload) + end + + def get_source_filter(source_name, filter_name) + payload = { sourceName: source_name, filterName: filter_name } + resp = call("GetSourceFilter", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_source_filter_index(source_name, filter_name, filter_index) + payload = { + sourceName: source_name, + filterName: filter_name, + filterIndex: filter_index + } + call("SetSourceFilterIndex", payload) + end + + def set_source_filter_settings( + source_name, + filter_name, + settings, + overlay = nil + ) + payload = { + sourceName: source_name, + filterName: filter_name, + filterSettings: settings, + overlay: overlay + } + call("SetSourceFilterSettings", payload) + end + + def set_source_filter_enabled(source_name, filter_name, enabled) + payload = { + sourceName: source_name, + filterName: filter_name, + filterEnabled: enabled + } + call("SetSourceFilterEnabled", payload) + end + + def get_scene_item_list(name) + payload = { sceneName: name } + resp = call("GetSceneItemList", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_group_scene_item_list(name) + payload = { sceneName: name } + resp = call("GetGroupSceneItemList", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_scene_item_id(scene_name, source_name, offset = nil) + payload = { + sceneName: scene_name, + sourceName: source_name, + searchOffset: offset + } + resp = call("GetSceneItemId", payload) + Mixin::Response.new(resp, resp.keys) + end + + def create_scene_item(scene_name, source_name, enabled = nil) + payload = { + sceneName: scene_name, + sourceName: source_name, + sceneItemEnabled: enabled + } + resp = call("CreateSceneItem", payload) + Mixin::Response.new(resp, resp.keys) + end + + def remove_scene_item(scene_name, item_id) + payload = { sceneName: scene_name, sceneItemId: item_id } + call("RemoveSceneItem", payload) + end + + def duplicate_scene_item(scene_name, item_id, dest_scene_name = nil) + payload = { + sceneName: scene_name, + sceneItemId: item_id, + destinationSceneName: dest_scene_name + } + resp = call("DuplicateSceneItem", payload) + Mixin::Response.new(resp, resp.keys) + end + + def get_scene_item_transform(scene_name, item_id) + payload = { sceneName: scene_name, sceneItemId: item_id } + resp = call("GetSceneItemTransform", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_scene_item_transform(scene_name, item_id, transform) + payload = { + sceneName: scene_name, + sceneItemId: item_id, + sceneItemTransform: transform + } + call("SetSceneItemTransform", payload) + end + + def get_scene_item_enabled(scene_name, item_id) + payload = { sceneName: scene_name, sceneItemId: item_id } + resp = call("GetSceneItemEnabled", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_scene_item_enabled(scene_name, item_id, enabled) + payload = { + sceneName: scene_name, + sceneItemId: item_id, + sceneItemEnabled: enabled + } + call("SetSceneItemEnabled", payload) + end + + def get_scene_item_locked(scene_name, item_id) + payload = { sceneName: scene_name, sceneItemId: item_id } + resp = call("GetSceneItemLocked", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_scene_item_locked(scene_name, item_id, locked) + payload = { + sceneName: scene_name, + sceneItemId: item_id, + sceneItemLocked: locked + } + call("SetSceneItemLocked", payload) + end + + def get_scene_item_index(scene_name, item_id) + payload = { sceneName: scene_name, sceneItemId: item_id } + resp = call("GetSceneItemIndex", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_scene_item_index(scene_name, item_id, item_index) + payload = { + sceneName: scene_name, + sceneItemId: item_id, + sceneItemLocked: item_index + } + call("SetSceneItemIndex", payload) + end + + def get_scene_item_blend_mode(scene_name, item_id) + payload = { sceneName: scene_name, sceneItemId: item_id } + resp = call("GetSceneItemBlendMode", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_scene_item_blend_mode(scene_name, item_id, blend) + payload = { + sceneName: scene_name, + sceneItemId: item_id, + sceneItemBlendMode: blend + } + call("SetSceneItemBlendMode", payload) + end + + def get_virtual_cam_status + resp = call("GetVirtualCamStatus") + Mixin::Response.new(resp, resp.keys) + end + + def toggle_virtual_cam + resp = call("ToggleVirtualCam") + Mixin::Response.new(resp, resp.keys) + end + + def start_virtual_cam + call("StartVirtualCam") + end + + def stop_virtual_cam + call("StopVirtualCam") + end + + def get_replay_buffer_status + resp = call("GetReplayBufferStatus") + Mixin::Response.new(resp, resp.keys) + end + + def toggle_replay_buffer + resp = call("ToggleReplayBuffer") + Mixin::Response.new(resp, resp.keys) + end + + def start_replay_buffer + call("StartReplayBuffer") + end + + def stop_replay_buffer + call("StopReplayBuffer") + end + + def save_replay_buffer + call("SaveReplayBuffer") + end + + def get_last_replay_buffer_replay + resp = call("GetLastReplayBufferReplay") + Mixin::Response.new(resp, resp.keys) + end + + def get_output_list + resp = call("GetOutputList") + Mixin::Response.new(resp, resp.keys) + end + + def get_output_status(name) + payload = { outputName: name } + resp = call("GetOutputStatus", payload) + Mixin::Response.new(resp, resp.keys) + end + + def toggle_output(name) + payload = { outputName: name } + resp = call("ToggleOutput", payload) + Mixin::Response.new(resp, resp.keys) + end + + def start_output(name) + payload = { outputName: name } + call("StartOutput", payload) + end + + def stop_output(name) + payload = { outputName: name } + call("StopOutput", payload) + end + + def get_output_settings(name) + payload = { outputName: name } + resp = call("GetOutputSettings", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_output_settings(name, settings) + payload = { outputName: name, outputSettings: settings } + call("SetOutputSettings", payload) + end + + def get_stream_status + resp = call("GetStreamStatus") + Mixin::Response.new(resp, resp.keys) + end + + def toggle_stream + resp = call("ToggleStream") + Mixin::Response.new(resp, resp.keys) + end + + def start_stream + call("StartStream") + end + + def stop_stream + call("StopStream") + end + + def send_stream_caption(caption) + call("SendStreamCaption") + end + + def get_record_status + resp = call("GetRecordStatus") + Mixin::Response.new(resp, resp.keys) + end + + def toggle_record + call("ToggleRecord") + end + + def start_record + call("StartRecord") + end + + def stop_record + resp = call("StopRecord") + end + + def toggle_record_pause + call("ToggleRecordPause") + end + + def pause_record + call("PauseRecord") + end + + def resume_record + call("ResumeRecord") + end + + def get_media_input_status(name) + payload = { inputName: name } + resp = call("GetMediaInputStatus", payload) + Mixin::Response.new(resp, resp.keys) + end + + def set_media_input_cursor(name, cursor) + payload = { inputName: name, mediaCursor: cursor } + call("SetMediaInputCursor", payload) + end + + def offset_media_input_cursor(name, offset) + payload = { inputName: name, mediaCursorOffset: offset } + call("OffsetMediaInputCursor", payload) + end + + def trigger_media_input_action(name, action) + payload = { inputName: name, mediaAction: action } + call("TriggerMediaInputAction", payload) + end + + def get_studio_mode_enabled + resp = call("GetStudioModeEnabled") + Mixin::Response.new(resp, resp.keys) + end + + def set_studio_mode_enabled(enabled) + payload = { studioModeEnabled: enabled } + call("SetStudioModeEnabled", payload) + end + + def open_input_properties_dialog(name) + payload = { inputName: name } + call("OpenInputPropertiesDialog", payload) + end + + def open_input_filters_dialog(name) + payload = { inputName: name } + call("OpenInputFiltersDialog", payload) + end + + def open_input_interact_dialog(name) + payload = { inputName: name } + call("OpenInputInteractDialog", payload) + end + + def get_monitor_list + resp = call("GetMonitorList") + Mixin::Response.new(resp, resp.keys) + end + end + end +end diff --git a/lib/obsws/util.rb b/lib/obsws/util.rb new file mode 100644 index 0000000..7c68da1 --- /dev/null +++ b/lib/obsws/util.rb @@ -0,0 +1,16 @@ +module OBSWS + module Util + class ::String + def to_camel + self.split(/_/).map(&:capitalize).join + end + + def to_snake + self + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end + end + end +end diff --git a/lib/obsws/version.rb b/lib/obsws/version.rb new file mode 100644 index 0000000..5b632d8 --- /dev/null +++ b/lib/obsws/version.rb @@ -0,0 +1,25 @@ +module OBSWS + module Version + module_function + + def major + 0 + end + + def minor + 0 + end + + def patch + 1 + end + + def to_a + [major, minor, patch] + end + + def to_s + to_a.join(".") + end + end +end diff --git a/main.rb b/main.rb new file mode 100644 index 0000000..31285b9 --- /dev/null +++ b/main.rb @@ -0,0 +1,17 @@ +require_relative "lib/obsws" + +def main + r_client = + OBSWS::Requests::Client.new( + host: "localhost", + port: 4455, + password: "strongpassword" + ) + + r_client.run do + # Toggle the mute state of your Mic input + r_client.toggle_input_mute("Mic/Aux") + end +end + +main if $0 == __FILE__ diff --git a/obsws_rb.gemspec b/obsws_rb.gemspec new file mode 100644 index 0000000..8456eb9 --- /dev/null +++ b/obsws_rb.gemspec @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require File.expand_path("lib/obsws/version", __dir__) +lib = File.expand_path("./lib") + +Gem::Specification.new do |spec| + spec.name = "obsws" + spec.version = OBSWS::Version + spec.summary = "OBS Websocket v5 wrapper" + spec.description = "A Ruby wrapper around OBS Websocket v5" + spec.authors = ["onyx_online"] + spec.email = "code@onyxandiris.online" + spec.files = Dir["lib/**/*.rb"] + spec.extra_rdoc_files = Dir["README.md", "CHANGELOG.md", "LICENSE"] + spec.homepage = "https://rubygems.org/gems/obsws" + spec.license = "MIT" + spec.add_runtime_dependency "observer", "~> 0.1.1" + spec.add_runtime_dependency "websocket-driver", "~> 0.7.5" + spec.add_runtime_dependency "waitutil", "~> 0.2.1" + spec.add_development_dependency "perfect_toml", "~> 0.9.0" + spec.add_development_dependency "minitest", "~> 5.16", ">= 5.16.3" + spec.add_development_dependency "rake", "~> 11.2", ">= 11.2.2" + spec.required_ruby_version = ">= 3.0" + spec.metadata = { + "source_code_uri" => "https://github.com/onyx-and-iris/obsws.git" + } +end diff --git a/test/minitest_helper.rb b/test/minitest_helper.rb new file mode 100644 index 0000000..5cd0de7 --- /dev/null +++ b/test/minitest_helper.rb @@ -0,0 +1,31 @@ +require "minitest" +require "minitest/autorun" +require "perfect_toml" + +require_relative "../lib/obsws" + +class OBSWSTest < Minitest::Test + def self.before_run + conn = PerfectTOML.load_file("obs.toml", symbolize_names: true)[:connection] + @@r_client = OBSWS::Requests::Client.new(**conn) + + @@r_client.create_scene("START_TEST") + @@r_client.create_scene("BRB_TEST") + @@r_client.create_scene("END_TEST") + end + + before_run + + def setup + end + + def teardown + end + + Minitest.after_run do + @@r_client.remove_scene("START_TEST") + @@r_client.remove_scene("BRB_TEST") + @@r_client.remove_scene("END_TEST") + @@r_client.close + end +end diff --git a/test/obsws/attrs_test.rb b/test/obsws/attrs_test.rb new file mode 100644 index 0000000..75a63fa --- /dev/null +++ b/test/obsws/attrs_test.rb @@ -0,0 +1,17 @@ +require_relative "../minitest_helper" + +class AttrsTest < OBSWSTest + def test_get_version_attrs + resp = @@r_client.get_version + assert resp.attrs == + %w[ + available_requests + obs_version + obs_web_socket_version + platform + platform_description + rpc_version + supported_image_formats + ] + end +end diff --git a/test/obsws/request_test.rb b/test/obsws/request_test.rb new file mode 100644 index 0000000..7912141 --- /dev/null +++ b/test/obsws/request_test.rb @@ -0,0 +1,38 @@ +require_relative "../minitest_helper" + +class RequestTest < OBSWSTest + def test_it_checks_obs_major_version + resp = @@r_client.get_version + ver = resp.obs_version.split(".").map(&:to_i) + assert ver[0] >= 28 + end + + def test_it_checks_ws_major_version + resp = @@r_client.get_version + ver = resp.obs_web_socket_version.split(".").map(&:to_i) + assert ver[0] >= 5 + end + + def test_it_sets_and_gets_current_program_scene + %w[START_TEST BRB_TEST END_TEST].each do |s| + @@r_client.set_current_program_scene(s) + resp = @@r_client.get_current_program_scene + assert resp.current_program_scene_name == s + end + end + + def test_stream_service_settings + settings = { + server: "rtmp://addressofrtmpserver", + key: "live_myvery_secretkey" + } + @@r_client.set_stream_service_settings("rtmp_common", settings) + resp = @@r_client.get_stream_service_settings + assert resp.stream_service_type == "rtmp_common" + assert resp.stream_service_settings == + { + server: "rtmp://addressofrtmpserver", + key: "live_myvery_secretkey" + } + end +end