require "json" require "pretty_print" require "http" require "uuid" struct Nil def as_s? self end end module OBS 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 WriterUnavailableException < Exception end class PasswordMissingException < Exception end class SourceMissingException < Exception end class ResponseDataMissingException < Exception end class WebSocket @@writer : Fiber | Nil @@reqchan = Channel( Tuple( ( Channel( JSON::Any ) | Nil ), String ) ).new @scenecollection = OBS::SceneCollection.new( @@reqchan, @@inputs ) @@inputs = OBS::Inputs.new( @@reqchan ) @@scenes = OBS::SceneList.new( @@reqchan, @@inputs ) @sources = OBS::Sources.new( @@reqchan, @@inputs, @@scenes ) @outputs = OBS::Outputs.new( @@reqchan ) @video = OBS::VideoSettings.new( @@reqchan ) @stats = OBS::Stats.new( @@reqchan ) @negotiated = false @connecterror = 0 @shutdown = false @closed = true def closed? return @closed end 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 send( reschan, OBS.req( "TriggerStudioModeTransition", UUID.random.to_s ) ) return reschan.receive end # request channel, response channel def send( json : String ) if ( @@writer && ! @@writer.not_nil!.dead? ) @@reqchan.send( { nil, json } ) else raise WriterUnavailableException.new( "Writer fiber is not available." ) end end def send( reschan : ( Channel( JSON::Any ) | Nil ), json : String ) if @@writer && ! @@writer.not_nil!.dead? @@reqchan.send( { reschan, json } ) else raise WriterUnavailableException.new( "Writer fiber is not available." ) end 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, retry : Bool = true ) spawn do eventsubs = Array( Channel(JSON::Any) ).new # initialize this here to it survives across connection retries loop do if @shutdown break end @obs_pubsub = HTTP::WebSocket.new( URI.parse( uri ), HTTP::Headers{"Cookie" => "SESSIONID=1235", "Sec-WebSocket-Protocol" => "OBS.json"} ) @obs_pubsub.on_close do | message | # for some reason HTTP::WebSocket.closed? SIGSEGVs on a dangling pointer on the 1.6 runtime, so we hack around it with our own state variable for now. @closed = true end @closed = false openrequests = Hash( String, Channel( JSON::Any ) ).new #metachan = Channel( Tuple( String, Channel( JSON::Any ) ) ).new @@writer = 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] STDERR.print( "SENT: " ) json = JSON.parse(msg[1]) STDERR.print( json.pretty_inspect ) 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] STDERR.print( "SENT: " ) json = JSON.parse( msgtuple[1] ) STDERR.print( json.pretty_inspect ) if reschan openrequests[ json["d"]["requestId"].as_s ] = reschan end @obs_pubsub.send( msgtuple[1] ) elsif JSON.parse( msgtuple[1] )["op"].as_i64 == 1 STDERR.print( "SENT: " ) json = JSON.parse( msgtuple[1] ) STDERR.print( json.pretty_inspect ) @obs_pubsub.send( msgtuple[1] ) else STDERR.print( "QUEUED: " ) STDERR.print( msgtuple[1].pretty_inspect ) queue.push( { msgtuple[0], msgtuple[1] } ) end end end @obs_pubsub.on_message do | message | json = JSON.parse( message ) STDERR.print( "RECEIVED: ") STDERR.print( json.to_pretty_json ) STDERR.print( "\n") if json["error"]? STDERR.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"]? if 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 else raise PasswordMissingException.new( "Negotiation: missing password" ) end 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 # FIXME: These should be switched over to Enum flags 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 "CurrentSceneCollectionChanging" # FIXME: switch to queue in reqchan @scenecollection.deletecache() @@scenes.deletecache() when "CurrentSceneCollectionChanged" # FIXME: empty queue in reqchan @scenecollection.deletecache() @@scenes.deletecache() edata = d["eventData"] @scenecollection.currentcache( edata["sceneCollectionName"].as_s ) when "SceneCollectionListChanged" @scenecollection.deletecache() # FIXME: Save requests by using the edata here when "CurrentProfileChanging" when "CurrentProfileChanged" when "ProfileListChanged" when "SceneListChanged" # FIXME when "SceneCreated" # FIXME when "SceneRemoved" # FIXME when "SceneNameChanged" # FIXME when "InputCreated" edata = d["eventData"] @@inputs[edata["inputName"].to_s] = OBS::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 "InputActiveStateChanged" when "InputShowStateChanged" when "InputMuteStateChanged" when "InputVolumeChanged" when "InputAudioBalanceChanged" when "InputAudioSyncOffsetChanged" when "InputAudioTracksChanged" when "InputAudioMonitorTypeChanged" when "InputVolumeMeters" when "MediaInputPlaybackStarted" when "MediaInputPlaybackEnded" when "MediaInputActionTriggered" when "CurrentSceneTransitionChanged" when "CurrentSceneTransitionDurationChanged" when "StudioModeStateChanged" when "SceneTransitionStarted" when "SceneTransitionEnded" when "SceneTransitionVideoEnded" when "SceneItemSelected" when "SceneItemCreated" # FIXME 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 "SceneItemListReindexed" # FIXME when "SceneItemLockStateChanged" 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]? ) STDERR.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 when "SourceFilterListReindexed" # FIXME when "SourceFilterCreated" # FIXME when "SourceFilterRemoved" # FIXME when "SourceFilterNameChanged" # FIXME when "StreamStateChanged" when "RecordStateChanged" when "ReplayBufferStateChanged" when "VirtualcamStateChanged" when "ReplayBufferSaved" 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 STDERR.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 STDERR.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 STDERR.print( ex.message ) @connecterror += 1 ( @connecterror == 3 ) && ( @connecterror += 1 ) && STDERR.print( ". Suppressing further repeat errors." ) STDERR.print( "\n" ) end rescue ex STDERR.print( ex.pretty_inspect ) STDERR.print( ex.backtrace?.pretty_inspect ) ensure @closed || @obs_pubsub.close @negotiated = false if @@writer unless @@writer.not_nil!.dead? # wtf is the type checker smoking that Nil would be an option here? @@reqchan.send( { nil, "break" } ) # ask writer fiber to terminate end @@writer = nil end sleep 10 # invalidate state on everything @scenecollection = OBS::SceneCollection.new( @@reqchan, @@inputs ) @@inputs = OBS::Inputs.new( @@reqchan ) @@scenes = OBS::SceneList.new( @@reqchan, @@inputs ) @sources = OBS::Sources.new( @@reqchan, @@inputs, @@scenes ) @outputs = OBS::Outputs.new( @@reqchan ) @video = OBS::VideoSettings.new( @@reqchan ) @stats = OBS::Stats.new( @@reqchan ) retry || ( @shutdown = true ) end end end def close @shutdown = true # Does it matter if the writer thread is even running, here? @@reqchan.send( { nil, "break" } ) # ask writer fiber to terminate @obs_pubsub && @obs_pubsub.close end end class Outputs @outputs = Hash( String, OBS::Output ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.req( "GetOutputList", UUID.random.to_s ) } ) d = reschan.receive if ( rdata = d["responseData"]? ) STDERR.print( "OUTPUTS THREAD: " ) STDERR.print( rdata.pretty_inspect ) rdata["outputs"].as_a.each do | output | @outputs[output["outputName"].as_s] = OBS::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 : OBS::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 = OBS::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, OBS.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, OBS.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, OBS.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, OBS::SceneList ).new @reqchan = Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @inputs : OBS::Inputs ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.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] = OBS::SceneList.new( @reqchan, @inputs ) } 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 def deletecache() @current = String.new @list = Hash( String, OBS::SceneList ).new end def currentcache( @current : String ) end end class SceneList # Note: distinct from SceneCollection @scenes_by_index = Array(String).new @scenes = Hash(String, OBS::Scene).new @program = String.new @preview = String.new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @inputs : OBS::Inputs ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.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] = OBS::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 STDERR.puts( "SCENELIST THREAD: scenes invalidated" ) @scenes_by_index = Array(String).new @scenes = Hash(String, OBS::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 ) : OBS::Scene @scenes_by_index[index]? || populate @scenes[@scenes_by_index[index]]? || populate @scenes[@scenes_by_index[index]] end def []( index : String ) : OBS::Scene @scenes[index]? || populate @scenes[index] end def []?( index : String ) : OBS::Scene | Nil @scenes[index]? || populate @scenes[index]? end def to_h : Hash( String, OBS::Scene ) @scenes.empty? && populate return @scenes end def deletecache() @scenes_by_index = Array(String).new @scenes = Hash(String, OBS::Scene).new @program = String.new @preview = String.new end end class Scene @items_by_id = Hash( Int64, OBS::SceneItem ).new @items_by_name = Hash( String, Int64 ).new @items_by_index = Array( Int64 ).new @subscenes = Array( OBS::Scene ).new getter filters : OBS::SourceFilters getter name : String getter parent : OBS::SceneList # needed for reverse metasceneitem lookup @age = Int64.new( 0 ) def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @name : String, @parent : OBS::SceneList ) @filters = OBS::SourceFilters.new( @reqchan, @name ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.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] = OBS::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. # Note: OBS refuses to create a referential loop def metascene : Hash( String, Array( OBS::SceneItem ) ) @subscenes.empty? && populate metascene = Hash( String, Array( OBS::SceneItem ) ).new newsubscenes = [ self ] while ( subscene = newsubscenes.shift? ) subscene.to_h.each do | siname, sceneitem | if sceneitem.type == "OBS_SOURCE_TYPE_SCENE" newsubscenes.push( parent[siname] ) end metascene[ siname ]? || ( metascene[ siname ] = Array( OBS::SceneItem ).new ) metascene[ siname ].push( sceneitem ) # We can have the same scene included in the scene graph multiple times, which will duplicate all its sceneitems in the resulting metascene array. Might be a good idea to add a uniqueness constraint at some point? Not sure anyone would really care one way or the other. end end return metascene end # returns first SceneItem it finds or Nil # less requests are generated with this method # but managing Nil unions is more annoying def metasceneitem?( name : String ) : OBS::SceneItem | Nil result = nil newsubscenes = [ self ] while ( subscene = newsubscenes.shift? ) subscene.to_h.each do | siname, sceneitem | if sceneitem.type == "OBS_SOURCE_TYPE_SCENE" newsubscenes.push( parent[siname] ) end if siname == name result = sceneitem break end end end return result end def []?( name : String ) : OBS::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 ) : OBS::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 ) : OBS::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, OBS::SceneItem ) @items_by_id.empty? && populate hash = Hash( String, OBS::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! reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.req( "SetCurrentProgramScene", UUID.random.to_s, JSON.parse( { "sceneName" => @name }.to_json ) ) } ) reschan.receive # we get weird behavior on transitions unless we block here end def preview! reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.req( "SetCurrentPreviewScene", UUID.random.to_s, JSON.parse( { "sceneName" => @name }.to_json ) ) } ) reschan.receive # we get weird behavior on transitions unless we block here end def createinput( iname : String, kind : String, settings : OBS::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, OBS.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, OBS::SceneItem ).new @items_by_name = Hash( String, Int64 ).new @items_by_index = Array( Int64 ).new @subscenes = Array( OBS::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 : OBS::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 = OBS::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, OBS.req( "SetSceneItemEnabled", UUID.random.to_s, JSON.parse( { "sceneName" => @scenename, "sceneItemId" => @id, "sceneItemEnabled" => true }.to_json ) ) } ) end def disable! @reqchan.send( { nil, OBS.req( "SetSceneItemEnabled", UUID.random.to_s, JSON.parse( { "sceneName" => @scenename, "sceneItemId" => @id, "sceneItemEnabled" => false }.to_json ) ) } ) end def transform( newtransform : OBS::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, OBS.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, OBS.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 : OBS::SceneList getter inputs : OBS::Inputs @last_known_real_transform = Hash( String, OBS::SceneItemTransform ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ), @inputs : OBS::Inputs, @scenes : OBS::SceneList ) end def []?( index : String ) : OBS::Scene | OBS::Input | Nil if @inputs[index]? return @inputs[index] elsif @scenes[index]? return @scenes[index] else return nil end end def to_h() : Hash( String, OBS::Scene | OBS::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 ] = OBS::SceneItemTransform.new( @reqchan, transform, sourcename, 0.to_i64 ) end def last_known_real_transform( sourcename : String ) : OBS::SceneItemTransform @last_known_real_transform[ sourcename ] end def last_known_real_transform?( sourcename : String ) : OBS::SceneItemTransform | Nil @last_known_real_transform[ sourcename ]? end end class Inputs @inputs = Hash( String, OBS::Input ).new def initialize( @reqchan : Channel( Tuple( Channel( JSON::Any ) | Nil, String ) ) ) end def populate reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.req( "GetInputList", UUID.random.to_s) } ) d = reschan.receive if ( rdata = d["responseData"]? ) rdata["inputs"].as_a.each{ | input | @inputs[input["inputName"].as_s] = OBS::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 ) : OBS::Input | Nil @inputs[index]? || populate return @inputs[index]? end def []( index : String ) @inputs[index]? || populate return @inputs[index] end def []=( index : String, input = OBS::Input ) @inputs[index] = input end def to_h : Hash( String, OBS::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, OBS::Input ).new end def deletecache( input : String ) @inputs.delete( input ) end end class Input getter kind : String getter name : String getter settings : OBS::InputSettings getter filters : OBS::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 = OBS::InputSettings.new( @reqchan, @name ) @filters = OBS::SourceFilters.new( @reqchan, @name ) end def delete!( ) reschan = Channel( JSON::Any ).new @reqchan.send( { reschan, OBS.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, OBS.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 OBS::Input and OBS::Scene @filters_by_name = Hash( String, OBS::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, OBS.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] = OBS::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 ) : OBS::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 : OBS::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 = OBS::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, OBS.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, OBS.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