require "json" require "pretty_print" require "http" require "uuid" struct Nil def as_s? self end end module OBSWebSocket extend self def req( type : String, id : String, data : ( String | Nil | JSON::Any ) = nil ) request = JSON.build do |json| json.object do json.field "op", 6 json.field "d" do json.object do json.field "requestType", type json.field "requestId", id if data json.field "requestData", data end end end end end return( request.to_s ) end class SourceMissingException < Exception end class ResponseDataMissingException < Exception end class Server @@reqchan = Channel( Tuple( ( Channel( JSON::Any ) | Nil ), String ) ).new @scenecollection = OBSWebSocket::SceneCollection.new( @@reqchan ) @@inputs = OBSWebSocket::Inputs.new( @@reqchan ) @@scenes = OBSWebSocket::SceneList.new( @@reqchan, @@inputs ) @sources = OBSWebSocket::Sources.new( @@reqchan, @@inputs, @@scenes ) @outputs = OBSWebSocket::Outputs.new( @@reqchan ) @video = OBSWebSocket::VideoSettings.new( @@reqchan ) @stats = OBSWebSocket::Stats.new( @@reqchan ) @negotiated = false @connecterror = 0 def negotiated? return @negotiated end def reqchan return @@reqchan end def scenecollection return @scenecollection end def scenes return @@scenes end def inputs return @@inputs end def outputs return @outputs end def sources return @sources end def video return @video end def stats return @stats end def transition! reschan = Channel( JSON::Any ).new @@reqchan.send( { reschan, OBSWebSocket.req( "TriggerStudioModeTransition", UUID.random.to_s ) } ) return reschan.receive end # request channel, response channel def send( json : String ) self.reqchan.send( { nil, json } ) end def send( reschan : ( Channel( JSON::Any ) | Nil ), json : String ) self.reqchan.send( { reschan, json } ) end def request( type : String, id : String, data : ( String | Nil | JSON::Any ) = nil ) request = JSON.build do |json| json.object do json.field "op", 6 json.field "d" do json.object do json.field "requestType", type json.field "requestId", id if data json.field "requestData", data end end end end end self.reqchan.send( { nil, request.to_s } ) end def request( reschan : ( Channel( JSON::Any ) | Nil ), type : String, id : String, data : ( String | Nil | JSON::Any ) = nil ) request = JSON.build do |json| json.object do json.field "op", 6 json.field "d" do json.object do json.field "requestType", type json.field "requestId", id if data json.field "requestData", data end end end end end self.reqchan.send( { reschan, request.to_s } ) end def initialize( uri : String, password : String | Nil = nil ) spawn do loop do obs_pubsub = HTTP::WebSocket.new( URI.parse( uri ), HTTP::Headers{"Cookie" => "SESSIONID=1235", "Sec-WebSocket-Protocol" => "obswebsocket.json"} ) openrequests = Hash( String, Channel( JSON::Any ) ).new eventsubs = Array( Channel(JSON::Any) ).new #metachan = Channel( Tuple( String, Channel( JSON::Any ) ) ).new spawn do queue = Array( Tuple( Channel( JSON::Any ) | Nil, String ) ).new while msgtuple = @@reqchan.receive if msgtuple[1] == "clear queue" queue.each do | msg | reschan = msg[0] print( "SENT: " ) json = JSON.parse(msg[1]) pp json if reschan openrequests[ json["d"]["requestId"].as_s ] = reschan end obs_pubsub.send( msg[1] ) end elsif msgtuple[1] == "break" break elsif msgtuple[1] == "subscribe events" reschan = msgtuple[0] if reschan eventsubs.push(reschan) end elsif @negotiated reschan = msgtuple[0] print "SENT: " json = JSON.parse( msgtuple[1] ) pp json if reschan openrequests[ json["d"]["requestId"].as_s ] = reschan end obs_pubsub.send( msgtuple[1] ) elsif JSON.parse( msgtuple[1] )["op"].as_i64 == 1 print "SENT: " json = JSON.parse( msgtuple[1] ) pp json obs_pubsub.send( msgtuple[1] ) else print "QUEUED: " pp msgtuple[1] queue.push( { msgtuple[0], msgtuple[1] } ) end end end obs_pubsub.on_message do | message | json = JSON.parse( message ) print( "RECEIVED: ") print( json.to_pretty_json ) print( "\n") if json["error"]? puts json["error"] next end case json["op"].as_i64 when 0 # hello hello = JSON.build do |j| j.object do j.field "op", 1 j.field "d" do j.object do j.field "rpcVersion", 1 j.field "eventSubscriptions", 526335 if json["d"]["authentication"]? && password secret = Base64.encode(OpenSSL::Digest.new("sha256").update( password + json["d"]["authentication"]["salt"].as_s ).final).chomp auth = Base64.encode(OpenSSL::Digest.new("sha256").update( secret + json["d"]["authentication"]["challenge"].as_s ).final).chomp j.field "authentication", auth end end end end end self.send( hello.to_s ) when 2 # identified @negotiated = true @connecterror = 0 self.reqchan.send( { nil, "clear queue" } ) when 5 # event eventsubs.each do | eventchan | eventchan.send( json["d"] ) end # A Fiber.yield occurs immediately after this to make sure "json" doesn't get overwritten before we can use it. spawn do d = json["d"] case d["eventType"].as_s when "CurrentPreviewSceneChanged" edata = d["eventData"] @@scenes.previewcache( edata["sceneName"].as_s ) when "CurrentProgramSceneChanged" edata = d["eventData"] @@scenes.programcache( edata["sceneName"].as_s ) when "InputCreated" edata = d["eventData"] @@inputs[edata["inputName"].to_s] = OBSWebSocket::Input.new( @@reqchan, edata ) when "InputNameChanged" edata = d["eventData"] @@inputs.renamecache( edata["oldInputName"].as_s, edata["inputName"].as_s ) when "InputRemoved" edata = d["eventData"] @@inputs.deletecache( edata["inputName"].as_s ) when "SceneItemRemoved" edata = d["eventData"] @@scenes[edata["sceneName"].to_s].deletecache( edata["sceneItemId"].as_i64, edata["sourceName"].as_s ) when "SceneItemEnableStateChanged" edata = d["eventData"] @@scenes[edata["sceneName"].as_s][edata["sceneItemId"].as_i64].enabledcache( edata["sceneItemEnabled"].as_bool ) when "SceneItemTransformChanged" edata = d["eventData"] transform = edata["sceneItemTransform"] sceneitem = @@scenes[edata["sceneName"].as_s][edata["sceneItemId"].as_i64] sceneitem.transform.cache( transform ) if ( transform["sourceHeight"].as_f.to_i64 > 0 ) && ( transform["sourceWidth"].as_f.to_i64 > 0 ) sources.last_known_real_transform_add( sceneitem.name, transform ) end when "SourceDestroyed" # We always dynamically assemble sources from inputs and scenes, so this event is ignored. when "SourceFilterEnableStateChanged" edata = json["d"]["eventData"] if ( source = @sources[edata["sourceName"].as_s]? ) puts "SourceFilterEnableStateChanged: #{source.class}" source.filters[edata["filterName"].as_s].enabledcache( edata["filterEnabled"].as_bool ) else raise SourceMissingException.new( "SourceFilterEnableStateChanged: missing source #{ edata["sourceName"].as_s}" ) end end end Fiber.yield when 7 # response # A Fiber.yield occurs immediately after this to make sure "json" doesn't get overwritten before we can use it. spawn do d = json["d"] if d["requestStatus"]["result"].as_bool == false puts "ERROR: #{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" end if openrequests[ d["requestId"].as_s ]? channel = openrequests[ d["requestId"].as_s ] channel.send( d ) channel.close openrequests.delete( d["requestId"].as_s ) else puts "PUBSUBREAD THREAD: no response channel known for requestId: #{ d["requestId"].as_s }" end end Fiber.yield end end obs_pubsub.run rescue ex : Socket::ConnectError | IO::Error if @connecterror < 3 print ex.message @connecterror += 1 ( @connecterror == 3 ) && ( @connecterror += 1 ) && print ". Suppressing further repeat errors." print "\n" end rescue ex pp ex pp ex.backtrace? ensure obs_pubsub && obs_pubsub.close @negotiated = false @@reqchan.send( { nil, "break" } ) # ask reader fiber to terminate # Should we invalidate all the cached ORM data here? sleep 10 end end end end class Outputs @outputs = Hash( String, OBSWebSocket::Output ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetOutputList", UUID.random.to_s ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) print "OUTPUTS THREAD: " pp rdata rdata["outputs"].as_a.each do | output | @outputs[output["outputName"].as_s] = OBSWebSocket::Output.new( @reqchan, output ) end else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def []( key : String ) @outputs[key]? || populate @outputs[key] end def to_h @outputs.empty? && populate @outputs end end class Output getter status : OBSWebSocket::OutputStatus getter flags : Hash( String, Bool ) getter state : Hash( String, String | Bool | Int64 | Float64 | Hash( String, Bool ) ) def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), json : JSON::Any ) @flags = Hash( String, Bool ).from_json(json["outputFlags"].to_json) @status = OBSWebSocket::OutputStatus.new( @reqchan, json["outputName"].as_s ) @state = Hash(String, String | Bool | Int64 | Float64 | Hash( String, Bool ) ).from_json(json.to_json) end end class OutputStatus @age = Int64.new( 0 ) @status = Hash(String, String | Bool | Int64 | Float64 ).new getter name : String def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @name : String ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetOutputStatus", UUID.random.to_s, JSON.parse( { "outputName" => @name }.to_json ) ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @age = Time.utc.to_unix @status = Hash(String, String | Bool | Int64 | Float64 ).from_json(rdata.to_json) else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def []( key : String ) ( @age < ( Time.utc.to_unix - 10 ) ) && populate @status[key]? || populate @status[key] end def to_h : Hash(String, String | Bool | Int64 | Float64 ) ( @age < ( Time.utc.to_unix - 10 ) ) && populate @status.empty? && populate @status end end class VideoSettings @settings = Hash(String, String | Bool | Int64 | Float64 ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetVideoSettings", UUID.random.to_s ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @settings = Hash(String, String | Bool | Int64 | Float64 ).from_json(rdata.to_json) else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def settings @settings.empty? && populate @settings end def []( key : String ) @settings[key]? || populate @settings[key] end def to_h @settings.empty? && populate @settings end end class Stats @stats = Hash(String, String | Bool | Int64 | Float64 ).new @age = Int64.new( 0 ) def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetStats", UUID.random.to_s ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @stats = Hash(String, String | Bool | Int64 | Float64 ).from_json(rdata.to_json) @age = Time.utc.to_unix else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def []( key : String ) ( @age < ( Time.utc.to_unix - 10 ) ) && populate @stats[key]? || populate @stats[key] end def to_h : Hash(String, String | Bool | Int64 | Float64 ) @stats.empty? && populate @stats end end class SceneCollection # Note: distinct from SceneList @current = String.new @list = Hash( String, OBSWebSocket::SceneList ).new @reqchan = Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetSceneCollectionList", UUID.random.to_s ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @current = rdata["currentSceneCollectionName"].as_s rdata["sceneCollections"].as_a.each{ | scenecollection | @list[scenecollection.as_s] = OBSWebSocket::SceneList.new( @reqchan ) } else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end return true end def current() @current.empty? && populate() return @current end def []( scenelist : String ) if list[scenelist]? return list[scenelist] else self.populate end end end class SceneList # Note: distinct from SceneCollection @scenes_by_index = Array(String).new @scenes = Hash(String, OBSWebSocket::Scene).new @program = String.new @preview = String.new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @inputs : OBSWebSocket::Inputs ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetSceneList", UUID.random.to_s ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @program = rdata["currentProgramSceneName"].as_s @preview = rdata["currentPreviewSceneName"].as_s rdata["scenes"].as_a.each_with_index{ | scene, i | name = scene["sceneName"].as_s @scenes_by_index.push(name) @scenes[name] = OBSWebSocket::Scene.new( @reqchan, name, self ) } else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end return true end def invalidate puts "SCENELIST THREAD: scenes invalidated" @scenes_by_index = Array(String).new @scenes = Hash(String, OBSWebSocket::Scene).new @program = String.new @preview = String.new end def programcache( v : String ) @program = v end def program @program.empty? && populate() @scenes[@program] end def previewcache( v : String ) @preview = v end def preview @preview.empty? && populate() @scenes[@preview] end def current @program.empty? && populate @scenes[@program] end def []( index : Int ) : OBSWebSocket::Scene @scenes_by_index[index]? || populate @scenes[@scenes_by_index[index]]? || populate @scenes[@scenes_by_index[index]] end def []( index : String ) : OBSWebSocket::Scene @scenes[index]? || populate @scenes[index] end def []?( index : String ) : OBSWebSocket::Scene | Nil @scenes[index]? || populate @scenes[index]? end def to_h : Hash( String, OBSWebSocket::Scene ) @scenes.empty? && populate return @scenes end end class Scene @items_by_id = Hash( Int64, OBSWebSocket::SceneItem ).new @items_by_name = Hash( String, Int64 ).new @items_by_index = Array( Int64 ).new @subscenes = Array( OBSWebSocket::Scene ).new getter filters : OBSWebSocket::SourceFilters getter name : String getter parent : OBSWebSocket::SceneList # needed for reverse metasceneitem lookup @age = Int64.new( 0 ) def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @name : String, @parent : OBSWebSocket::SceneList ) @filters = OBSWebSocket::SourceFilters.new( @reqchan, @name ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetSceneItemList", UUID.random.to_s, JSON.parse( { "sceneName" => @name }.to_json ) ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) # handle "sourceType": "OBS_SOURCE_TYPE_SCENE" rdata["sceneItems"].as_a.each_with_index{ | item, i | sname = item["sourceName"].as_s siid = item["sceneItemId"].as_i64 @items_by_id[siid] = OBSWebSocket::SceneItem.new( @reqchan, @name, item ) @items_by_index.push( siid ) @items_by_name[sname] = siid if item["sourceType"].as_s == "OBS_SOURCE_TYPE_SCENE" @subscenes.push( @parent[sname] ) end } @age = Time.utc.to_unix else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end return true end # Sources may be present a scene via other scenes. # We can still look these up by source name. # FIXME: does not recurse # Note: OBS refuses to create a referential loop # FIXME: only returns one SceneItem def metascene : Hash( String, OBSWebSocket::SceneItem ) @subscenes.empty? && populate metascene = Hash( String, OBSWebSocket::SceneItem ).new @subscenes.each do | subscene | metascene.merge!(subscene.to_h) end @items_by_id.values.each do | sitem | metascene[ sitem.name ] = sitem end return metascene end # less requests generated with this method # but Nil union is more annoying # FIXME: See metascene() def metasceneitem?( name : String ) : OBSWebSocket::SceneItem | Nil result = nil if self[name]? result = self[name] else @subscenes.each do | scene | if scene[name]? result = scene[name] break end end end return result end def []?( name : String ) : OBSWebSocket::SceneItem | Nil ( @items_by_name[name]? || ( @age > 0 ) ) || populate if @items_by_name[name]? ( @items_by_id[ @items_by_name[name] ]? || ( @age > 0 ) ) || populate return @items_by_id[ @items_by_name[name] ] else return nil end end def []( name : String ) : OBSWebSocket::SceneItem @items_by_name[name]? || populate @items_by_id[ @items_by_name[name] ]? || populate return @items_by_id[ @items_by_name[name] ] end def []( id : Int64 ) : OBSWebSocket::SceneItem @items_by_id[id]? || populate return @items_by_id[id] end def by_index( index : Int64 ) @items_by_index[index]? || populate @items_by_id[ @items_by_index[index] ]? || populate return @items_by_id[ @items_by_index[index] ] end def to_h : Hash( String, OBSWebSocket::SceneItem ) @items_by_id.empty? && populate hash = Hash( String, OBSWebSocket::SceneItem ).new @items_by_name.each do | key, value | hash[key] = @items_by_id[value] end return hash end def current! program! end def program! @reqchan.send( { nil, OBSWebSocket.req( "SetCurrentProgramScene", UUID.random.to_s, JSON.parse( { "sceneName" => @name }.to_json ) ) } ) end def preview! @reqchan.send( { nil, OBSWebSocket.req( "SetCurrentPreviewScene", UUID.random.to_s, JSON.parse( { "sceneName" => @name }.to_json ) ) } ) end def createinput( iname : String, kind : String, settings : OBSWebSocket::InputSettings | Hash( String, String | Bool | Int64 | Float64 ) | Nil = nil, enabled : Bool = true ) reqdata = ( Hash( String, String | Bool | Int64 | Float64 | Hash( String, String | Bool | Int64 | Float64 ) ) ).new reqdata["sceneName"] = @name reqdata["inputName"] = iname reqdata["inputKind"] = kind if settings reqdata["inputSettings"] = settings.to_h end unless enabled reqdata["sceneItemEnabled"] = false end reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "CreateInput", UUID.random.to_s, JSON.parse(reqdata.to_json) ) } ) d = reschan.receive # maybe block until the event comes in? # will have to depend on the EventSubscription state if d["requestStatus"]["result"].as_bool # success? # "responseData": { "sceneItemId": 219 } # Is this ever useful? It's a lot more requests than to just redo the entire GetSceneItemList #siid = d["responseData"]["sceneItemId"].as_i64 parent.@inputs.deletecache self.deletecache end return d end def deletecache( ) @items_by_id = Hash( Int64, OBSWebSocket::SceneItem ).new @items_by_name = Hash( String, Int64 ).new @items_by_index = Array( Int64 ).new @subscenes = Array( OBSWebSocket::Scene ).new end def deletecache( siid : Int64, siname : String ) @items_by_id.delete( siid ) @items_by_name.delete( siname ) @items_by_index.delete( siid ) end end class SceneItem getter id : Int64 getter name : String getter kind : ( String | Nil ) getter type : String getter scenename : String getter enabled : Bool getter blendmode : ( String | Nil ) getter locked : Bool getter transform : OBSWebSocket::SceneItemTransform def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @scenename : String, json : JSON::Any ) @id = json["sceneItemId"].as_i64 @name = json["sourceName"].as_s @kind = json["inputKind"]?.as_s? @type = json["sourceType"].as_s @locked = json["sceneItemLocked"].as_bool @enabled = json["sceneItemEnabled"].as_bool @blendmode = json["sceneItemBlendmode"]?.as_s? @transform = OBSWebSocket::SceneItemTransform.new( @reqchan, json["sceneItemTransform"], @name, @id ) end def enabledcache( v : Bool ) @enabled = v end def enabled @enabled end def toggle! if @enabled disable! else enable! end end def enable! @reqchan.send( { nil, OBSWebSocket.req( "SetSceneItemEnabled", UUID.random.to_s, JSON.parse( { "sceneName" => @scenename, "sceneItemId" => @id, "sceneItemEnabled" => true }.to_json ) ) } ) end def disable! @reqchan.send( { nil, OBSWebSocket.req( "SetSceneItemEnabled", UUID.random.to_s, JSON.parse( { "sceneName" => @scenename, "sceneItemId" => @id, "sceneItemEnabled" => false }.to_json ) ) } ) end def transform( newtransform : OBSWebSocket::SceneItemTransform | Hash( String, String | Bool | Int64 | Float64 ) ) reqdata = ( Hash( String, String | Bool | Int64 | Float64 | Hash( String, String | Bool | Int64 | Float64 ) ) ).new reqdata["sceneItemId"] = @id.to_i64 reqdata["sceneName"] = @scenename reqdata["sceneItemTransform"] = Hash( String, String | Bool | Int64 | Float64).new.merge( newtransform.to_h ) reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "SetSceneItemTransform", UUID.random.to_s, JSON.parse(reqdata.to_json) ) } ) d = reschan.receive end end class SceneItemTransform @sceneitemtransform = Hash(String, String | Bool | Int64 | Float64 ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), json : JSON::Any, @sceneitemname : String, @sceneitemid : Int64 ) @sceneitemtransform = Hash(String, String | Bool | Int64 | Float64 ).from_json(json.to_json) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetSceneItemTransform", UUID.random.to_s, JSON.parse( { "sceneName" => @sceneitemname, "sceneItemId" => @sceneitemid }.to_json ) ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @sceneitemtransform = Hash(String, String | Bool | Int64 | Float64 ).from_json(rdata["sceneItemTransform"].to_json) else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def []( key : String ) @sceneitemtransform[key]? || populate @sceneitemtransform[key] end def to_h @sceneitemtransform.empty? && populate @sceneitemtransform end def cache( newtransform = JSON::Any | Hash(String, String | Bool | Int64 | Float64 ) ) @sceneitemtransform = Hash(String, String | Bool | Int64 | Float64 ).from_json(newtransform.to_json) end end class Sources getter scenes : OBSWebSocket::SceneList getter inputs : OBSWebSocket::Inputs @last_known_real_transform = Hash( String, OBSWebSocket::SceneItemTransform ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @inputs : OBSWebSocket::Inputs, @scenes : OBSWebSocket::SceneList ) end def []?( index : String ) : OBSWebSocket::Scene | OBSWebSocket::Input | Nil if @inputs[index]? return @inputs[index] elsif @scenes[index]? return @scenes[index] else return nil end end def to_h() : Hash( String, OBSWebSocket::Scene | OBSWebSocket::Input ) return @inputs.to_h.merge( @scenes.to_h ) end def last_known_real_transform_add( sourcename : String, transform : JSON::Any ) @last_known_real_transform[ sourcename ] = OBSWebSocket::SceneItemTransform.new( @reqchan, transform, sourcename, 0.to_i64 ) end def last_known_real_transform( sourcename : String ) : OBSWebSocket::SceneItemTransform @last_known_real_transform[ sourcename ] end def last_known_real_transform?( sourcename : String ) : OBSWebSocket::SceneItemTransform | Nil @last_known_real_transform[ sourcename ]? end end class Inputs @inputs = Hash( String, OBSWebSocket::Input ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetInputList", UUID.random.to_s) } ) d = reschan.receive if ( rdata = d["responseData"]? ) rdata["inputs"].as_a.each{ | input | @inputs[input["inputName"].as_s] = OBSWebSocket::Input.new( @reqchan, input ) } else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def []?( index : String ) : OBSWebSocket::Input | Nil @inputs[index]? || populate return @inputs[index]? end def []( index : String ) @inputs[index]? || populate return @inputs[index] end def []=( index : String, input = OBSWebSocket::Input ) @inputs[index] = input end def to_h : Hash( String, OBSWebSocket::Input ) @inputs.empty? && populate return @inputs end def renamecache( old : String, new : String ) @inputs[new] = @inputs[old] @inputs.delete( old ) @inputs[new].namecache( new ) end def deletecache() @inputs = Hash( String, OBSWebSocket::Input ).new end def deletecache( input : String ) @inputs.delete( input ) end end class Input getter kind : String getter name : String getter settings : OBSWebSocket::InputSettings getter filters : OBSWebSocket::SourceFilters def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), json : JSON::Any ) @kind = json["inputKind"].as_s @name = json["inputName"].as_s @settings = OBSWebSocket::InputSettings.new( @reqchan, @name ) @filters = OBSWebSocket::SourceFilters.new( @reqchan, @name ) end def delete!( ) reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "RemoveInput", UUID.random.to_s, JSON.parse({ "inputName" => @name }.to_json) ) } ) d = reschan.receive end def namecache( name : String ) @name = name @settings.namecache( name ) @filters.to_h.values.each do | value | value.sourcenamecache( name ) end end end class InputSettings @inputsettings = Hash(String, String | Bool | Int64 | Float64 ).new @inputkind = String.new getter name : String def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @name : String ) end def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @name : String, @inputkind : String, @inputsettings : Hash( String, String | Bool | Int64 | Float64 ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetInputSettings", UUID.random.to_s, JSON.parse( { "inputName" => @name }.to_json ) ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) @inputsettings = Hash(String, String | Bool | Int64 | Float64 ).from_json(rdata["inputSettings"].to_json) @inputkind = rdata["inputKind"].as_s else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def kind @inputkind.empty? && populate @inputkind end def []( key : String ) @inputsettings[key]? || populate @inputsettings[key] end def to_h @inputsettings.empty? && populate @inputsettings end def namecache( name : String) @name = name end end class SourceFilters # Note: a property of both OBSWebSocket::Input and OBSWebSocket::Scene @filters_by_name = Hash( String, OBSWebSocket::SourceFilter ).new @filters_by_index = Array( String ).new getter sourcename : String def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @sourcename : String ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "GetSourceFilterList", UUID.random.to_s, JSON.parse( { "sourceName" => @sourcename }.to_json ) ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) rdata["filters"].as_a.each do | filter | name = filter["filterName"].as_s @filters_by_index.push( name ) @filters_by_name[name] = OBSWebSocket::SourceFilter.new( @reqchan, @sourcename, filter ) end else raise ResponseDataMissingException.new( "#{d["requestType"].as_s} #{d["requestStatus"]["code"].as_i64}: #{d["requestStatus"]["comment"].as_s}" ) end end def []( index : Int64 ) @filters_by_index[index]? || populate @filters_by_name[@filters_by_index[index]]? || populate @filters_by_name[@filters_by_index[index]] end def []?( key : String ) : OBSWebSocket::SourceFilter | Nil @filters_by_name[key]? || populate @filters_by_name[key]? end def []( key : String ) @filters_by_name[key]? || populate @filters_by_name[key] end def to_h @filters_by_name.empty? && populate @filters_by_name end end class SourceFilter getter name : String getter kind : String getter enabled : Bool getter settings : OBSWebSocket::SourceFilterSettings getter sourcename : String def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @sourcename, json : JSON::Any ) @kind = json["filterKind"].as_s @name = json["filterName"].as_s @enabled = json["filterEnabled"].as_bool @settings = OBSWebSocket::SourceFilterSettings.new( @reqchan, @name, json["filterSettings"] ) end def toggle! if @enabled disable! else enable! end end def enable! reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "SetSourceFilterEnabled", UUID.random.to_s, JSON.parse( { "sourceName" => @sourcename, "filterName" => @name, "filterEnabled" => true }.to_json ) ) } ) d = reschan.receive return d["requestStatus"]["result"].as_bool end def disable! reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBSWebSocket.req( "SetSourceFilterEnabled", UUID.random.to_s, JSON.parse( { "sourceName" => @sourcename, "filterName" => @name, "filterEnabled" => false }.to_json ) ) } ) d = reschan.receive return d["requestStatus"]["result"].as_bool end def enabledcache( bool : Bool ) @enabled = bool end def sourcenamecache( name : String ) @sourcename = name end end class SourceFilterSettings getter settings : Hash(String, String | Bool | Int64 | Float64 ) getter sourcename : String def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @sourcename, json : JSON::Any ) @settings = Hash(String, String | Bool | Int64 | Float64 ).from_json(json.to_json) end # do we need to populate() this somehow? # SetSourceFilterSettings is available, but # GetSourceFilterSettings is not. Must use # GetSourceFilters instead. def []( key : String ) @settings[key] end def to_h @settings end end end