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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +22 -0
- data/README.md +278 -0
- data/lib/ffi-shout.rb +5 -0
- data/lib/shout/configuration.rb +157 -0
- data/lib/shout/connection.rb +160 -0
- data/lib/shout/constants.rb +75 -0
- data/lib/shout/delegation.rb +71 -0
- data/lib/shout/error.rb +58 -0
- data/lib/shout/http_metadata.rb +104 -0
- data/lib/shout/library_version.rb +47 -0
- data/lib/shout/metadata.rb +64 -0
- data/lib/shout/native/enums.rb +68 -0
- data/lib/shout/native/functions.rb +192 -0
- data/lib/shout/native/handle.rb +20 -0
- data/lib/shout/native/metadata_handle.rb +21 -0
- data/lib/shout/native.rb +24 -0
- data/lib/shout/version.rb +5 -0
- data/lib/shout.rb +27 -0
- metadata +77 -0
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/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,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
|