ffi-shout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3afeb4f1d4e446f230a11be7e907e5ca99cc60bfe591cb10902b951ba7fc9a41
4
+ data.tar.gz: efc7c5332efca1d45ffba08ca66357796bf3a800e2584189214fc974807bd8a7
5
+ SHA512:
6
+ metadata.gz: 3b3de3046e1fb2875a36481ac428e2142b761291597f0a959e34480845cfef5ee8c2c6a73df322e94896eead1b68c7acbc0bc77366edc324bbbe710941ed9657
7
+ data.tar.gz: ff0ab48f10510901e8a4321eeefd73f4609cb2423262897c942c0ca80f0d03880f078d4f8e36c4ac9bfebd694fa10682186f765fb412f04b6d7a0031aaf3bf46
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2026 Roel van Dijk
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,278 @@
1
+ # ffi-shout
2
+
3
+ Ruby FFI bindings for [libshout](https://icecast.org/), the streaming library for Icecast.
4
+
5
+ See [CHANGELOG.md](CHANGELOG.md) for what changed.
6
+
7
+ ## Installation
8
+
9
+ ### Requirements
10
+
11
+ - Ruby 3.2 or newer
12
+ - libshout 2.4.6 or newer, including its development files:
13
+ - Ubuntu / Debian: `sudo apt-get install libshout3-dev`
14
+ - Arch Linux: `sudo pacman -S libshout`
15
+ - macOS (Homebrew): `brew install libshout`
16
+
17
+ Older distributions may ship an older libshout; check with `pkg-config --modversion shout`. With libshout older than 2.4.6, `require "shout"` raises a `LoadError` that names the version it found.
18
+
19
+ ### Gem Installation
20
+
21
+ Add to your Gemfile:
22
+
23
+ ```ruby
24
+ gem "ffi-shout"
25
+ ```
26
+
27
+ And run:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ Then load it with `require "shout"` (or `require "ffi-shout"`; Bundler's auto-require uses that one). The library lives in the `Shout` module.
34
+
35
+ The older, unmaintained [ruby-shout](https://rubygems.org/gems/ruby-shout) gem (a C extension) also installs `shout.rb` and defines a top-level `Shout` class, so the two gems can't be used together.
36
+
37
+ ## Usage
38
+
39
+ ### Connecting and Streaming to Icecast
40
+
41
+ #### 1. Automatic Lifecycle (Block Form)
42
+
43
+ You can pass a block to `connect`, which automatically handles disconnecting when the block completes (even if an exception is raised). `connect` returns the block's value.
44
+
45
+ ```ruby
46
+ require "shout"
47
+
48
+ connection = Shout::Connection.new do |conn|
49
+ conn.configuration.host = "127.0.0.1"
50
+ conn.configuration.port = 8000
51
+ conn.configuration.mount = "/stream.mp3"
52
+ conn.configuration.user = "source"
53
+ conn.configuration.password = "hackme"
54
+ conn.configuration.protocol = :http
55
+ conn.configuration.set_content_format(:mp3)
56
+
57
+ # Audio information
58
+ conn.configuration.audio_info.bitrate = 128
59
+ conn.configuration.audio_info.samplerate = 44100
60
+ conn.configuration.audio_info.channels = 2
61
+
62
+ # Stream metadata
63
+ conn.configuration.meta.name = "My Radio Station"
64
+ conn.configuration.meta.genre = "Rock"
65
+ conn.configuration.meta.description = "Awesome Radio Stream"
66
+ conn.configuration.meta.url = "https://example.com"
67
+ end
68
+
69
+ # Connect to Icecast with automatic disconnect
70
+ connection.connect do |conn|
71
+ # Update song metadata
72
+ conn.update_metadata(song: "Queen - Bohemian Rhapsody")
73
+
74
+ # Stream audio chunks
75
+ File.open("audio.mp3", "rb") do |file|
76
+ while (chunk = file.read(4096))
77
+ conn.send_data(chunk)
78
+
79
+ # Wait for the server timing synchronization
80
+ conn.sync
81
+ end
82
+ end
83
+ end
84
+ # Automatically disconnected once the block exits
85
+ ```
86
+
87
+ Configuration attributes can also be passed as keyword arguments, and `Connection.open` creates and connects in one step:
88
+
89
+ ```ruby
90
+ Shout::Connection.open(host: "127.0.0.1", port: 8000, mount: "/stream.mp3",
91
+ user: "source", password: "hackme") do |conn|
92
+ conn.update_metadata(song: "Daft Punk - One More Time")
93
+ # ...
94
+ end
95
+ ```
96
+
97
+ An unknown attribute raises `ArgumentError` (for example `unknown attribute(s): hots`). Settings without a keyword form, such as `set_content_format` or `audio_info`, go in a block as above.
98
+
99
+ #### 2. Manual Lifecycle (Holding the Connection in a Field)
100
+
101
+ If you need a long-running stream managed across multiple methods or worker events, call `connect` without a block. This returns the connection instance, allowing you to store it in an instance variable (field) and explicitly call `disconnect` (or `close`) later:
102
+
103
+ ```ruby
104
+ require "shout"
105
+
106
+ class RadioBroadcaster
107
+ attr_reader :connection
108
+
109
+ def initialize
110
+ @connection = Shout::Connection.new do |conn|
111
+ conn.configuration.host = "127.0.0.1"
112
+ conn.configuration.port = 8000
113
+ conn.configuration.mount = "/stream.mp3"
114
+ conn.configuration.user = "source"
115
+ conn.configuration.password = "hackme"
116
+ conn.configuration.protocol = :http
117
+ conn.configuration.set_content_format(:mp3)
118
+ end
119
+ end
120
+
121
+ def start
122
+ # Connects and keeps the socket open
123
+ @connection.connect
124
+ end
125
+
126
+ def broadcast(chunk)
127
+ @connection.send_data(chunk).sync
128
+ end
129
+
130
+ def change_track(title)
131
+ @connection.update_metadata(song: title)
132
+ end
133
+
134
+ def stop
135
+ # Manually close the connection when done
136
+ @connection.disconnect if @connection.connected?
137
+ end
138
+ end
139
+
140
+ # Example usage:
141
+ broadcaster = RadioBroadcaster.new
142
+ broadcaster.start
143
+
144
+ broadcaster.change_track("Led Zeppelin - Immigrant Song")
145
+ broadcaster.broadcast(audio_data) # audio_data: a chunk of MP3 data, e.g. from File#read
146
+
147
+ # ... later in your application lifecycle:
148
+ broadcaster.stop
149
+ ```
150
+
151
+ `send_data` (and its aliases `write` and `<<`), `sync` and `disconnect` return the connection, so calls can be chained: `connection << chunk1 << chunk2`.
152
+
153
+ #### Blocking and threads
154
+
155
+ libshout calls that wait on the network (`connect`, `send_data`, `sync`, `disconnect`) block the calling thread but let other Ruby threads keep running.
156
+
157
+ Non-blocking mode (`nonblocking = :none`) is not supported yet: in that mode libshout reports a busy connection, which this gem raises as `Shout::BusyError` instead of retrying.
158
+
159
+ ### Sending Metadata
160
+
161
+ You can update stream metadata dynamically at any time while connected:
162
+
163
+ ```ruby
164
+ # Using a String (defaults to "song" metadata)
165
+ connection.metadata = "Artist - Song Title"
166
+
167
+ # Using a Hash
168
+ connection.update_metadata(song: "Artist - Song Title")
169
+
170
+ # Using a Shout::Metadata object
171
+ metadata = Shout::Metadata.new(song: "Artist - Song Title")
172
+ connection.send_metadata(metadata)
173
+ ```
174
+
175
+ `Shout::Metadata` behaves like a small hash of strings: `metadata["song"]` reads a value, `metadata.add("artist", "Queen")` (or `metadata["artist"] = "Queen"`) adds one, it is `Enumerable` (`each` yields key and value), and two instances with the same keys and values are equal.
176
+
177
+ With the HTTP protocol (the default), metadata updates don't go through libshout's `shout_set_metadata_utf8`. With Icecast 2.5, libshout 2.4.6 never returns from that call ([icecast-libshout#2341](https://gitlab.xiph.org/xiph/icecast-libshout/-/issues/2341)). Instead, this gem sends the request to Icecast's `/admin/metadata` endpoint itself, with the source credentials included in the first request. Other protocols still use libshout.
178
+
179
+ - Requests time out after 5 seconds. Change this with `connection.metadata_timeout = 2`.
180
+ - Failures raise `Shout::LoginError` (credentials rejected), `Shout::MetadataError` (the server refused the update) or `Shout::ConnectionError` (network error or timeout).
181
+ - For TLS, set `tls` to `:rfc2818` (HTTPS) or `:disabled`/`:auto` (plain HTTP). `:auto_no_plain` and `:rfc2817` raise `Shout::UnsupportedError` for metadata updates.
182
+
183
+ > **Security note:** with `tls = :auto` (libshout's default), libshout may upgrade the audio connection to TLS, but metadata updates are sent over plain HTTP, so the source password is sent unencrypted. If you rely on TLS, set `tls = :rfc2818`.
184
+
185
+ ### Configuration Options
186
+
187
+ The configuration object exposes all properties supported by `shout.h`:
188
+
189
+ ```ruby
190
+ connection.configure do |config|
191
+ # Server parameters
192
+ config.host = "localhost"
193
+ config.port = 8000
194
+ config.mount = "/mountpoint"
195
+ config.user = "source"
196
+ config.password = "secret"
197
+ config.agent = "CustomAgent/1.0"
198
+
199
+ # Protocol & Format
200
+ config.protocol = :http # :http, :xaudiocast, :icy, :roaraudio
201
+ config.set_content_format(:mp3) # :mp3, :ogg, :webm, :matroska, :text
202
+
203
+ # TLS options
204
+ config.tls = :auto # :disabled, :auto, :auto_no_plain, :rfc2818, :rfc2817
205
+ config.ca_directory = "/etc/ssl/certs"
206
+ config.ca_file = "/etc/ssl/cert.pem"
207
+
208
+ # Streaming options
209
+ config.public = false
210
+ config.nonblocking = :full # :full (blocking); :none is not supported yet, see above
211
+ config.content_language = "en-US"
212
+
213
+ # Audio info
214
+ config.audio_info.bitrate = 320
215
+ config.audio_info.samplerate = 44100
216
+ config.audio_info.channels = 2
217
+ config.audio_info.quality = 5
218
+
219
+ # Station meta
220
+ config.meta.name = "My Station"
221
+ config.meta.url = "http://myradio.fm"
222
+ config.meta.genre = "Electronic"
223
+ config.meta.description = "24/7 Electronic Music"
224
+ end
225
+ ```
226
+
227
+ Settings that take a symbol also read back as one: `config.protocol` returns `:http`, `config.tls` returns `:auto`, and `config.content_format.format` returns `:mp3`. Setting an unknown symbol raises `ArgumentError`. The on/off flags also have a `?` reader: `config.public?` and `config.nonblocking?`.
228
+
229
+ `config.format=`, `config.name=`, `config.url=`, `config.genre=`, `config.description=` and `config.dumpfile=` call libshout functions that libshout marks obsolete. They still work, but print a deprecation warning when Ruby's deprecation warnings are on (`ruby -W:deprecated`). Use `set_content_format` and `config.meta.*` instead.
230
+
231
+ ### Errors
232
+
233
+ Every libshout error is raised as a subclass of `Shout::ShoutError`, so `rescue Shout::ShoutError` catches them all. The message is libshout's own error text where available.
234
+
235
+ | Class | libshout error |
236
+ |---|---|
237
+ | `Shout::InsaneError` | `SHOUTERR_INSANE`: invalid parameters or state |
238
+ | `Shout::ConnectionError` | `SHOUTERR_NOCONNECT`: couldn't connect (also network errors in HTTP metadata updates) |
239
+ | `Shout::LoginError` | `SHOUTERR_NOLOGIN`: the server rejected the login |
240
+ | `Shout::SocketIOError` | `SHOUTERR_SOCKET`: error while talking to the server |
241
+ | `Shout::MallocError` | `SHOUTERR_MALLOC`: out of memory |
242
+ | `Shout::MetadataError` | `SHOUTERR_METADATA`: metadata update failed |
243
+ | `Shout::ConnectedError` | `SHOUTERR_CONNECTED`: can't change this while connected |
244
+ | `Shout::UnconnectedError` | `SHOUTERR_UNCONNECTED`: not connected |
245
+ | `Shout::UnsupportedError` | `SHOUTERR_UNSUPPORTED`: not supported by this server, protocol or format |
246
+ | `Shout::BusyError` | `SHOUTERR_BUSY`: busy, try again later (non-blocking mode) |
247
+ | `Shout::NoTLSError` | `SHOUTERR_NOTLS`: TLS requested but not available |
248
+ | `Shout::TLSBadCertError` | `SHOUTERR_TLSBADCERT`: the server's TLS certificate failed verification |
249
+ | `Shout::RetryError` | `SHOUTERR_RETRY`: try again |
250
+
251
+ ### libshout Version
252
+
253
+ `Shout.version` describes the loaded libshout library:
254
+
255
+ ```ruby
256
+ Shout.version.to_s # => "2.4.6"
257
+ Shout.version >= "2.4.6" # => true
258
+ ```
259
+
260
+ ### Low-level libshout Functions
261
+
262
+ Every function in `shout.h` is attached to `Shout::Native`, an `FFI::Library`, for anything `Shout::Connection` doesn't cover. `Native.shout_new` returns a handle that frees itself when it is garbage collected, and settings with libshout constants take and return symbols:
263
+
264
+ ```ruby
265
+ shout = Shout::Native.shout_new
266
+ Shout::Native.shout_set_protocol(shout, :icy)
267
+ Shout::Native.shout_get_protocol(shout) # => :icy
268
+ ```
269
+
270
+ These functions return libshout's error codes instead of raising, and their API follows libshout rather than this gem.
271
+
272
+ ## Development
273
+
274
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to set up the project, run the specs (including the integration specs against a local Icecast server) and which Ruby versions changes need to support.
275
+
276
+ ## License
277
+
278
+ MIT License. See [LICENSE.txt](LICENSE.txt).
data/lib/ffi-shout.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `require "ffi-shout"` and
4
+ # `Bundler.require` load the library. Same as `require "shout"`.
5
+ require "shout"
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shout
4
+ # Base class for configuration objects that read and write settings on a
5
+ # libshout handle.
6
+ class ConfigurationBase
7
+ extend Delegation
8
+
9
+ attr_reader :shout
10
+
11
+ def initialize(shout)
12
+ @shout = shout
13
+ end
14
+ end
15
+
16
+ # Connection settings (host, mount, credentials, protocol, ...) stored on a
17
+ # libshout handle. Each attribute reads and writes the matching
18
+ # shout_get_*/shout_set_* function.
19
+ class Configuration < ConfigurationBase
20
+ delegate_alias_to_shout username: :user,
21
+ pass: :password,
22
+ mountpoint: :mount,
23
+ user_agent: :agent
24
+
25
+ delegate_to_shout :host, :port, :agent, :tls, :ca_directory, :ca_file,
26
+ :allowed_ciphers, :user, :password, :client_certificate,
27
+ :mount, :content_language, :protocol, :nonblocking
28
+
29
+ delegate_to_shout :public, predicate: true
30
+
31
+ # Settings that libshout marks obsolete. Their writers print a deprecation
32
+ # warning (shown with `ruby -W:deprecated`).
33
+ OBSOLETE_SETTINGS = {
34
+ name: "meta.name=",
35
+ url: "meta.url=",
36
+ genre: "meta.genre=",
37
+ description: "meta.description=",
38
+ format: "set_content_format",
39
+ dumpfile: nil # only useful with the deprecated xaudiocast protocol
40
+ }.freeze
41
+
42
+ delegate_to_shout(*OBSOLETE_SETTINGS.keys)
43
+
44
+ prepend(Module.new do
45
+ OBSOLETE_SETTINGS.each do |name, replacement|
46
+ define_method(:"#{name}=") do |value|
47
+ advice = replacement ? "; use #{replacement} instead" : ""
48
+ warn "Shout::Configuration##{name}= is obsolete in libshout#{advice}", category: :deprecated, uplevel: 1
49
+ super(value)
50
+ end
51
+ end
52
+ end)
53
+
54
+ def nonblocking?
55
+ nonblocking == :none
56
+ end
57
+
58
+ def audio_info
59
+ @audio_info ||= AudioInfo.new(shout)
60
+ end
61
+
62
+ def meta
63
+ @meta ||= Meta.new(shout)
64
+ end
65
+
66
+ def content_format
67
+ @content_format ||= ContentFormat.new(shout)
68
+ end
69
+
70
+ def set_content_format(format, usage = SHOUT_USAGE_AUDIO, codecs = nil)
71
+ content_format.set(format, usage, codecs)
72
+ end
73
+
74
+ # Base for settings that libshout stores as string key/value pairs. Each
75
+ # subclass names its getter and setter function and its known keys; every
76
+ # known key gets a reader and a writer, and any key works with [] / []=.
77
+ class KeyValueSettings < ConfigurationBase
78
+ def self.settings(getter:, setter:, **keys)
79
+ define_method(:[]) do |key|
80
+ Native.public_send(getter, shout, key.to_s)
81
+ end
82
+
83
+ define_method(:[]=) do |key, value|
84
+ Error.check_error(Native.public_send(setter, shout, key.to_s, value&.to_s), shout)
85
+ end
86
+
87
+ keys.each { |name, key| define_key_accessors(name, key) }
88
+ end
89
+
90
+ def self.define_key_accessors(name, key)
91
+ define_method(name) { self[key] }
92
+ define_method(:"#{name}=") { |value| self[key] = value }
93
+ end
94
+ private_class_method :define_key_accessors
95
+ end
96
+
97
+ # Audio parameters advertised to the server (shout_set_audio_info).
98
+ class AudioInfo < KeyValueSettings
99
+ settings getter: :shout_get_audio_info, setter: :shout_set_audio_info,
100
+ bitrate: SHOUT_AI_BITRATE,
101
+ samplerate: SHOUT_AI_SAMPLERATE,
102
+ channels: SHOUT_AI_CHANNELS,
103
+ quality: SHOUT_AI_QUALITY
104
+ end
105
+
106
+ # Stream description such as name, genre and URL (shout_set_meta).
107
+ class Meta < KeyValueSettings
108
+ settings getter: :shout_get_meta, setter: :shout_set_meta,
109
+ name: SHOUT_META_NAME,
110
+ url: SHOUT_META_URL,
111
+ genre: SHOUT_META_GENRE,
112
+ description: SHOUT_META_DESCRIPTION,
113
+ irc: SHOUT_META_IRC,
114
+ aim: SHOUT_META_AIM,
115
+ icq: SHOUT_META_ICQ
116
+ end
117
+
118
+ # Content format, usage and codecs of the stream (shout_set_content_format).
119
+ class ContentFormat < ConfigurationBase
120
+ def set(format, usage = SHOUT_USAGE_AUDIO, codecs = nil)
121
+ format = Delegation::TypeConvertor.convert(format)
122
+ Error.check_error(Native.shout_set_content_format(shout, format, usage, codecs), shout)
123
+ self
124
+ end
125
+
126
+ # The format as a symbol such as :mp3 (or the raw value if unknown).
127
+ def format
128
+ value = info[0]
129
+ Native.enum_type(:shout_format)[value] || value
130
+ end
131
+
132
+ def usage
133
+ info[1]
134
+ end
135
+
136
+ def codecs
137
+ info[2]
138
+ end
139
+
140
+ private
141
+
142
+ def info
143
+ format_ptr = FFI::MemoryPointer.new(:uint)
144
+ usage_ptr = FFI::MemoryPointer.new(:uint)
145
+ codecs_ptr = FFI::MemoryPointer.new(:pointer)
146
+
147
+ ret = Native.shout_get_content_format(shout, format_ptr, usage_ptr, codecs_ptr)
148
+ Error.check_error(ret, shout)
149
+
150
+ c_str_ptr = codecs_ptr.read_pointer
151
+ codecs_str = c_str_ptr.null? ? nil : c_str_ptr.read_string
152
+
153
+ [format_ptr.read_uint, usage_ptr.read_uint, codecs_str]
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shout
4
+ # A source connection to an Icecast server: configure it, connect, send
5
+ # audio data and update the stream's metadata.
6
+ #
7
+ # Commands (disconnect, send_data, sync, configure) return the connection,
8
+ # so they can be chained: `connection << chunk1 << chunk2`.
9
+ class Connection
10
+ attr_reader :shout
11
+
12
+ # Seconds to wait for the server when updating metadata over HTTP.
13
+ attr_writer :metadata_timeout
14
+
15
+ def metadata_timeout
16
+ @metadata_timeout || HttpMetadata::DEFAULT_TIMEOUT
17
+ end
18
+
19
+ # Creates a connection and connects. With a block, yields the connection,
20
+ # disconnects afterwards and returns the block's value.
21
+ def self.open(**, &)
22
+ new(**).connect(&)
23
+ end
24
+
25
+ # Creates a connection, optionally setting configuration attributes, e.g.
26
+ # Connection.new(host: "localhost", port: 8000, mount: "/live.mp3").
27
+ # Raises ArgumentError for attributes the configuration doesn't have.
28
+ def initialize(**attributes)
29
+ @shout = Native.shout_new
30
+ assign_attributes(attributes)
31
+
32
+ yield self if block_given?
33
+ end
34
+
35
+ def configure
36
+ yield configuration
37
+ self
38
+ end
39
+
40
+ def configuration
41
+ @configuration ||= Configuration.new(shout)
42
+ end
43
+
44
+ # Connects to the server. Without a block, returns the connection. With a
45
+ # block, yields the connection, disconnects afterwards (also when the
46
+ # block raises) and returns the block's value.
47
+ def connect
48
+ Error.check_error(Native.shout_open(shout), shout)
49
+ return self unless block_given?
50
+
51
+ begin
52
+ yield self
53
+ ensure
54
+ disconnect
55
+ end
56
+ end
57
+ alias open connect
58
+
59
+ # Disconnects; does nothing when not connected.
60
+ def disconnect
61
+ ret = Native.shout_close(shout)
62
+ Error.check_error(ret, shout) unless ret == SHOUTERR_UNCONNECTED
63
+ self
64
+ end
65
+ alias close disconnect
66
+
67
+ def connected?
68
+ Native.shout_get_connected(shout) == SHOUTERR_CONNECTED
69
+ end
70
+
71
+ # Sends audio data, which libshout parses for timing information.
72
+ def send_data(data)
73
+ with_buffer(data) { |ptr, len| Error.check_error(Native.shout_send(shout, ptr, len), shout) }
74
+ self
75
+ end
76
+ alias write send_data
77
+ alias << send_data
78
+
79
+ # Sends data without parsing it. Returns the number of bytes written.
80
+ def send_raw(data)
81
+ with_buffer(data) do |ptr, len|
82
+ Native.shout_send_raw(shout, ptr, len).tap { |ret| Error.check_error(ret, shout) }
83
+ end
84
+ end
85
+
86
+ # Number of bytes waiting in the send queue (only useful in nonblocking mode).
87
+ def queuelen
88
+ Native.shout_queuelen(shout).tap { |ret| Error.check_error(ret, shout) }
89
+ end
90
+
91
+ # Sleeps until it is time to send more data.
92
+ def sync
93
+ Native.shout_sync(shout)
94
+ self
95
+ end
96
+
97
+ # Milliseconds to wait before sending more data.
98
+ def delay
99
+ Native.shout_delay(shout).tap { |ret| Error.check_error(ret, shout) }
100
+ end
101
+
102
+ # Updates the stream's metadata. Accepts a Metadata, a Hash or a String
103
+ # (used as the "song" value). Returns the Metadata that was sent.
104
+ def send_metadata(metadata_or_hash)
105
+ raise UnconnectedError, "Cannot send metadata to an unconnected stream" unless connected?
106
+
107
+ metadata = coerce_metadata(metadata_or_hash)
108
+ if http_protocol?
109
+ # libshout's own HTTP metadata update can loop forever; see HttpMetadata.
110
+ HttpMetadata.new(configuration, timeout: metadata_timeout).update(metadata)
111
+ else
112
+ Error.check_error(Native.shout_set_metadata_utf8(shout, metadata.handle), shout)
113
+ end
114
+ metadata
115
+ end
116
+ alias update_metadata send_metadata
117
+ alias metadata= send_metadata
118
+
119
+ def error
120
+ Native.shout_get_error(shout)
121
+ end
122
+
123
+ def errno
124
+ Native.shout_get_errno(shout)
125
+ end
126
+
127
+ private
128
+
129
+ def http_protocol?
130
+ configuration.protocol == :http
131
+ end
132
+
133
+ # Copies +data+ into native memory for the duration of the block. libshout
134
+ # calls release the GVL, so they must not read a Ruby string's memory.
135
+ def with_buffer(data)
136
+ bytes = data.to_s
137
+ ptr = FFI::MemoryPointer.new(:char, bytes.bytesize)
138
+ ptr.put_bytes(0, bytes)
139
+ yield ptr, bytes.bytesize
140
+ end
141
+
142
+ def assign_attributes(attributes)
143
+ unknown = attributes.keys.reject { |key| configuration.respond_to?(:"#{key}=") }
144
+ raise ArgumentError, "unknown attribute(s): #{unknown.join(", ")}" if unknown.any?
145
+
146
+ attributes.each { |key, value| configuration.public_send(:"#{key}=", value) }
147
+ end
148
+
149
+ def coerce_metadata(value)
150
+ case value
151
+ when Metadata then value
152
+ when Hash then Metadata.new(value)
153
+ when String then Metadata.new("song" => value)
154
+ else raise ArgumentError, "Expected Metadata, Hash, or String, got #{value.class}"
155
+ end
156
+ end
157
+ end
158
+
159
+ Client = Connection
160
+ end