require "json" require "pretty_print" require "http" require "uuid" struct Nil def as_s? self end end module OBS extend self @[Flags] enum EventSubscription : UInt32 Unused1 Config Scenes Inputs Transitions Filters Outputs SceneItems MediaInputs Vendors Ui Unused11 Unused12 Unused13 Unused14 Unused15 InputVolumeMeters InputActiveStateChanged InputShowStateChanged SceneItemTransformChanged # "All" in the protocol docs is not actually "all events", but "all non-high-volume events" because reasons??? # The unused enums are presumably reserved for that class. AllNonHigh = Config|Scenes|Inputs|Transitions|Filters|Outputs|SceneItems|MediaInputs|Vendors|Ui|Unused11|Unused12|Unused13|Unused14|Unused15 ORMCritical = Scenes|Inputs| Filters|Outputs|SceneItems|SceneItemTransformChanged end def req( type : String, data : ( String | Nil | JSON::Any ) = nil, id : String = UUID.random.to_s ) 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 PasswordMissingException < Exception end class SourceMissingException < Exception end class StudioModeException < Exception end class ResponseDataMissingException < Exception end class WebSocket # A bunch of these get initialized in OBS::WebSocket.initialize, # but we still want a class-wide typedef here to avoid managing Nils @inputs : OBS::Inputs = OBS::Inputs.allocate @scenes : OBS::SceneList = OBS::SceneList.allocate @sources : OBS::Sources = OBS::Sources.allocate @scenecollection : OBS::SceneCollection = OBS::SceneCollection.allocate @outputs : OBS::Outputs = OBS::Outputs.allocate @video : OBS::VideoSettings = OBS::VideoSettings.allocate @stats : OBS::Stats = OBS::Stats.allocate @studiomode : Bool | Nil = nil @eventsubs = Array( Tuple( Channel(JSON::Any), UInt32 ) ).new # convert this to Hash if anyone ever wants eventsub_remove() @openrequests = Hash( String, Channel( JSON::Any ) ).new @negotiated = false @negotiationdelayqueue = 0 @negotiationdelay = Channel( Exception | Nil ).new @connecterror = 0 @shutdown = false @closed = true @eventmask : UInt32 = (EventSubscription::AllNonHigh|EventSubscription::ORMCritical).value def closed? return @closed end def studiomode : Bool if @studiomode == nil d = self.send_sync( OBS.req( "GetStudioModeEnabled" ) ) @studiomode = d["responseData"]["studioModeEnabled"].as_bool end @studiomode.not_nil! end def studiomode=( state : Bool ) : Bool unless self.studiomode == state d = self.send_sync( OBS.req( "SetStudioModeEnabled", JSON.parse( { "studioModeEnabled" => state }.to_json ) ) ) d["requestStatus"]["result"].as_bool && ( @studiomode = state ) end return state end def studiomodecache=( state : Bool ) @studiomode = state end def eventsub_add( channel : Channel( JSON::Any ), mask : UInt32 = @eventmask ) unless @eventmask.bits_set?( mask ) @eventmask = @eventmask|( mask ) # bitwise OR if @websocket STDERR.puts( "Warning: eventsub_add mask requesting more bits than negotiated. Reconnecting." ) STDERR.puts( "Warning: To avoid this, initialize OBS::WebSocket with eventmask: #{@eventmask}" ) @websocket.not_nil!.close end end @eventsubs.push( { channel, mask } ) end def negotiated? return @negotiated 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! if self.studiomode self.send_sync( OBS.req( "TriggerStudioModeTransition" ) ) else raise StudioModeException.new("Not currently in Studio Mode.") end end def block_until_negotiated unless @negotiated @negotiationdelayqueue += 1 negotiationresult = @negotiationdelay.receive if negotiationresult.is_a?( Exception ) raise negotiationresult end @negotiated || return false # not sure why this condition would occur without an exception end end # Asynchronous send def send( json : String ) self.block_until_negotiated() STDERR.puts( "SENT: #{JSON.parse(json).pretty_inspect}" ) @websocket && @websocket.not_nil!.send( json ) end # Block until response def send_sync( json : String ) self.block_until_negotiated() reschan = Channel( JSON::Any ).new STDERR.puts( "SENT: #{JSON.parse(json).pretty_inspect}" ) @openrequests[ JSON.parse( json )["d"]["requestId"].as_s ] = reschan @websocket && @websocket.not_nil!.send( json ) return reschan.receive end def initialize( uri : String, password : String | Nil = nil, retry : Bool = true, @eventmask : UInt32 = @eventmask ) @scenecollection.initialize( self ) @inputs.initialize( self ) @scenes.initialize( self ) @sources.initialize( self ) @outputs.initialize( self ) @video.initialize( self ) @stats.initialize( self ) unless @eventmask.bits_set?( EventSubscription::ORMCritical.value ) STDERR.puts( "WARNING: ORM-critical event subscriptions are missing. Expect broken statekeeping." ) end spawn do loop do if @shutdown break end # FFS why is HTTP::WebSocket.initialize protected? # Now we have to .not_nil! this shit everywhere # @websocket.initialize( HTTP::WebSocket::Protocol.new( URI.parse( uri ), HTTP::Headers{"Cookie" => "SESSIONID=1235", "Sec-WebSocket-Protocol" => "OBS.json"} ) ) @websocket = HTTP::WebSocket.new( URI.parse( uri ), HTTP::Headers{"Cookie" => "SESSIONID=1235", "Sec-WebSocket-Protocol" => "OBS.json"} ) @websocket.not_nil!.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 @websocket.not_nil!.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", @eventmask 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 STDERR.puts( "SENT: #{hello.pretty_inspect}" ) @websocket.not_nil!.send( hello.to_s ) when 2 # identified @negotiated = true @negotiationdelayqueue.times do @negotiationdelay.send( nil ) @negotiationdelayqueue -= 1 end @connecterror = 0 when 5 # event @eventsubs.each do | eventsub | if ( eventsub[1].bits_set?( json["d"]["eventIntent"].as_i ) ) eventsub[0].send( json["d"] ) end 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" @scenecollection.deletecache() @scenes.deletecache() when "CurrentSceneCollectionChanged" @scenecollection.deletecache() @scenes.deletecache() edata = d["eventData"] @scenecollection.currentcache( edata["sceneCollectionName"].as_s ) when "SceneCollectionListChanged" @scenecollection.deletecache() # We could probably save requests by using the edata here. when "CurrentProfileChanging" when "CurrentProfileChanged" when "ProfileListChanged" when "SceneListChanged" @scenes.deletecache # We could probably save requests by using the edata here. when "SceneCreated" @scenes.deletecache # We could probably save requests by using the edata here. when "SceneRemoved" @scenes.deletecache # We could probably save requests by using the edata here. when "SceneNameChanged" @scenes.deletecache # We could probably save requests by using the edata here. when "InputCreated" edata = d["eventData"] @inputs[edata["inputName"].to_s] = OBS::Input.new( self, 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" edata = d["eventData"] @studiomode = edata["studioModeEnabled"].as_bool when "SceneTransitionStarted" when "SceneTransitionEnded" when "SceneTransitionVideoEnded" when "SceneItemSelected" when "SceneItemCreated" edata = d["eventData"] @scenes[edata["sceneName"].to_s].deletecache() # This could probably be made more efficient using the eventData 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" edata = d["eventData"] @scenes[edata["sceneName"].to_s].deletecache() # This could probably be made more efficient using the eventData when "SceneItemLockStateChanged" edata = d["eventData"] @scenes[edata["sceneName"].to_s].deletecache() 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" edata = d["eventData"] self.sources[edata["sourceName"].as_s].filters.deletecache() # This could probably be made more efficient using the eventData when "SourceFilterCreated" edata = d["eventData"] self.sources[edata["sourceName"].as_s].filters.deletecache() # This could probably be made more efficient using the eventData when "SourceFilterRemoved" edata = d["eventData"] self.sources[edata["sourceName"].as_s].filters.deletecache() # This could probably be made more efficient using the eventData when "SourceFilterNameChanged" edata = d["eventData"] self.sources[edata["sourceName"].as_s].filters.deletecache() # This could probably be made more efficient using the eventData 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 @websocket.not_nil!.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 unless retry @negotiationdelayqueue.times do @negotiationdelay.send( ex ) @negotiationdelayqueue -= 1 end end @closed || @websocket && @websocket.not_nil!.close @negotiated = false sleep 5 rescue ex STDERR.print( ex.pretty_inspect ) STDERR.print( ex.backtrace?.pretty_inspect ) @closed || @websocket && @websocket.not_nil!.close @negotiated = false sleep 5 ensure @closed || @websocket && @websocket.not_nil!.close @negotiated = false unless retry @negotiationdelayqueue.times do @negotiationdelay.send( nil ) @negotiationdelayqueue -= 1 end end # invalidate state on everything @scenecollection = OBS::SceneCollection.new( self ) @inputs = OBS::Inputs.new( self ) @scenes = OBS::SceneList.new( self ) @sources = OBS::Sources.new( self ) @outputs = OBS::Outputs.new( self ) @video = OBS::VideoSettings.new( self ) @stats = OBS::Stats.new( self ) retry || ( @shutdown = true ) end end end def close @shutdown = true @websocket && @websocket.close end end class Outputs @outputs = Hash( String, OBS::Output ).new def initialize( @obs : OBS::WebSocket ) end def populate d = @obs.send_sync( OBS.req( "GetOutputList" ) ) 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( @obs, 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( @obs : OBS::WebSocket, json : JSON::Any ) @flags = Hash( String, Bool ).from_json(json["outputFlags"].to_json) @status = OBS::OutputStatus.new( @obs, 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( @obs : OBS::WebSocket, @name : String ) end def populate d = @obs.send_sync( OBS.req( "GetOutputStatus", JSON.parse( { "outputName" => @name }.to_json ) ) ) 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( @obs : OBS::WebSocket ) end def populate d = @obs.send_sync( OBS.req( "GetVideoSettings" ) ) 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( @obs : OBS::WebSocket ) end def populate d = @obs.send_sync( OBS.req( "GetStats" ) ) 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 @inputs : OBS::Inputs @current = String.new @list = Hash( String, OBS::SceneList ).new def initialize( @obs : OBS::WebSocket ) @inputs = @obs.inputs end def populate d = @obs.send_sync( OBS.req( "GetSceneCollectionList" ) ) if ( rdata = d["responseData"]? ) @current = rdata["currentSceneCollectionName"].as_s rdata["sceneCollections"].as_a.each{ | scenecollection | @list[scenecollection.as_s] = OBS::SceneList.new( @obs ) } 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 : Nil | String = nil def initialize( @obs : OBS::WebSocket ) end def populate d = @obs.send_sync( OBS.req( "GetSceneList" ) ) if ( rdata = d["responseData"]? ) @program = rdata["currentProgramSceneName"].as_s if @preview = rdata["currentPreviewSceneName"].as_s? @obs.studiomodecache=true else @obs.studiomodecache=false end rdata["scenes"].as_a.each_with_index{ | scene, i | name = scene["sceneName"].as_s @scenes_by_index.push(name) @scenes[name] = OBS::Scene.new( @obs, 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 programcache( v : String ) @program = v end def program @program.empty? && populate() @scenes[@program] end def previewcache( v : String ) @preview = v end def preview @preview || populate() if @preview return @scenes[@preview] else return @preview end 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( @obs : OBS::WebSocket, @name : String, @parent : OBS::SceneList ) @filters = OBS::SourceFilters.new( @obs, @name ) end def populate d = @obs.send_sync( OBS.req( "GetSceneItemList", JSON.parse( { "sceneName" => @name }.to_json ) ) ) 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( @obs, @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! # we get weird behavior on transitions unless we block here @obs.send_sync( OBS.req( "SetCurrentProgramScene", JSON.parse( { "sceneName" => @name }.to_json ) ) ) end def preview! # we get weird behavior on transitions unless we block here @obs.studiomode || @obs.send_sync( OBS.req( "SetStudioModeEnabled", JSON.parse( { "studioModeEnabled" => true }.to_json ) ) ) @obs.send_sync( OBS.req( "SetCurrentPreviewScene", JSON.parse( { "sceneName" => @name }.to_json ) ) ) 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 d = @obs.send_sync( OBS.req( "CreateInput", JSON.parse(reqdata.to_json) ) ) # 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 @obs.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( @obs : OBS::WebSocket, @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( @obs, 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! @obs.send_sync( OBS.req( "SetSceneItemEnabled", JSON.parse( { "sceneName" => @scenename, "sceneItemId" => @id, "sceneItemEnabled" => true }.to_json ) ) ) end def disable! @obs.send_sync( OBS.req( "SetSceneItemEnabled", 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 ) @obs.send_sync( OBS.req( "SetSceneItemTransform", JSON.parse(reqdata.to_json) ) ) end end class SceneItemTransform @sceneitemtransform = Hash(String, String | Bool | Int64 | Float64 ).new def initialize( @obs : OBS::WebSocket, json : JSON::Any, @sceneitemname : String, @sceneitemid : Int64 ) @sceneitemtransform = Hash(String, String | Bool | Int64 | Float64 ).from_json(json.to_json) end def populate d = @obs.send_sync( OBS.req( "GetSceneItemTransform", JSON.parse( { "sceneName" => @sceneitemname, "sceneItemId" => @sceneitemid }.to_json ) ) ) 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 @last_known_real_transform = Hash( String, OBS::SceneItemTransform ).new def initialize( @obs : OBS::WebSocket ) end def []( index : String ) : OBS::Scene | OBS::Input if @obs.inputs[index]? return @obs.inputs[index] elsif @obs.scenes[index]? return @obs.scenes[index] else raise IndexError.new("Source unknown.") end end def []?( index : String ) : OBS::Scene | OBS::Input | Nil if @obs.inputs[index]? return @obs.inputs[index] elsif @obs.scenes[index]? return @obs.scenes[index] else return nil end end def to_h() : Hash( String, OBS::Scene | OBS::Input ) return @obs.@inputs.to_h.merge( @obs.@scenes.to_h ) end def last_known_real_transform_add( sourcename : String, transform : JSON::Any ) @last_known_real_transform[ sourcename ] = OBS::SceneItemTransform.new( @obs, 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( @obs : OBS::WebSocket ) end def populate d = @obs.send_sync( OBS.req( "GetInputList") ) if ( rdata = d["responseData"]? ) rdata["inputs"].as_a.each{ | input | @inputs[input["inputName"].as_s] = OBS::Input.new( @obs, 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 def pretty_print(pp) : Nil executed = exec_recursive(:pretty_print) do pp.list("{", self.to_h, "}") do |key, value| pp.group do key.pretty_print(pp) pp.text " =>" pp.nest do pp.breakable value.pretty_print(pp) end end end end pp.text "{...}" unless executed end end class Input getter kind : String getter name : String getter settings : OBS::InputSettings getter filters : OBS::SourceFilters def initialize( @obs : OBS::WebSocket, json : JSON::Any ) @kind = json["inputKind"].as_s @name = json["inputName"].as_s @settings = OBS::InputSettings.new( @obs, @name ) @filters = OBS::SourceFilters.new( @obs, @name ) end def delete!( ) @obs.send_sync( OBS.req( "RemoveInput", JSON.parse({ "inputName" => @name }.to_json) ) ) end def namecache( name : String ) @name = name @settings.namecache( name ) @filters.to_h.values.each do | value | value.sourcenamecache( name ) end end def pretty_print(pp) : Nil self.name.pretty_print(pp) self.kind.pretty_print(pp) self.settings.pretty_print(pp) self.filters.pretty_print(pp) end end class InputSettings @inputsettings = Hash(String, String | Bool | Int64 | Float64 ).new @inputkind = String.new getter name : String def initialize( @obs : OBS::WebSocket, @name : String ) end def initialize( @obs : OBS::WebSocket, @name : String, @inputkind : String, @inputsettings : Hash( String, String | Bool | Int64 | Float64 ) ) end def populate d = @obs.send_sync( OBS.req( "GetInputSettings", JSON.parse( { "inputName" => @name }.to_json ) ) ) 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 def pretty_print(pp) : Nil @inputkind.pretty_print(pp) @name.pretty_print(pp) @inputsettings.pretty_print(pp) 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( @obs : OBS::WebSocket, @sourcename : String ) end def populate d = @obs.send_sync( OBS.req( "GetSourceFilterList", JSON.parse( { "sourceName" => @sourcename }.to_json ) ) ) 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( @obs, @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 def deletecache() @filters_by_name = Hash( String, OBS::SourceFilter ).new @filters_by_index = Array( String ).new end def pretty_print(pp) : Nil @sourcename.pretty_print(pp) self.to_h.pretty_print(pp) end end class SourceFilter getter name : String getter kind : String getter enabled : Bool getter settings : OBS::SourceFilterSettings getter sourcename : String def initialize( @obs : OBS::WebSocket, @sourcename, json : JSON::Any ) @kind = json["filterKind"].as_s @name = json["filterName"].as_s @enabled = json["filterEnabled"].as_bool @settings = OBS::SourceFilterSettings.new( @obs, @name, json["filterSettings"] ) end def toggle! if @enabled disable! else enable! end end def enable! d = @obs.send_sync( OBS.req( "SetSourceFilterEnabled", JSON.parse( { "sourceName" => @sourcename, "filterName" => @name, "filterEnabled" => true }.to_json ) ) ) return d["requestStatus"]["result"].as_bool end def disable! d = @obs.send_sync( OBS.req( "SetSourceFilterEnabled", JSON.parse( { "sourceName" => @sourcename, "filterName" => @name, "filterEnabled" => false }.to_json ) ) ) 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( @obs : OBS::WebSocket, @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