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 ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "activesupport"
6
+ gem "terminal-notifier"
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
@@ -0,0 +1,8 @@
1
+ class Shoutout
2
+ module Util
3
+ def self.camelize(term)
4
+ term = term.sub(/^[a-z\d]*/) { $&.capitalize }
5
+ term.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ class Shoutout
2
+ VERSION = "0.0.1"
3
+ 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
@@ -0,0 +1,2 @@
1
+ HTTP/1.0 404 Not Found
2
+
Binary file
@@ -0,0 +1,3 @@
1
+ HTTP/1.0 301 Moved Permanently
2
+ Location: http://82.201.100.7:8000/radio538
3
+
@@ -0,0 +1,3 @@
1
+ HTTP/1.0 200 OK
2
+
3
+ <strong>Unsupported</strong>
@@ -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,11 @@
1
+ require "spec_helper"
2
+
3
+ describe Shoutout::Util do
4
+
5
+ describe ".camelize" do
6
+
7
+ it "camelizes the passed string" do
8
+ described_class.camelize("under_score").should eq("UnderScore")
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,7 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
3
+
4
+ require "rspec"
5
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f }
6
+
7
+ require "shoutout"
@@ -0,0 +1,20 @@
1
+ require "stringio"
2
+
3
+ class FakeTCPSocket < StringIO
4
+
5
+ def <<(*args)
6
+ # noop
7
+ end
8
+
9
+ def puts(*args)
10
+ # noop
11
+ end
12
+
13
+ def print(*args)
14
+ # noop
15
+ end
16
+
17
+ def printf(*args)
18
+ # noop
19
+ end
20
+ end
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