obs-websocket 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 60bbb3f01fc0e188ac097d94be7e06b44c75ff002fbe472a3a1ab250c546c466
4
+ data.tar.gz: b09a363ab96faa4cfd516d5f76f6f5d101e4b562be3bb6d53deddc5b4c2db20c
5
+ SHA512:
6
+ metadata.gz: 7bc1716e17df1452b8a666c4362b64189d64825f1a114348c9625cb8420601f28022b32ed1379d539c694575e84905ee8e127d017be6c6255975a2d4ef06ec77
7
+ data.tar.gz: 6643bb0a4b17ca827a5e31ff2e30560adaa05babebbdb10932ab9dba44465a2c6f5c443cede4d8ab1a10c6e2472be95a41f656d7443f421fd098a55c36b4e8ca
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # ChangeLog
2
+
3
+ ## UNRELEASED
4
+
5
+ ## v0.1.0 (2021-0524)
6
+ - Initial release
data/HACKING.md ADDED
@@ -0,0 +1,13 @@
1
+ # Hacking ruby-obs-websocket
2
+
3
+ All contributions including patches and bug reports are welcome at GitHub <https://github.com/hanazuki/ruby-obs-websocket>.
4
+
5
+ ## Packaging and release
6
+
7
+ ### Update obs/websocket/protocol.rb
8
+
9
+ When a new version of obs-websocket is released or the codegen script is modified, take the following steps to generate the updated protocol definition.
10
+
11
+ 1. Change `API_VERSION` in `Rakefile`.
12
+ 2. Run `rake codegen`
13
+ 3. Commit (and release).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Kasumi Hanazuki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # OBS::WebSocket
2
+
3
+ OBS::WebSocket is a [Ruby](https://www.ruby-lang.org) library to remotely controls [OBS Studio](https://obsproject.com/) using [obs-websocket](https://github.com/Palakis/obs-websocket) API.
4
+
5
+ ## Usage
6
+
7
+ See examples/ for a runnable code example.
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'obs-websocket', path: '../..'
4
+ gem 'websocket-driver'
@@ -0,0 +1,23 @@
1
+ PATH
2
+ remote: ../..
3
+ specs:
4
+ obs-websocket (0.1.0)
5
+ concurrent-ruby (~> 1.1)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ concurrent-ruby (1.1.8)
11
+ websocket-driver (0.7.3)
12
+ websocket-extensions (>= 0.1.0)
13
+ websocket-extensions (0.1.5)
14
+
15
+ PLATFORMS
16
+ x86_64-linux
17
+
18
+ DEPENDENCIES
19
+ obs-websocket!
20
+ websocket-driver
21
+
22
+ BUNDLED WITH
23
+ 2.2.15
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env ruby
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ ENV['BUNDLE_GEMFILE'] = File.join(__dir__, 'Gemfile')
5
+ require 'bundler/setup'
6
+
7
+ require 'obs/websocket'
8
+ require 'socket'
9
+ require 'uri'
10
+ require 'websocket/driver'
11
+
12
+ class Main
13
+ def run(uri, password)
14
+ connect(URI.parse(uri))
15
+
16
+ # To initialize OBS::WebSocket, pass a WebSocket connection.
17
+ obs = OBS::WebSocket::Client.new(@driver)
18
+
19
+ obs.on_open do
20
+ obs.authenticate!(password).value!
21
+
22
+ # Request methods return Future values that can be waited for by `value!`.
23
+ version = obs.get_version.value!
24
+ puts "OBS version: #{version.obs_studio_version}; OBS-websocket version: #{version.obs_websocket_version}"
25
+
26
+ # Pass request parameters using keyword arguments. Composite types are mapped to Hash objects.
27
+ obs.broadcast_custom_message(
28
+ realm: 'helloworld',
29
+ data: {greeting: 'Hello, World!'}
30
+ ).wait!
31
+ rescue => e
32
+ $stderr.puts e.message
33
+ obs.close
34
+ end
35
+
36
+ # Listen for events using `on_*` methods. The event payload is yielded to the block.
37
+ obs.on_broadcast_custom_message do |ev|
38
+ puts ev.data['greeting']
39
+ obs.close
40
+ end
41
+
42
+ obs.on_close(executor: :immediate) do |code, reason|
43
+ puts "Bye! (code: #{code})"
44
+ end
45
+
46
+ start_driver
47
+ end
48
+
49
+ private
50
+
51
+ def connect(uri)
52
+ fail ArgumentError, 'Only supports ws:// URI' unless uri.scheme == 'ws'
53
+
54
+ @socket = TCPSocket.new(uri.host, uri.port || 80)
55
+ @driver = WebSocket::Driver.client(SocketWrapper.new(uri.to_s, @socket))
56
+ end
57
+
58
+ def start_driver
59
+ @driver.start
60
+
61
+ loop do
62
+ @driver.parse(@socket.readpartial(4096))
63
+ rescue EOFError
64
+ break
65
+ end
66
+ end
67
+
68
+ class SocketWrapper
69
+ def initialize(url, socket)
70
+ @url = url
71
+ @socket = socket
72
+ end
73
+
74
+ attr_reader :url
75
+
76
+ def write(s)
77
+ @socket.write(s)
78
+ end
79
+ end
80
+ end
81
+
82
+ if $0 == __FILE__
83
+ if ARGV.size != 2
84
+ $stderr.puts "Usage: #{File.basename __FILE__} ws://HOST:PORT PASSWORD"
85
+ exit 1
86
+ end
87
+
88
+ Main.new.run(*ARGV)
89
+ end
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ require_relative 'obs/websocket'
@@ -0,0 +1,443 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ require 'concurrent'
4
+ require 'digest/sha2'
5
+ require 'json'
6
+ require 'securerandom'
7
+
8
+ require_relative 'websocket/version'
9
+
10
+ module OBS
11
+ module WebSocket
12
+ class Error < StandardError; end
13
+
14
+ class RequestError < Error; end
15
+
16
+ # Shortcut for {OBS::WebSocket::Client#initialize}
17
+ #
18
+ # @see OBS::WebSocket::Client#initialize
19
+ def self.new(*args, **kwargs)
20
+ Client.new(*args, **kwargs)
21
+ end
22
+
23
+ # OBS-websocket client.
24
+ class Client
25
+ # Creates an OBS-websocket client.
26
+ #
27
+ # <tt>websocket</tt> object must respond to the following methods:
28
+ # - <tt>text(str)</tt>: send a text frame
29
+ # - <tt>on(event, &block)</tt>: add an event handler
30
+ # - <tt>close()</tt>: close the connection
31
+ #
32
+ # @param websocket [Object] the websocket object
33
+ # @param executor the executor on which the callbacks are invoked
34
+ def initialize(websocket, executor: :io)
35
+ @websocket = websocket
36
+ @response_dispatcher = ResponseDispatcher.new
37
+ @event_dispatcher = EventDispatcher.new
38
+ @executor = executor
39
+ @on_open = Concurrent::Promises.resolvable_event
40
+ @on_close = Concurrent::Promises.resolvable_future
41
+
42
+ websocket.on(:open) do
43
+ @on_open.resolve
44
+ end
45
+
46
+ websocket.on(:close) do |event|
47
+ @on_close.resolve(true, [event.code, event.reason])
48
+ end
49
+
50
+ websocket.on(:message) do |event|
51
+ handle_message(JSON.parse(event.data))
52
+ end
53
+
54
+ websocket.on(:error) do |event|
55
+ $stderr.puts "Error: #{event.code} #{event.reason}"
56
+ end
57
+ end
58
+
59
+ # Authenticates the client to the server using the password.
60
+ #
61
+ # @param password [String] the password
62
+ # @return [Future<:ok>]
63
+ def authenticate!(password)
64
+ get_auth_required.then do |h|
65
+ if h.auth_required
66
+ token = auth_token(
67
+ password: password,
68
+ salt: h.salt,
69
+ challenge: h.challenge,
70
+ )
71
+ authenticate(auth: token).then { :ok }
72
+ else
73
+ :ok
74
+ end
75
+ end.flat
76
+ end
77
+
78
+ # Adds an event handler for connection establishment.
79
+ #
80
+ # @param executor the executor on which the callback is invoked
81
+ # @yield Called when obs-websocket connection is established.
82
+ # @return [Event]
83
+ def on_open(executor: @executor, &listener)
84
+ if listener
85
+ @on_open.chain_on(executor, &listener)
86
+ else
87
+ @on_open.with_default_executor(executor)
88
+ end
89
+ end
90
+
91
+ # Adds an event handler for connection termination.
92
+ #
93
+ # @param executor the executor on which the callback is invoked
94
+ # @yield Called when obs-websocket connection is terminated.
95
+ # @return [Future]
96
+ def on_close(executor: @executor, &listener)
97
+ if listener
98
+ @on_close.then_on(executor, &listener)
99
+ else
100
+ @on_close.with_default_executor(executor)
101
+ end
102
+ end
103
+
104
+ # Adds an event handler for obs-websocket event.
105
+ #
106
+ # @param type [String] type of obs-websocket event to listen for
107
+ # @param executor the executor on which the callback is invoked
108
+ # @yield Called when the specified type of obs-websocket event is received.
109
+ # @yieldparam event [Event] the event object
110
+ # @return [void]
111
+ def on(type, executor: @executor, &listener)
112
+ @event_dispatcher.register(executor, type, listener)
113
+ nil
114
+ end
115
+
116
+ # Close the connection.
117
+ #
118
+ # @return [void]
119
+ def close
120
+ @websocket.close
121
+ end
122
+
123
+ private
124
+
125
+ def auth_token(password:, salt:, challenge:)
126
+ Digest::SHA256.base64digest(Digest::SHA256.base64digest(password + salt) + challenge)
127
+ end
128
+
129
+ def send_request(request)
130
+ message_id, future = @response_dispatcher.register(@executor, request)
131
+ @websocket.text(JSON.dump({**request.to_h, 'message-id' => message_id}))
132
+ future
133
+ end
134
+
135
+ def handle_message(payload)
136
+ if message_id = payload['message-id']
137
+ @response_dispatcher.dispatch(message_id, payload)
138
+ elsif update_type = payload['update-type']
139
+ @event_dispatcher.dispatch(update_type, payload)
140
+ else
141
+ fail 'Unknown message'
142
+ end
143
+ end
144
+ end
145
+
146
+ class EventDispatcher
147
+ def initialize
148
+ @listeners = Hash.new {|h, k| h[k] = []}
149
+ end
150
+
151
+ def register(executor, type, listener)
152
+ @listeners[type].push([executor, listener])
153
+ end
154
+
155
+ def dispatch(update_type, payload)
156
+ event = Protocol::Event.create(update_type, payload).freeze
157
+ @listeners[update_type].each do |(executor, listener)|
158
+ Concurrent::Promises.future_on(executor, event, &listener).run
159
+ end
160
+ end
161
+ end
162
+
163
+ class ResponseDispatcher
164
+ def initialize
165
+ @ongoing_requests = {}
166
+ end
167
+
168
+ def register(executor, request)
169
+ message_id = new_message_id
170
+ future = Concurrent::Promises.resolvable_future_on(executor)
171
+
172
+ @ongoing_requests[message_id] = {
173
+ request: request,
174
+ future: future,
175
+ }
176
+
177
+ [message_id, future.with_hidden_resolvable]
178
+ end
179
+
180
+ def dispatch(message_id, payload)
181
+ if h = @ongoing_requests.delete(message_id)
182
+ request = h[:request]
183
+ future = h[:future]
184
+
185
+ case payload['status']
186
+ when 'ok'
187
+ response_class = request.class.const_get(:Response)
188
+ future.fulfill(response_class.new(payload))
189
+ when 'error'
190
+ future.reject(RequestError.new(payload['error']))
191
+ else
192
+ fail 'Unknown status'
193
+ end
194
+ end
195
+ end
196
+
197
+ private
198
+
199
+ def new_message_id
200
+ SecureRandom.uuid
201
+ end
202
+ end
203
+
204
+ module Protocol
205
+ class Type
206
+ def initialize(**kwargs, &block)
207
+ kwargs.each do |k, v|
208
+ instance_variable_set(:"@#{k}", v)
209
+ end
210
+ instance_eval(&block)
211
+ end
212
+
213
+ attr_reader :name
214
+ end
215
+
216
+ module Types
217
+ Boolean = Type.new(name: 'Boolean') do
218
+ def as_ruby(b)
219
+ !!b
220
+ end
221
+
222
+ def as_json(b)
223
+ !!b
224
+ end
225
+ end
226
+
227
+ Integer = Type.new(name: 'Int') do
228
+ def as_ruby(i)
229
+ i.to_i
230
+ end
231
+
232
+ def as_json(i)
233
+ i.to_i
234
+ end
235
+ end
236
+
237
+ Float = Type.new(name: 'Double') do
238
+ def as_ruby(f)
239
+ f.to_f
240
+ end
241
+
242
+ def as_json(f)
243
+ f.to_f
244
+ end
245
+ end
246
+ Numeric = Float
247
+
248
+ String = Type.new(name: 'String') do
249
+ def as_ruby(s)
250
+ s.to_s
251
+ end
252
+
253
+ def as_json(s)
254
+ s.to_s
255
+ end
256
+ end
257
+
258
+ Optional = Class.new do
259
+ def [](element_type)
260
+ Type.new(name: "Optional[#{element_type.name}]", element_type: element_type) do
261
+ def as_ruby(v)
262
+ return unless v
263
+ @element_type.as_ruby(v)
264
+ end
265
+
266
+ def as_json(v)
267
+ return unless v
268
+ @element_type.as_json(v)
269
+ end
270
+ end
271
+ end
272
+ end.new
273
+
274
+ Array = Class.new do
275
+ def [](element_type)
276
+ Type.new(name: "Array[#{element_type.name}]", element_type: element_type) do
277
+ def as_ruby(a)
278
+ a.to_a.map {|v| @element_type.as_ruby(v) }
279
+ end
280
+
281
+ def as_json(a)
282
+ a.to_a.map {|v| @element_type.as_json(v) }
283
+ end
284
+ end
285
+ end
286
+ end.new
287
+
288
+ Object = Type.new(name: 'Object') do
289
+ def as_ruby(o)
290
+ o.to_h
291
+ end
292
+
293
+ def as_json(o)
294
+ o.to_h
295
+ end
296
+
297
+ def [](fields)
298
+ Type.new(name: "Object[#{fields.keys.map(&:to_s).join(', ')}]", fields: fields) do
299
+ def as_ruby(o)
300
+ @fields.to_h do |name, field|
301
+ json_name = field[:json_name]
302
+ type = field[:type]
303
+ [name, type.as_ruby(o[json_name])]
304
+ end
305
+ end
306
+
307
+ def as_json(a)
308
+ @fields.to_h do |name, field|
309
+ json_name = field[:json_name]
310
+ type = field[:type]
311
+ [json_name, type.as_ruby(o[name])]
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
317
+
318
+ StringOrObject = Type.new(name: 'Object') do
319
+ def as_ruby(o)
320
+ String === o ? o : o.to_h
321
+ end
322
+
323
+ def as_json(o)
324
+ o.respond_to?(:to_str) ? o.to_str : o.to_h
325
+ end
326
+
327
+ def [](fields)
328
+ Type.new(name: "Object[#{fields.keys.map(&:to_s).join(', ')}]", fields: fields) do
329
+ def as_ruby(o)
330
+ return o if String === o
331
+
332
+ @fields.to_h do |name, field|
333
+ json_name = field[:json_name]
334
+ type = field[:type]
335
+ [name, type.as_ruby(o[json_name])]
336
+ end
337
+ end
338
+
339
+ def as_json(a)
340
+ return o.to_str if o.respond_to?(:to_str)
341
+
342
+ @fields.to_h do |name, field|
343
+ json_name = field[:json_name]
344
+ type = field[:type]
345
+ [json_name, type.as_ruby(o[name])]
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+
353
+ class ServerMessage
354
+ end
355
+
356
+ class ClientMessage
357
+ end
358
+
359
+ class Event < ServerMessage
360
+ CLASSES_BY_JSON_NAME = {}
361
+ private_constant :CLASSES_BY_JSON_NAME
362
+
363
+ def self.json_name(json_name)
364
+ CLASSES_BY_JSON_NAME[json_name] = self
365
+ end
366
+
367
+ def self.create(type, payload)
368
+ cls = CLASSES_BY_JSON_NAME[type] || UnknownEvent
369
+ cls.new(payload)
370
+ end
371
+
372
+ def initialize(json)
373
+ @json = json
374
+ end
375
+
376
+ private def get_field(name, type)
377
+ type.as_ruby(@json[name])
378
+ end
379
+
380
+ def to_h
381
+ @json
382
+ end
383
+
384
+ def update_type
385
+ get_field('update-type', Types::String)
386
+ end
387
+ def stream_timecode;
388
+ get_field('stream-timecode', Types::Optional[Types::String])
389
+ end
390
+ def rec_timecode
391
+ get_field('rec-timecode', Types::Optional[Types::String])
392
+ end
393
+ end
394
+
395
+ class UnknownEvent < Event
396
+ end
397
+
398
+ class Request < ClientMessage
399
+ class << self
400
+ def json_name(json_name)
401
+ @json_name = json_name
402
+ end
403
+
404
+ def params(params = {})
405
+ (@params ||= {}).update(params)
406
+ end
407
+ end
408
+
409
+ def initialize(args)
410
+ @json = self.class.instance_variable_get(:@params).to_h do |name, v|
411
+ type = v[:type]
412
+ json_name = v[:json_name]
413
+ [json_name, type.as_json(args[name])]
414
+ end
415
+ @json['request-type'] = self.class.instance_variable_get(:@json_name)
416
+ end
417
+
418
+ def to_h
419
+ @json
420
+ end
421
+ end
422
+
423
+ class Response < ServerMessage
424
+ def initialize(json)
425
+ @json = json
426
+ end
427
+
428
+ def to_h
429
+ @json
430
+ end
431
+
432
+ private def get_field(name, type)
433
+ type.as_ruby(@json[name])
434
+ end
435
+ end
436
+ end
437
+
438
+ require_relative 'websocket/protocol'
439
+
440
+ Client.include Protocol::Event::Mixin
441
+ Client.include Protocol::Request::Mixin
442
+ end
443
+ end