shoutout 0.0.1
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.
- 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
|