shoutout 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Gemfile +6 -0
- data/LICENSE +20 -0
- data/README.md +101 -0
- data/Rakefile +17 -0
- data/lib/shoutout/headers.rb +66 -0
- data/lib/shoutout/metadata.rb +69 -0
- data/lib/shoutout/quick_access.rb +64 -0
- data/lib/shoutout/util.rb +8 -0
- data/lib/shoutout/version.rb +3 -0
- data/lib/shoutout.rb +178 -0
- data/spec/fixtures/error_response +2 -0
- data/spec/fixtures/ok_response +0 -0
- data/spec/fixtures/redirect_response +3 -0
- data/spec/fixtures/unsupported_response +3 -0
- data/spec/shoutout/headers_spec.rb +132 -0
- data/spec/shoutout/metadata_spec.rb +90 -0
- data/spec/shoutout/quick_access_spec.rb +42 -0
- data/spec/shoutout/util_spec.rb +11 -0
- data/spec/shoutout_spec.rb +289 -0
- data/spec/spec_helper.rb +7 -0
- data/spec/support/fake_tcp_socket.rb +20 -0
- metadata +106 -0
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2013 Douwe Maan
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
# shoutout
|
2
|
+
|
3
|
+
A Ruby library for easily getting metadata from Shoutcast-compatible audio streaming servers.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
```sh
|
8
|
+
gem install shoutout
|
9
|
+
```
|
10
|
+
|
11
|
+
Or in your Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem "shoutout"
|
15
|
+
```
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
require "shoutout"
|
21
|
+
|
22
|
+
shoutout = Shoutout.new("http://82.201.100.5:8000/radio538")
|
23
|
+
|
24
|
+
# Explicitly open a connection with the server. You're responsible for closing this connection using `#disconnect`.
|
25
|
+
shoutout.connect
|
26
|
+
|
27
|
+
# If you call any of the reader methods below without having explicitly opened a connection,
|
28
|
+
# one will be opened and closed around reading the information implicitly.
|
29
|
+
# This is convenient if you're only looking for one piece of information, but it is of course
|
30
|
+
# very inefficient if you're going to do multiple reads.
|
31
|
+
|
32
|
+
# Stream info
|
33
|
+
shoutout.name # => "RADIO538"
|
34
|
+
shoutout.description # => "ARE YOU IN"
|
35
|
+
shoutout.genre # => "Various"
|
36
|
+
shoutout.notice # => nil in this case, but this could very well have a value for your stream
|
37
|
+
shoutout.content_type # => "audio/mpeg"
|
38
|
+
shoutout.bitrate # => 128
|
39
|
+
shoutout.public? # => true
|
40
|
+
shoutout.audio_info # => { :samplerate => 44100, :bitrate => 128, :channels => 2 }
|
41
|
+
|
42
|
+
# Current metadata
|
43
|
+
shoutout.metadata # => { "StreamTitle" => "ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE", "StreamUrl" => "http://www.radio538.nl" }
|
44
|
+
|
45
|
+
# The Metadata object is a Hash that has been extended with the following features:
|
46
|
+
shoutout.metadata[:stream_title] # => "ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE"
|
47
|
+
shoutout.metadata[:stream_url] # => "http://www.radio538.nl"
|
48
|
+
shoutout.metadata.now_playing # => "ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE"
|
49
|
+
shoutout.metadata.website # => "http://www.radio538.nl"
|
50
|
+
shoutout.metadata.artist # => "ARMIN VAN BUUREN"
|
51
|
+
shoutout.metadata.song # => "THIS IS WHAT IT FEELS LIKE"
|
52
|
+
|
53
|
+
# Conveniently, `#now_playing` and `#website` are also available on the Shoutout instance:
|
54
|
+
shoutout.now_playing # => "ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE"
|
55
|
+
shoutout.website # => "http://www.radio538.nl"
|
56
|
+
|
57
|
+
# For convenience, all of the reader methods above are also available as class methods:
|
58
|
+
Shoutout.now_playing("http://82.201.100.5:8000/radio538") # => "ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE"
|
59
|
+
# Just like the equivalent `Shoutout.new("http://82.201.100.5:8000/radio538").now_playing`,
|
60
|
+
# this will automatically open and close a connection around reading the information.
|
61
|
+
|
62
|
+
# You can have a block called every time the metadata changes:
|
63
|
+
shoutout.metadata_change do |metadata|
|
64
|
+
puts "Now playing: #{metadata.song} by #{metadata.artist}"
|
65
|
+
end
|
66
|
+
# Of course, this only works with an explicitly opened connection.
|
67
|
+
|
68
|
+
# If you're done setting up but want the program to keep listening for metadata, say so:
|
69
|
+
shoutout.listen
|
70
|
+
# Note that listening will only end when the connection is lost or the end of file is reached,
|
71
|
+
# so anything that comes after this call will only then be executed.
|
72
|
+
# This will generally be the last call in your program.
|
73
|
+
|
74
|
+
# If we don't want to wait around and listen, just let the program exit or disconnect explicitly:
|
75
|
+
shoutout.disconnect
|
76
|
+
```
|
77
|
+
|
78
|
+
## Examples
|
79
|
+
Check out the [`examples/`](examples) folder for an example that I actually use myself.
|
80
|
+
|
81
|
+
## License
|
82
|
+
Copyright (c) 2013 Douwe Maan
|
83
|
+
|
84
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
85
|
+
a copy of this software and associated documentation files (the
|
86
|
+
"Software"), to deal in the Software without restriction, including
|
87
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
88
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
89
|
+
permit persons to whom the Software is furnished to do so, subject to
|
90
|
+
the following conditions:
|
91
|
+
|
92
|
+
The above copyright notice and this permission notice shall be
|
93
|
+
included in all copies or substantial portions of the Software.
|
94
|
+
|
95
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
96
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
97
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
98
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
99
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
100
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
101
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require "rspec/core/rake_task"
|
2
|
+
|
3
|
+
spec = Gem::Specification.load("shoutout.gemspec")
|
4
|
+
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
6
|
+
|
7
|
+
task default: :spec
|
8
|
+
|
9
|
+
desc "Build the .gem file"
|
10
|
+
task :build do
|
11
|
+
system "gem build #{spec.name}.gemspec"
|
12
|
+
end
|
13
|
+
|
14
|
+
desc "Push the .gem file to rubygems.org"
|
15
|
+
task release: :build do
|
16
|
+
system "gem push #{spec.name}-#{spec.version}.gem"
|
17
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
class Shoutout
|
2
|
+
class Headers < Hash
|
3
|
+
def self.parse(raw_headers)
|
4
|
+
headers = {}
|
5
|
+
raw_headers.split($/).each do |line|
|
6
|
+
key, value = line.chomp.split(":", 2)
|
7
|
+
headers[key.strip] = value.strip
|
8
|
+
end
|
9
|
+
|
10
|
+
new(headers)
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(constructor = {})
|
14
|
+
if constructor.is_a?(Hash)
|
15
|
+
super()
|
16
|
+
update(constructor)
|
17
|
+
else
|
18
|
+
super(constructor)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
alias_method :regular_writer, :[]= unless method_defined?(:regular_writer)
|
23
|
+
alias_method :regular_update, :update unless method_defined?(:regular_update)
|
24
|
+
|
25
|
+
def update(other_hash)
|
26
|
+
if other_hash.is_a?(Headers)
|
27
|
+
super(other_hash)
|
28
|
+
else
|
29
|
+
other_hash.each_pair do |key, value|
|
30
|
+
if block_given? && has_key?(key)
|
31
|
+
value = yield(convert_key(key), self[key], value)
|
32
|
+
end
|
33
|
+
self[key] = value
|
34
|
+
end
|
35
|
+
self
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def [](key)
|
40
|
+
super(convert_key(key))
|
41
|
+
end
|
42
|
+
|
43
|
+
def []=(key, value)
|
44
|
+
regular_writer(convert_key(key), value)
|
45
|
+
end
|
46
|
+
|
47
|
+
alias_method :store, :[]=
|
48
|
+
|
49
|
+
def key?(key)
|
50
|
+
super(convert_key(key))
|
51
|
+
end
|
52
|
+
|
53
|
+
alias_method :include?, :key?
|
54
|
+
alias_method :has_key?, :key?
|
55
|
+
alias_method :member?, :key?
|
56
|
+
|
57
|
+
def delete(key)
|
58
|
+
super(convert_key(key))
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
def convert_key(key)
|
63
|
+
(key.kind_of?(Symbol) ? key.to_s.gsub(/_/, "-") : key).downcase
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
class Shoutout
|
2
|
+
class Metadata < Hash
|
3
|
+
def self.parse(raw_metadata)
|
4
|
+
metadata = {}
|
5
|
+
raw_metadata.split(";").each do |key_value_pair|
|
6
|
+
key, value = key_value_pair.split("=", 2)
|
7
|
+
metadata[key] = value.match(/\A'(.*)'\z/)[1]
|
8
|
+
end
|
9
|
+
|
10
|
+
new(metadata)
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(constructor = {})
|
14
|
+
if constructor.is_a?(Hash)
|
15
|
+
super()
|
16
|
+
update(constructor)
|
17
|
+
else
|
18
|
+
super(constructor)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def [](key)
|
23
|
+
super(convert_key(key))
|
24
|
+
end
|
25
|
+
|
26
|
+
def []=(key, value)
|
27
|
+
super(convert_key(key), value)
|
28
|
+
end
|
29
|
+
|
30
|
+
alias_method :store, :[]=
|
31
|
+
|
32
|
+
def key?(key)
|
33
|
+
super(convert_key(key))
|
34
|
+
end
|
35
|
+
|
36
|
+
alias_method :include?, :key?
|
37
|
+
alias_method :has_key?, :key?
|
38
|
+
alias_method :member?, :key?
|
39
|
+
|
40
|
+
def delete(key)
|
41
|
+
super(convert_key(key))
|
42
|
+
end
|
43
|
+
|
44
|
+
module QuickAccess
|
45
|
+
def website
|
46
|
+
self[:stream_url]
|
47
|
+
end
|
48
|
+
|
49
|
+
def now_playing
|
50
|
+
self[:stream_title]
|
51
|
+
end
|
52
|
+
|
53
|
+
def artist
|
54
|
+
now_playing.split(" - ")[0]
|
55
|
+
end
|
56
|
+
|
57
|
+
def song
|
58
|
+
now_playing.split(" - ")[1]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
include QuickAccess
|
63
|
+
|
64
|
+
private
|
65
|
+
def convert_key(key)
|
66
|
+
key.kind_of?(Symbol) ? Util.camelize(key.to_s) : key
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
class Shoutout
|
2
|
+
module QuickAccess
|
3
|
+
def self.included(base)
|
4
|
+
base.extend(ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
def content_type
|
8
|
+
headers[:content_type]
|
9
|
+
end
|
10
|
+
|
11
|
+
def audio_info
|
12
|
+
return @audio_info if defined?(@audio_info)
|
13
|
+
|
14
|
+
raw_audio_info = headers[:ice_audio_info]
|
15
|
+
return @audio_info = nil if raw_audio_info.nil?
|
16
|
+
|
17
|
+
audio_info = {}
|
18
|
+
|
19
|
+
raw_audio_info.split(";").each do |key_value_pair|
|
20
|
+
key, value = key_value_pair.split("=")
|
21
|
+
key = key.sub(/\Aice-/, "").to_sym
|
22
|
+
value = value.to_i
|
23
|
+
|
24
|
+
audio_info[key] = value
|
25
|
+
end
|
26
|
+
|
27
|
+
@audio_info = audio_info
|
28
|
+
end
|
29
|
+
|
30
|
+
%w(name description genre notice).each do |method|
|
31
|
+
define_method(method) do
|
32
|
+
headers["icy-#{method}"]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def bitrate
|
37
|
+
headers[:icy_br].to_i
|
38
|
+
end
|
39
|
+
|
40
|
+
def public?
|
41
|
+
headers[:icy_pub] == "1"
|
42
|
+
end
|
43
|
+
|
44
|
+
def metadata_interval
|
45
|
+
headers[:icy_metaint].to_i if headers[:icy_metaint]
|
46
|
+
end
|
47
|
+
|
48
|
+
def now_playing
|
49
|
+
metadata.now_playing
|
50
|
+
end
|
51
|
+
|
52
|
+
def website
|
53
|
+
metadata.website || headers[:icy_url]
|
54
|
+
end
|
55
|
+
|
56
|
+
module ClassMethods
|
57
|
+
QuickAccess.instance_methods.each do |method|
|
58
|
+
define_method(method) do |url|
|
59
|
+
new(url).send(method)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/shoutout.rb
ADDED
@@ -0,0 +1,178 @@
|
|
1
|
+
require "uri"
|
2
|
+
require "socket"
|
3
|
+
|
4
|
+
require "shoutout/version"
|
5
|
+
require "shoutout/util"
|
6
|
+
require "shoutout/metadata"
|
7
|
+
require "shoutout/headers"
|
8
|
+
require "shoutout/quick_access"
|
9
|
+
|
10
|
+
class Shoutout
|
11
|
+
include QuickAccess
|
12
|
+
|
13
|
+
attr_reader :url
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def open(url)
|
17
|
+
shoutcast = new(url)
|
18
|
+
|
19
|
+
begin
|
20
|
+
shoutcast.connect
|
21
|
+
yield shoutcast
|
22
|
+
ensure
|
23
|
+
shoutcast.disconnect
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def metadata(url)
|
28
|
+
new(url).metadata
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(url)
|
33
|
+
@url = url
|
34
|
+
end
|
35
|
+
|
36
|
+
def connected?
|
37
|
+
@connected
|
38
|
+
end
|
39
|
+
|
40
|
+
def connect
|
41
|
+
return false if @connected
|
42
|
+
|
43
|
+
uri = URI.parse(@url)
|
44
|
+
@socket = TCPSocket.new(uri.host, uri.port)
|
45
|
+
@socket.puts "GET #{uri.path} HTTP/1.0"
|
46
|
+
@socket.puts "Icy-MetaData: 1"
|
47
|
+
@socket.puts
|
48
|
+
|
49
|
+
# Read status line
|
50
|
+
status_line = @socket.gets
|
51
|
+
status_code = status_line.match(/\AHTTP\/([0-9]\.[0-9]) ([0-9]{3})/)[2].to_i
|
52
|
+
|
53
|
+
@connected = true
|
54
|
+
|
55
|
+
read_headers
|
56
|
+
|
57
|
+
if status_code >= 300 && status_code < 400 && headers[:location]
|
58
|
+
disconnect
|
59
|
+
|
60
|
+
@url = URI.join(uri, headers[:location]).to_s
|
61
|
+
|
62
|
+
return connect
|
63
|
+
end
|
64
|
+
|
65
|
+
unless status_code >= 200 && status_code < 300
|
66
|
+
disconnect
|
67
|
+
|
68
|
+
return false
|
69
|
+
end
|
70
|
+
|
71
|
+
unless metadata_interval
|
72
|
+
disconnect
|
73
|
+
|
74
|
+
return false
|
75
|
+
end
|
76
|
+
|
77
|
+
@read_metadata_thread = Thread.new(&method(:read_metadata))
|
78
|
+
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
def disconnect
|
83
|
+
return false unless @connected
|
84
|
+
|
85
|
+
@connected = false
|
86
|
+
|
87
|
+
@socket.close if @socket && !@socket.closed?
|
88
|
+
@socket = nil
|
89
|
+
|
90
|
+
true
|
91
|
+
end
|
92
|
+
|
93
|
+
def listen
|
94
|
+
return unless @connected
|
95
|
+
|
96
|
+
@read_metadata_thread.join
|
97
|
+
@last_metadata_change_thread.join if @last_metadata_change_thread
|
98
|
+
end
|
99
|
+
|
100
|
+
def metadata
|
101
|
+
return @metadata if defined?(@metadata)
|
102
|
+
|
103
|
+
original_metadata_change_block = @metadata_change_block
|
104
|
+
|
105
|
+
received = false
|
106
|
+
metadata_change do |new_metadata|
|
107
|
+
received = true
|
108
|
+
end
|
109
|
+
|
110
|
+
already_connected = @connected
|
111
|
+
connect unless already_connected
|
112
|
+
|
113
|
+
sleep 0.015 until received
|
114
|
+
|
115
|
+
disconnect unless already_connected
|
116
|
+
|
117
|
+
metadata_change(&original_metadata_change_block) if original_metadata_change_block
|
118
|
+
|
119
|
+
@metadata
|
120
|
+
end
|
121
|
+
|
122
|
+
def metadata_change(&block)
|
123
|
+
@metadata_change_block = block
|
124
|
+
|
125
|
+
report_metadata_change(@metadata) if defined?(@metadata)
|
126
|
+
|
127
|
+
true
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
def read_headers
|
132
|
+
raw_headers = ""
|
133
|
+
while line = @socket.gets
|
134
|
+
break if line.chomp == ""
|
135
|
+
raw_headers << line
|
136
|
+
end
|
137
|
+
@headers = Headers.parse(raw_headers)
|
138
|
+
end
|
139
|
+
|
140
|
+
def read_metadata
|
141
|
+
while @connected
|
142
|
+
# Skip audio data
|
143
|
+
data = @socket.read(metadata_interval) || raise(EOFError)
|
144
|
+
|
145
|
+
data = @socket.read(1) || raise(EOFError)
|
146
|
+
metadata_length = data.unpack("c")[0] * 16
|
147
|
+
next if metadata_length == 0
|
148
|
+
|
149
|
+
data = @socket.read(metadata_length) || raise(EOFError)
|
150
|
+
raw_metadata = data.unpack("A*")[0]
|
151
|
+
@metadata = Metadata.parse(raw_metadata)
|
152
|
+
|
153
|
+
report_metadata_change(@metadata)
|
154
|
+
end
|
155
|
+
rescue Errno::EBADF, IOError => e
|
156
|
+
# Connection lost
|
157
|
+
disconnect
|
158
|
+
end
|
159
|
+
|
160
|
+
def report_metadata_change(metadata)
|
161
|
+
@last_metadata_change_thread = Thread.new(metadata, @last_metadata_change_thread) do |metadata, last_metadata_change_thread|
|
162
|
+
last_metadata_change_thread.join if last_metadata_change_thread
|
163
|
+
|
164
|
+
@metadata_change_block.call(metadata) if @metadata_change_block
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def headers
|
169
|
+
return @headers if defined?(@headers)
|
170
|
+
|
171
|
+
# Connected but no headers? I give up.
|
172
|
+
return [] if @connected
|
173
|
+
|
174
|
+
connect && disconnect
|
175
|
+
|
176
|
+
@headers
|
177
|
+
end
|
178
|
+
end
|
Binary file
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Shoutout::Headers do
|
4
|
+
|
5
|
+
let(:raw_headers) {
|
6
|
+
"Content-Type: audio/mpeg\n" <<
|
7
|
+
"icy-br:128\n" <<
|
8
|
+
"ice-audio-info: ice-samplerate=44100;ice-bitrate=128;ice-channels=2\n" <<
|
9
|
+
"icy-br:128\n" <<
|
10
|
+
"icy-description:ARE YOU IN\n" <<
|
11
|
+
"icy-genre:Various\n" <<
|
12
|
+
"icy-name:RADIO538\n" <<
|
13
|
+
"icy-pub:1\n" <<
|
14
|
+
"icy-url:http://www.radio538.nl\n" <<
|
15
|
+
"Server: Icecast 2.3.2-kh31\n" <<
|
16
|
+
"icy-metaint:16000"
|
17
|
+
}
|
18
|
+
|
19
|
+
let(:headers) {
|
20
|
+
{
|
21
|
+
"Content-Type" => "audio/mpeg",
|
22
|
+
"icy-br" => "128",
|
23
|
+
"ice-audio-info" => "ice-samplerate=44100;ice-bitrate=128;ice-channels=2",
|
24
|
+
"icy-description" => "ARE YOU IN",
|
25
|
+
"icy-genre" => "Various",
|
26
|
+
"icy-name" => "RADIO538",
|
27
|
+
"icy-pub" => "1",
|
28
|
+
"icy-url" => "http://www.radio538.nl",
|
29
|
+
"Server" => "Icecast 2.3.2-kh31",
|
30
|
+
"icy-metaint" => "16000"
|
31
|
+
}
|
32
|
+
}
|
33
|
+
|
34
|
+
subject { described_class.new(headers) }
|
35
|
+
|
36
|
+
describe ".parse" do
|
37
|
+
|
38
|
+
it "parses the headers" do
|
39
|
+
described_class.should_receive(:new).with(headers)
|
40
|
+
|
41
|
+
described_class.parse(raw_headers)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "#initialize" do
|
46
|
+
|
47
|
+
context "when provided a Hash" do
|
48
|
+
|
49
|
+
it "updates self with the Hash" do
|
50
|
+
described_class.any_instance.should_receive(:update).with(headers)
|
51
|
+
|
52
|
+
described_class.new(headers)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe "#[]" do
|
58
|
+
|
59
|
+
context "when provided a Symbol" do
|
60
|
+
|
61
|
+
context "when the header's key was originally camel cased" do
|
62
|
+
|
63
|
+
it "returns the header's value" do
|
64
|
+
subject[:content_type].should eq(headers["Content-Type"])
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context "when the header's key was originally lowercase" do
|
69
|
+
|
70
|
+
it "returns the header's value" do
|
71
|
+
subject[:Icy_Br].should eq(headers["icy-br"])
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context "when provided a String" do
|
77
|
+
|
78
|
+
context "when the header's key was originally camel cased" do
|
79
|
+
|
80
|
+
it "returns the header's value" do
|
81
|
+
subject["content-type"].should eq(headers["Content-Type"])
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
context "when the header's key was originally lowercase" do
|
86
|
+
|
87
|
+
it "returns the header's value" do
|
88
|
+
subject["Icy-Br"].should eq(headers["icy-br"])
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
describe "#[]=" do
|
95
|
+
|
96
|
+
let(:content_type) { "text/plain" }
|
97
|
+
|
98
|
+
context "when provided a Symbol key" do
|
99
|
+
|
100
|
+
it "is saved on the hash" do
|
101
|
+
subject[:content_type] = content_type
|
102
|
+
|
103
|
+
subject.should have_key(:content_type)
|
104
|
+
end
|
105
|
+
|
106
|
+
it "can be read out again" do
|
107
|
+
subject[:content_type] = content_type
|
108
|
+
|
109
|
+
subject[:content_type].should eq(content_type)
|
110
|
+
subject["content-type"].should eq(content_type)
|
111
|
+
subject["Content-Type"].should eq(content_type)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
context "when provided a String key" do
|
116
|
+
|
117
|
+
it "is saved on the hash" do
|
118
|
+
subject["content-type"] = content_type
|
119
|
+
|
120
|
+
subject.should have_key("content-type")
|
121
|
+
end
|
122
|
+
|
123
|
+
it "can be read out again" do
|
124
|
+
subject["content-type"] = content_type
|
125
|
+
|
126
|
+
subject[:content_type].should eq(content_type)
|
127
|
+
subject["content-type"].should eq(content_type)
|
128
|
+
subject["Content-Type"].should eq(content_type)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Shoutout::Metadata do
|
4
|
+
|
5
|
+
let(:raw_metadata) { "StreamTitle='ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE';StreamUrl='http://www.radio538.nl';" }
|
6
|
+
|
7
|
+
let(:metadata) {
|
8
|
+
{
|
9
|
+
"StreamTitle" => "ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE",
|
10
|
+
"StreamUrl" => "http://www.radio538.nl"
|
11
|
+
}
|
12
|
+
}
|
13
|
+
|
14
|
+
subject { described_class.new(metadata) }
|
15
|
+
|
16
|
+
describe ".parse" do
|
17
|
+
|
18
|
+
it "parses the metadata" do
|
19
|
+
described_class.should_receive(:new).with(metadata)
|
20
|
+
|
21
|
+
described_class.parse(raw_metadata)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe "#initialize" do
|
26
|
+
|
27
|
+
context "when provided a Hash" do
|
28
|
+
|
29
|
+
it "updates self with the Hash" do
|
30
|
+
described_class.any_instance.should_receive(:update).with(metadata)
|
31
|
+
|
32
|
+
described_class.new(metadata)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe "#[]" do
|
38
|
+
|
39
|
+
context "when provided a Symbol" do
|
40
|
+
|
41
|
+
it "returns the metadata" do
|
42
|
+
subject[:stream_title].should eq(metadata["StreamTitle"])
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context "when provided a String" do
|
47
|
+
|
48
|
+
it "returns the header's value" do
|
49
|
+
subject["StreamTitle"].should eq(metadata["StreamTitle"])
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
describe "#[]=" do
|
55
|
+
|
56
|
+
let(:stream_title) { "AVICII - WAKE ME UP" }
|
57
|
+
|
58
|
+
context "when provided a Symbol key" do
|
59
|
+
|
60
|
+
it "is saved on the hash" do
|
61
|
+
subject[:stream_title] = stream_title
|
62
|
+
|
63
|
+
subject.should have_key(:stream_title)
|
64
|
+
end
|
65
|
+
|
66
|
+
it "can be read out again" do
|
67
|
+
subject[:stream_title] = stream_title
|
68
|
+
|
69
|
+
subject[:stream_title].should eq(stream_title)
|
70
|
+
subject["StreamTitle"].should eq(stream_title)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
context "when provided a String key" do
|
75
|
+
|
76
|
+
it "is saved on the hash" do
|
77
|
+
subject["StreamTitle"] = stream_title
|
78
|
+
|
79
|
+
subject.should have_key("StreamTitle")
|
80
|
+
end
|
81
|
+
|
82
|
+
it "can be read out again" do
|
83
|
+
subject["StreamTitle"] = stream_title
|
84
|
+
|
85
|
+
subject[:stream_title].should eq(stream_title)
|
86
|
+
subject["StreamTitle"].should eq(stream_title)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Shoutout::QuickAccess do
|
4
|
+
|
5
|
+
let(:url) { "http://82.201.100.5:8000/radio538" }
|
6
|
+
subject { Shoutout.new(url) }
|
7
|
+
|
8
|
+
describe "#audio_info" do
|
9
|
+
|
10
|
+
let(:raw_audio_info) { "ice-samplerate=44100;ice-bitrate=128;ice-channels=2" }
|
11
|
+
let(:audio_info) {
|
12
|
+
{
|
13
|
+
samplerate: 44100,
|
14
|
+
bitrate: 128,
|
15
|
+
channels: 2
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
before(:each) do
|
20
|
+
subject.stub(:headers).and_return(Shoutout::Headers.new("ice-audio-info" => raw_audio_info))
|
21
|
+
end
|
22
|
+
|
23
|
+
it "returns the parsed metadata" do
|
24
|
+
subject.audio_info.should eq(audio_info)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe ".name" do
|
29
|
+
|
30
|
+
it "creates a new instance" do
|
31
|
+
Shoutout.should_receive(:new).with(url).and_return(double("shoutout").as_null_object)
|
32
|
+
|
33
|
+
Shoutout.name(url)
|
34
|
+
end
|
35
|
+
|
36
|
+
it "calls #name on the opened connection" do
|
37
|
+
Shoutout.any_instance.should_receive(:name)
|
38
|
+
|
39
|
+
Shoutout.name(url)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Shoutout do
|
4
|
+
|
5
|
+
let(:url) { "http://82.201.100.5:8000/radio538" }
|
6
|
+
let(:uri) { URI.parse(url) }
|
7
|
+
subject! { described_class.new(url) }
|
8
|
+
let(:response_data) { File.read(File.expand_path("../fixtures/ok_response", __FILE__)) }
|
9
|
+
let(:socket) { FakeTCPSocket.new(response_data) }
|
10
|
+
|
11
|
+
before(:each) do
|
12
|
+
TCPSocket.stub(:new).with(uri.host, uri.port).and_return(socket)
|
13
|
+
|
14
|
+
described_class.stub(:new).with(url).and_return(subject)
|
15
|
+
end
|
16
|
+
|
17
|
+
after(:each) do
|
18
|
+
subject.disconnect
|
19
|
+
end
|
20
|
+
|
21
|
+
describe ".open" do
|
22
|
+
|
23
|
+
let(:block) { lambda { |_| } }
|
24
|
+
|
25
|
+
it "creates a new instance" do
|
26
|
+
described_class.should_receive(:new).with(url)
|
27
|
+
|
28
|
+
described_class.open(url, &block)
|
29
|
+
end
|
30
|
+
|
31
|
+
it "opens a connection" do
|
32
|
+
subject.should_receive(:connect)
|
33
|
+
|
34
|
+
described_class.open(url, &block)
|
35
|
+
end
|
36
|
+
|
37
|
+
it "calls the block" do
|
38
|
+
expect { |block|
|
39
|
+
described_class.open(url, &block)
|
40
|
+
}.to yield_with_args(subject)
|
41
|
+
end
|
42
|
+
|
43
|
+
it "closes the connection" do
|
44
|
+
subject.should_receive(:disconnect).twice.and_call_original
|
45
|
+
|
46
|
+
described_class.open(url, &block)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe ".metadata" do
|
51
|
+
|
52
|
+
it "creates a new instance" do
|
53
|
+
described_class.should_receive(:new).with(url)
|
54
|
+
|
55
|
+
described_class.metadata(url)
|
56
|
+
end
|
57
|
+
|
58
|
+
it "calls #metadata on the opened connection" do
|
59
|
+
subject.should_receive(:metadata)
|
60
|
+
|
61
|
+
described_class.metadata(url)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "#connect" do
|
66
|
+
|
67
|
+
context "when already connected" do
|
68
|
+
|
69
|
+
before(:each) do
|
70
|
+
subject.connect
|
71
|
+
end
|
72
|
+
|
73
|
+
it "returns false" do
|
74
|
+
subject.connect.should be_false
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
context "when not connected" do
|
79
|
+
|
80
|
+
it "connects" do
|
81
|
+
subject.connect
|
82
|
+
|
83
|
+
subject.should be_connected
|
84
|
+
end
|
85
|
+
|
86
|
+
it "opens a socket" do
|
87
|
+
TCPSocket.should_receive(:new).with(uri.host, uri.port)
|
88
|
+
|
89
|
+
subject.connect
|
90
|
+
end
|
91
|
+
|
92
|
+
it "writes the HTTP request to the socket" do
|
93
|
+
socket.should_receive(:puts).once.ordered.with("GET #{uri.path} HTTP/1.0")
|
94
|
+
socket.should_receive(:puts).once.ordered.with("Icy-MetaData: 1")
|
95
|
+
socket.should_receive(:puts).once.ordered.with(no_args)
|
96
|
+
|
97
|
+
subject.connect
|
98
|
+
end
|
99
|
+
|
100
|
+
it "reads the headers" do
|
101
|
+
subject.connect
|
102
|
+
|
103
|
+
subject.metadata_interval.should eq(10)
|
104
|
+
end
|
105
|
+
|
106
|
+
context "when the response indicates a redirect" do
|
107
|
+
|
108
|
+
let(:redirect_response_data) { File.read(File.expand_path("../fixtures/redirect_response", __FILE__)) }
|
109
|
+
let(:redirect_socket) { FakeTCPSocket.new(redirect_response_data) }
|
110
|
+
|
111
|
+
before(:each) do
|
112
|
+
TCPSocket.stub(:new).with(uri.host, uri.port).and_return(redirect_socket)
|
113
|
+
TCPSocket.stub(:new).with("82.201.100.7", 8000).and_return(socket)
|
114
|
+
end
|
115
|
+
|
116
|
+
it "closes the connection" do
|
117
|
+
subject.should_receive(:disconnect).twice.and_call_original
|
118
|
+
|
119
|
+
subject.connect
|
120
|
+
end
|
121
|
+
|
122
|
+
it "updates the url" do
|
123
|
+
subject.connect
|
124
|
+
|
125
|
+
subject.url.should eq("http://82.201.100.7:8000/radio538")
|
126
|
+
end
|
127
|
+
|
128
|
+
it "opens a second connection" do
|
129
|
+
subject.should_receive(:connect).twice.and_call_original
|
130
|
+
|
131
|
+
subject.connect
|
132
|
+
end
|
133
|
+
|
134
|
+
it "returns true" do
|
135
|
+
subject.connect.should be_true
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
context "when the response indicates an error" do
|
140
|
+
|
141
|
+
let(:response_data) { File.read(File.expand_path("../fixtures/error_response", __FILE__)) }
|
142
|
+
|
143
|
+
it "closes the connection" do
|
144
|
+
subject.should_receive(:disconnect).twice.and_call_original
|
145
|
+
|
146
|
+
subject.connect
|
147
|
+
end
|
148
|
+
|
149
|
+
it "returns false" do
|
150
|
+
subject.connect.should be_false
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
context "when the response indicates success" do
|
155
|
+
|
156
|
+
context "when the response is unsupported" do
|
157
|
+
|
158
|
+
let(:response_data) { File.read(File.expand_path("../fixtures/unsupported_response", __FILE__)) }
|
159
|
+
|
160
|
+
it "closes the connection" do
|
161
|
+
subject.should_receive(:disconnect).twice.and_call_original
|
162
|
+
|
163
|
+
subject.connect
|
164
|
+
end
|
165
|
+
|
166
|
+
it "returns false" do
|
167
|
+
subject.connect.should be_false
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
context "when the response is supported" do
|
172
|
+
|
173
|
+
it "starts reading metadata in a thread" do
|
174
|
+
Thread.should_receive(:new)
|
175
|
+
|
176
|
+
subject.connect
|
177
|
+
end
|
178
|
+
|
179
|
+
it "returns true" do
|
180
|
+
subject.connect.should be_true
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
describe "#disconnect" do
|
188
|
+
|
189
|
+
context "when connected" do
|
190
|
+
|
191
|
+
before(:each) do
|
192
|
+
subject.connect
|
193
|
+
end
|
194
|
+
|
195
|
+
it "disconnects" do
|
196
|
+
subject.disconnect
|
197
|
+
|
198
|
+
subject.should_not be_connected
|
199
|
+
end
|
200
|
+
|
201
|
+
it "closes the socket" do
|
202
|
+
socket.should_receive(:close)
|
203
|
+
|
204
|
+
subject.disconnect
|
205
|
+
end
|
206
|
+
|
207
|
+
it "returns true" do
|
208
|
+
subject.disconnect.should be_true
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
context "when not connected" do
|
213
|
+
|
214
|
+
it "returns false" do
|
215
|
+
subject.disconnect.should be_false
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
describe "#listen" do
|
221
|
+
|
222
|
+
context "when connected" do
|
223
|
+
|
224
|
+
before(:each) do
|
225
|
+
subject.connect
|
226
|
+
end
|
227
|
+
|
228
|
+
after(:each) do
|
229
|
+
subject.disconnect
|
230
|
+
end
|
231
|
+
|
232
|
+
it "joins the metadata reading thread" do
|
233
|
+
Thread.any_instance.should_receive(:join)
|
234
|
+
|
235
|
+
subject.listen
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
describe "#metadata" do
|
241
|
+
|
242
|
+
context "when connected" do
|
243
|
+
|
244
|
+
before(:each) do
|
245
|
+
subject.connect
|
246
|
+
end
|
247
|
+
|
248
|
+
it "returns the metadata" do
|
249
|
+
subject.metadata.now_playing.should eq("AVICII - WAKE ME UP")
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
context "when not connected" do
|
254
|
+
|
255
|
+
it "opens a connection" do
|
256
|
+
subject.should_receive(:connect).and_call_original
|
257
|
+
|
258
|
+
subject.metadata
|
259
|
+
end
|
260
|
+
|
261
|
+
it "closes the connection" do
|
262
|
+
subject.should_receive(:disconnect).at_least(2).times.and_call_original
|
263
|
+
|
264
|
+
subject.metadata
|
265
|
+
end
|
266
|
+
|
267
|
+
it "returns the metadata" do
|
268
|
+
subject.metadata.now_playing.should eq("AVICII - WAKE ME UP")
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
describe "#metadata_change" do
|
274
|
+
|
275
|
+
it "sets a block that is called when the metadata changes" do
|
276
|
+
metadatas = []
|
277
|
+
|
278
|
+
subject.metadata_change do |metadata|
|
279
|
+
metadatas << metadata
|
280
|
+
end
|
281
|
+
|
282
|
+
subject.connect
|
283
|
+
subject.listen
|
284
|
+
|
285
|
+
metadatas[0].now_playing.should eq("ARMIN VAN BUUREN - THIS IS WHAT IT FEELS LIKE")
|
286
|
+
metadatas[1].now_playing.should eq("AVICII - WAKE ME UP")
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: shoutout
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Douwe Maan
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-09-08 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rake
|
16
|
+
requirement: &70299652575840 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70299652575840
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: rspec
|
27
|
+
requirement: &70299652591920 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ! '>='
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70299652591920
|
36
|
+
description: A Ruby library for easily getting metadata from Shoutcast-compatible
|
37
|
+
audio streaming servers
|
38
|
+
email: douwe@selenight.nl
|
39
|
+
executables: []
|
40
|
+
extensions: []
|
41
|
+
extra_rdoc_files: []
|
42
|
+
files:
|
43
|
+
- lib/shoutout/headers.rb
|
44
|
+
- lib/shoutout/metadata.rb
|
45
|
+
- lib/shoutout/quick_access.rb
|
46
|
+
- lib/shoutout/util.rb
|
47
|
+
- lib/shoutout/version.rb
|
48
|
+
- lib/shoutout.rb
|
49
|
+
- LICENSE
|
50
|
+
- README.md
|
51
|
+
- Rakefile
|
52
|
+
- Gemfile
|
53
|
+
- spec/fixtures/error_response
|
54
|
+
- spec/fixtures/ok_response
|
55
|
+
- spec/fixtures/redirect_response
|
56
|
+
- spec/fixtures/unsupported_response
|
57
|
+
- spec/shoutout/headers_spec.rb
|
58
|
+
- spec/shoutout/metadata_spec.rb
|
59
|
+
- spec/shoutout/quick_access_spec.rb
|
60
|
+
- spec/shoutout/util_spec.rb
|
61
|
+
- spec/shoutout_spec.rb
|
62
|
+
- spec/spec_helper.rb
|
63
|
+
- spec/support/fake_tcp_socket.rb
|
64
|
+
homepage: https://github.com/DouweM/shoutout
|
65
|
+
licenses:
|
66
|
+
- MIT
|
67
|
+
post_install_message:
|
68
|
+
rdoc_options: []
|
69
|
+
require_paths:
|
70
|
+
- lib
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ! '>='
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
segments:
|
78
|
+
- 0
|
79
|
+
hash: 848854510650494207
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
segments:
|
87
|
+
- 0
|
88
|
+
hash: 848854510650494207
|
89
|
+
requirements: []
|
90
|
+
rubyforge_project:
|
91
|
+
rubygems_version: 1.8.6
|
92
|
+
signing_key:
|
93
|
+
specification_version: 3
|
94
|
+
summary: Read metadata from Shoutcast streams
|
95
|
+
test_files:
|
96
|
+
- spec/fixtures/error_response
|
97
|
+
- spec/fixtures/ok_response
|
98
|
+
- spec/fixtures/redirect_response
|
99
|
+
- spec/fixtures/unsupported_response
|
100
|
+
- spec/shoutout/headers_spec.rb
|
101
|
+
- spec/shoutout/metadata_spec.rb
|
102
|
+
- spec/shoutout/quick_access_spec.rb
|
103
|
+
- spec/shoutout/util_spec.rb
|
104
|
+
- spec/shoutout_spec.rb
|
105
|
+
- spec/spec_helper.rb
|
106
|
+
- spec/support/fake_tcp_socket.rb
|