shoutout 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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