sonos 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+
data/Changelog.markdown CHANGED
@@ -1,3 +1,10 @@
1
+ ### Version 0.3.0 — February 2, 2012
2
+
3
+ * System owns groups that reflect the topology
4
+ * Group and ungroup speakers
5
+ * Rename `playlist_position` to `queue_position` for consistency
6
+ * Add `seek`
7
+
1
8
  ### Version 0.2.1 — December 24, 2012
2
9
 
3
10
  * Fix album art in now playing
data/Gemfile CHANGED
@@ -1,7 +1,17 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- # Specify your gem's dependencies in control.gemspec
3
+ # Gem dependencies
4
4
  gemspec
5
5
 
6
+ # Development dependencies
6
7
  gem 'rake'
7
8
  gem 'yard'
9
+
10
+ # Testing dependencies
11
+ group :test do
12
+ gem 'minitest'
13
+ gem 'minitest-wscolor'
14
+ gem 'fakeweb'
15
+ gem 'vcr'
16
+ gem 'mocha', require: false
17
+ end
data/Rakefile CHANGED
@@ -1 +1,8 @@
1
1
  require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.pattern = 'test/**/*_test.rb'
7
+ end
8
+ task default: :test
data/Readme.markdown CHANGED
@@ -4,6 +4,8 @@ Control Sonos speakers with Ruby.
4
4
 
5
5
  Huge thanks to [Rahim Sonawalla](https://github.com/rahims) for making [SoCo](https://github.com/rahims/SoCo). This gem would not be possible without his work.
6
6
 
7
+ [![Code Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/soffes/sonos) [![Dependency Status](https://gemnasium.com/soffes/sonos.png)](https://gemnasium.com/soffes/sonos)
8
+
7
9
  ## Installation
8
10
 
9
11
  Add this line to your application's Gemfile:
@@ -36,7 +38,8 @@ $ irb
36
38
  ``` ruby
37
39
  require 'rubygems'
38
40
  require 'sonos'
39
- speaker = Sonos.discover
41
+ system = Sonos::System.new # Auto-discovers your system
42
+ speaker = system.speakers.first
40
43
  ```
41
44
 
42
45
  Now that we have a reference to the speaker, we can do all kinds of stuff.
@@ -56,41 +59,48 @@ speaker.clear_queue
56
59
 
57
60
  ### Topology
58
61
 
59
- `Sonos.discover` finds the first speaker it can. We can get all of the Sonos devices (including Bridges, etc) by calling `speaker.topology`. This is going to get refactored a bit. Right now everything is nested under speaker which is kinda messy and confusing.
62
+ `Sonos.discover` finds the first speaker it can. We can get all of the Sonos devices (including Bridges, etc) by calling `Sonos.system.devices`. To get the groups, call `Sonos.system.groups`.
63
+
64
+ All of this is based off of the raw `Sonos.system.topology`.
60
65
 
61
66
  ### CLI
62
67
 
63
- There is a very limited CLI right now. You can run `sonos discover` to get the IP of one of your devices. Run `sonos discover --all` to get all of them.
68
+ There is a very limited CLI right now. You can run `sonos devices` to get the IP of all of your devices.
69
+
70
+ You can also run `sonos pause_all` to pause all your Sonos groups.
64
71
 
65
72
  ## To Do
66
73
 
67
74
  ### General
68
75
 
69
- * Refactor all of the things
70
- * Nonblocking calls with Celluloid::IO
71
- * List other speakers
72
76
  * Handle errors better
73
77
  * Handle line-in in `now_playing`
74
- * Better support for stero pairs
78
+ * Detect fixed volume
79
+ * Detect stereo pair
75
80
  * CLI client for everything
81
+ * Nonblocking calls with Celluloid::IO
76
82
 
77
83
  ### Features
78
84
 
79
- * Alarm clock
85
+ * Manipulating groups doesn't update `System#groups`
80
86
  * Pause all (there is no play all in the controller, we could loop through and do it though)
81
- * Group management
82
- * Party Mode
83
- * Join
87
+ * Party Mode
84
88
  * Line-in
85
89
  * Toggle cross fade
86
90
  * Toggle shuffle
87
91
  * Set repeat mode
88
- * Scrub
89
92
  * Search music library
90
93
  * Browse music library
91
94
  * Add songs to queue
92
95
  * Skip to song in queue
96
+ * Alarm clock
93
97
  * Sleep timer
98
+ * Pandora doesn't use the Queue. I bet things are all jacked up.
99
+ * CONNECT (and possibly PLAY:5) line in settings
100
+ * Source name
101
+ * Level
102
+ * Autoplay room
103
+ * Autoplay include grouped rooms
94
104
 
95
105
  ### Maybe
96
106
 
@@ -98,14 +108,16 @@ If we are implementing everything the official Sonos Controller does, here's som
98
108
 
99
109
  * Set zone name and icon
100
110
  * Create stero pair
101
- * Support for BRIDGE
111
+ * Support for SUB
102
112
  * Support for DOCK
113
+ * Support for CONNECT:AMP (not sure if this is any different from CONNECT)
103
114
  * Manage services
104
115
  * Date and time
105
116
  * Wireless channel
106
117
  * Audio compression
107
118
  * Automatically check for updates (not sure if this is a controller only preference)
108
119
  * Local music servers
120
+ * Add component
109
121
 
110
122
  ## Contributing
111
123
 
data/lib/sonos/cli.rb CHANGED
@@ -3,18 +3,29 @@ require 'sonos'
3
3
 
4
4
  module Sonos
5
5
  class Cli < Thor
6
- desc 'discover', 'Finds the IP address of a Sonos device on your network'
7
- method_option :all, type: :boolean, aliases: '-a', desc: 'Find all of the IP address instead of the first one discoverd'
8
- def discover
9
- speaker = Sonos.discover
6
+ desc 'devices', 'Finds the IP address of all of the Sonos devices on your network'
7
+ def devices
8
+ system.devices.each do |device|
9
+ puts device.name.ljust(20) + device.ip
10
+ end
11
+ end
10
12
 
11
- if options[:all]
12
- speaker.topology.each do |node|
13
- puts "#{node.name.ljust(20)} #{node.ip}"
14
- end
15
- else
16
- puts "#{speaker.zone_name.ljust(20)} #{speaker.ip}"
13
+ desc 'speakers', 'Finds the IP address of all of the Sonos speakers on your network'
14
+ def speakers
15
+ system.speakers.each do |speaker|
16
+ puts speaker.name.ljust(20) + speaker.ip
17
17
  end
18
18
  end
19
+
20
+ desc 'pause_all', 'Pauses all Sonos speaker groups'
21
+ def pause_all
22
+ system.pause_all
23
+ end
24
+
25
+ private
26
+
27
+ def system
28
+ @system ||= Sonos::System.new
29
+ end
19
30
  end
20
31
  end
@@ -0,0 +1,79 @@
1
+ require 'open-uri'
2
+ require 'nokogiri'
3
+
4
+ module Sonos::Device
5
+ class Base
6
+ attr_reader :ip, :name, :uid, :serial_number, :software_version, :hardware_version, :mac_address, :group
7
+
8
+ def self.detect(ip)
9
+ data = retrieve_information(ip)
10
+ model_number = data[:model_number]
11
+
12
+ if Bridge.model_numbers.include?(model_number)
13
+ Bridge.new(ip, data)
14
+ elsif Speaker.model_numbers.include?(model_number)
15
+ Speaker.new(ip, data)
16
+ else
17
+ raise ArgumentError.new("#{self.data[:model_number]} not supported")
18
+ end
19
+ end
20
+
21
+ def initialize(ip, data = nil)
22
+ @ip = ip
23
+
24
+ if data.nil?
25
+ self.data = Base.retrieve_information(ip)
26
+ else
27
+ self.data = data
28
+ end
29
+ end
30
+
31
+ def data=(data)
32
+ @name = data[:name]
33
+ @uid = data[:uid]
34
+ @serial_number = data[:serial_number]
35
+ @software_version = data[:software_version]
36
+ @hardware_version = data[:hardware_version]
37
+ @zone_type = data[:zone_type]
38
+ @model_number = data[:model_number]
39
+ end
40
+
41
+ def data
42
+ {
43
+ name: @name,
44
+ uid: @uid,
45
+ serial_number: @serial_number,
46
+ software_version: @software_version,
47
+ hardware_version: @hardware_version,
48
+ zone_type: @zone_type,
49
+ model_number: @model_number
50
+ }
51
+ end
52
+
53
+ # Can this device play music?
54
+ # @return [Boolean] a boolean indicating if it can play music
55
+ def speaker?
56
+ false
57
+ end
58
+
59
+ protected
60
+
61
+ def self.retrieve_information(ip)
62
+ url = "http://#{ip}:#{Sonos::PORT}/xml/device_description.xml"
63
+ parse_description(Nokogiri::XML(open(url)))
64
+ end
65
+
66
+ # Get information about the device
67
+ def self.parse_description(doc)
68
+ {
69
+ name: doc.xpath('/xmlns:root/xmlns:device/xmlns:roomName').inner_text,
70
+ uid: doc.xpath('/xmlns:root/xmlns:device/xmlns:UDN').inner_text,
71
+ serial_number: doc.xpath('/xmlns:root/xmlns:device/xmlns:serialNum').inner_text,
72
+ software_version: doc.xpath('/xmlns:root/xmlns:device/xmlns:hardwareVersion').inner_text,
73
+ hardware_version: doc.xpath('/xmlns:root/xmlns:device/xmlns:softwareVersion').inner_text,
74
+ zone_type: doc.xpath('/xmlns:root/xmlns:device/xmlns:zoneType').inner_text,
75
+ model_number: doc.xpath('/xmlns:root/xmlns:device/xmlns:modelNumber').inner_text
76
+ }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,17 @@
1
+ require 'savon'
2
+ require 'sonos/endpoint'
3
+
4
+ module Sonos::Device
5
+
6
+ # Used for Zone Bridge
7
+ class Bridge < Base
8
+
9
+ MODEL_NUMBERS = ['ZB100']
10
+
11
+ attr_reader :icon
12
+
13
+ def self.model_numbers
14
+ MODEL_NUMBERS
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ require 'savon'
2
+ require 'sonos/endpoint'
3
+
4
+ module Sonos::Device
5
+
6
+ # Used for PLAY:3, PLAY:5, and CONNECT
7
+ class Speaker < Base
8
+ include Sonos::Endpoint::AVTransport
9
+ include Sonos::Endpoint::Rendering
10
+ include Sonos::Endpoint::Device
11
+ include Sonos::Endpoint::ContentDirectory
12
+
13
+ MODEL_NUMBERS = ['S3', 'S5', 'ZP90']
14
+
15
+ attr_reader :icon
16
+
17
+ def self.model_numbers
18
+ MODEL_NUMBERS
19
+ end
20
+
21
+ def speaker?
22
+ true
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ module Sonos
2
+ module Device
3
+ end
4
+ end
5
+
6
+ require 'sonos/device/base'
7
+ require 'sonos/device/speaker'
8
+ require 'sonos/device/bridge'
@@ -1,6 +1,7 @@
1
1
  require 'socket'
2
2
  require 'ipaddr'
3
3
  require 'timeout'
4
+ require 'sonos/topology_node'
4
5
 
5
6
  #
6
7
  # Inspired by https://github.com/rahims/SoCo, https://github.com/turboladen/upnp,
@@ -18,17 +19,29 @@ module Sonos
18
19
  DEFAULT_TIMEOUT = 1
19
20
 
20
21
  attr_reader :timeout
22
+ attr_reader :first_device_ip
21
23
 
22
- def initialize(timeout = nil)
23
- @timeout = timeout || DEFAULT_TIMEOUT
24
-
24
+ def initialize(timeout = DEFAULT_TIMEOUT)
25
+ @timeout = timeout
25
26
  initialize_socket
26
27
  end
27
28
 
29
+ # Look for Sonos devices on the network and return the first IP address found
30
+ # @return [String] the IP address of the first Sonos device found
28
31
  def discover
29
32
  send_discovery_message
30
- result = listen_for_responses
31
- Sonos::Speaker.new(result) if result
33
+ @first_device_ip = listen_for_responses
34
+ end
35
+
36
+ # Find all of the Sonos devices on the network
37
+ # @return [Array] an array of TopologyNode objects
38
+ def topology
39
+ self.discover unless @first_device_ip
40
+
41
+ doc = Nokogiri::XML(open("http://#{@first_device_ip}:#{Sonos::PORT}/status/topology"))
42
+ doc.xpath('//ZonePlayers/ZonePlayer').map do |node|
43
+ TopologyNode.new(node)
44
+ end
32
45
  end
33
46
 
34
47
  private
@@ -1,6 +1,6 @@
1
1
  require 'uri'
2
2
 
3
- module Sonos::Endpoint::Transport
3
+ module Sonos::Endpoint::AVTransport
4
4
  TRANSPORT_ENDPOINT = '/MediaRenderer/AVTransport/Control'
5
5
  TRANSPORT_XMLNS = 'urn:schemas-upnp-org:service:AVTransport:1'
6
6
 
@@ -20,7 +20,7 @@ module Sonos::Endpoint::Transport
20
20
  title: doc.xpath('//dc:title').inner_text,
21
21
  artist: doc.xpath('//dc:creator').inner_text,
22
22
  album: doc.xpath('//upnp:album').inner_text,
23
- playlist_position: body[:track],
23
+ queue_position: body[:track],
24
24
  track_duration: body[:track_duration],
25
25
  current_position: body[:rel_time],
26
26
  uri: body[:track_uri],
@@ -58,6 +58,16 @@ module Sonos::Endpoint::Transport
58
58
  send_transport_message('Previous')
59
59
  end
60
60
 
61
+ # Seeks to a given timestamp in the current track
62
+ # @param [Fixnum] seconds
63
+ def seek(seconds = 0)
64
+ # Must be sent in the format of HH:MM:SS
65
+ timestamp = Time.at(seconds).utc.strftime('%H:%M:%S')
66
+
67
+ send_transport_message('Seek', "<Unit>REL_TIME</Unit><Target>#{timestamp}</Target>")
68
+
69
+ end
70
+
61
71
  # Clear the queue
62
72
  def clear_queue
63
73
  send_transport_message('RemoveAllTracksFromQueue')
@@ -65,30 +75,41 @@ module Sonos::Endpoint::Transport
65
75
 
66
76
  # Save queue
67
77
  def save_queue(title)
68
- name = 'SaveQueue'
69
- action = "#{TRANSPORT_XMLNS}##{name}"
70
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Title>#{title}</Title><ObjectID></ObjectID></u:#{name}>}
71
- transport_client.call(name, soap_action: action, message: message)
78
+ send_transport_message('SaveQueue', "<Title>#{title}</Title><ObjectID></ObjectID>")
79
+ end
80
+
81
+ # Join another speaker's group.
82
+ # Trying to call this on a stereo pair slave will fail.
83
+ def join(master)
84
+ set_av_transport_uri('x-rincon:' + master.uid.sub('uuid:', ''))
85
+ end
86
+
87
+ # Add another speaker to this group.
88
+ # Trying to call this on a stereo pair slave will fail.
89
+ def group(slave)
90
+ slave.join(self)
91
+ end
92
+
93
+ # Ungroup from its current group.
94
+ # Trying to call this on a stereo pair slave will fail.
95
+ def ungroup
96
+ send_transport_message('BecomeCoordinatorOfStandaloneGroup')
72
97
  end
73
98
 
74
99
  private
75
100
 
76
101
  # Play a stream.
77
102
  def set_av_transport_uri(uri)
78
- name = 'SetAVTransportURI'
79
- action = "#{TRANSPORT_XMLNS}##{name}"
80
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><CurrentURI>#{uri}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData></u:#{name}>}
81
- transport_client.call(name, soap_action: action, message: message)
82
- self.play
103
+ send_transport_message('SetAVTransportURI', "<CurrentURI>#{uri}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData>")
83
104
  end
84
105
 
85
106
  def transport_client
86
107
  @transport_client ||= Savon.client endpoint: "http://#{self.ip}:#{Sonos::PORT}#{TRANSPORT_ENDPOINT}", namespace: Sonos::NAMESPACE
87
108
  end
88
109
 
89
- def send_transport_message(name)
110
+ def send_transport_message(name, part = '<Speed>1</Speed>')
90
111
  action = "#{TRANSPORT_XMLNS}##{name}"
91
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Speed>1</Speed></u:#{name}>}
112
+ message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID>#{part}</u:#{name}>}
92
113
  transport_client.call(name, soap_action: action, message: message)
93
114
  end
94
115
  end
@@ -3,6 +3,7 @@ module Sonos::Endpoint::Rendering
3
3
  RENDERING_XMLNS = 'urn:schemas-upnp-org:service:RenderingControl:1'
4
4
 
5
5
  # Get the current volume.
6
+ # Fixed volume speakers will always return 100.
6
7
  # @return [Fixnum] the volume from 0 to 100
7
8
  def volume
8
9
  response = send_rendering_message('GetVolume')
@@ -10,6 +11,7 @@ module Sonos::Endpoint::Rendering
10
11
  end
11
12
 
12
13
  # Set the volume from 0 to 100.
14
+ # Trying to set the volume of a fixed volume speaker will fail.
13
15
  # @param [Fixnum] the desired volume from 0 to 100
14
16
  def volume=(value)
15
17
  send_rendering_message('SetVolume', value)
@@ -6,4 +6,4 @@ end
6
6
  require 'sonos/endpoint/content_directory'
7
7
  require 'sonos/endpoint/device'
8
8
  require 'sonos/endpoint/rendering'
9
- require 'sonos/endpoint/transport'
9
+ require 'sonos/endpoint/a_v_transport'
@@ -0,0 +1,48 @@
1
+ module Sonos
2
+ # Represents a Sonos group. A group can contain one or more speakers. All speakers in a group
3
+ # play the same music in sync.
4
+ class Group
5
+ # The master speaker in the group
6
+ attr_reader :master_speaker
7
+
8
+ # All other speakers in the group
9
+ attr_reader :slave_speakers
10
+
11
+ def initialize(master_speaker, slave_speakers)
12
+ @master_speaker = master_speaker
13
+ @slave_speakers = (slave_speakers or [])
14
+ end
15
+
16
+ # All of the speakers in the group
17
+ def speakers
18
+ [self.master_speaker] + self.slave_speakers
19
+ end
20
+
21
+ # Remove all speakers from the group
22
+ def disband
23
+ self.slave_speakers.each do |speaker|
24
+ speaker.ungroup
25
+ end
26
+ end
27
+
28
+ # Full group name
29
+ def name
30
+ self.speakers.collect(&:name).uniq.join(', ')
31
+ end
32
+
33
+ # Forward AVTransport methods to the master speaker
34
+ %w{now_playing pause stop next previous queue clear_queue}.each do |method|
35
+ define_method(method) do
36
+ self.master_speaker.send(method.to_sym)
37
+ end
38
+ end
39
+
40
+ def play(uri = nil)
41
+ self.master_speaker.play(uri)
42
+ end
43
+
44
+ def save_queue(name)
45
+ self.master_speaker.save_queue(name)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,59 @@
1
+ module Sonos
2
+ # The Sonos system. The root object to manage the collection of groups and devices. This is
3
+ # intended to be a singleton accessed from `Sonos.system`.
4
+ class System
5
+ attr_reader :topology
6
+ attr_reader :groups
7
+ attr_reader :devices
8
+
9
+ # Initialize the system
10
+ # @param [Array] the system topology. If this is nil, it will autodiscover.
11
+ def initialize(topology = Discovery.new.topology)
12
+ @topology = topology
13
+ @groups = []
14
+ @devices = @topology.collect(&:device)
15
+
16
+ construct_groups
17
+ end
18
+
19
+ # Returns all speakers
20
+ def speakers
21
+ @devices.select(&:speaker?)
22
+ end
23
+
24
+ # Pause all speakers
25
+ def pause_all
26
+ self.groups.each do |group|
27
+ group.pause
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def construct_groups
34
+ # Loop through all of the unique groups
35
+ @topology.collect(&:group).uniq.each do |group_uid|
36
+ master_uuid = group_uid.split(':').first
37
+ nodes = []
38
+ master = nil
39
+
40
+ @topology.each do |node|
41
+ # Select all of the nodes with this group uid
42
+ next unless node.group == group_uid
43
+
44
+ if node.uuid == master_uuid
45
+ master = node
46
+ else
47
+ nodes << node
48
+ end
49
+ end
50
+
51
+ # Skip this group if there are no nodes or master
52
+ next if nodes.empty? or master.nil?
53
+
54
+ # Add the group
55
+ @groups << Group.new(master.device, nodes.collect(&:device))
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ module Sonos
2
+ class TopologyNode
3
+ attr_accessor :name, :group, :coordinator, :location, :version, :uuid
4
+
5
+ def initialize(node)
6
+ node.attributes.each do |k, v|
7
+ self.send("#{k}=", v.inner_text) if self.respond_to?(k.to_sym)
8
+ end
9
+
10
+ self.name = node.inner_text
11
+ end
12
+
13
+ def ip
14
+ @ip ||= URI.parse(location).host
15
+ end
16
+
17
+ def device
18
+ @device || Device::Base.detect(ip)
19
+ end
20
+ end
21
+ end
data/lib/sonos/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Sonos
2
- VERSION = '0.2.1'
2
+ VERSION = '0.3.0'
3
3
  end
data/lib/sonos.rb CHANGED
@@ -1,16 +1,21 @@
1
1
  require 'sonos/version'
2
- require 'sonos/speaker'
2
+ require 'sonos/system'
3
3
  require 'sonos/discovery'
4
+ require 'sonos/device'
5
+ require 'sonos/group'
4
6
 
5
7
  module Sonos
6
8
  PORT = 1400
7
9
  NAMESPACE = 'http://www.sonos.com/Services/1.1'
8
10
 
9
- def self.Speaker(ip)
10
- Speaker.new(ip)
11
- end
11
+ # # Create a new speaker with it's IP address
12
+ # # @param [String] the speaker's IP address
13
+ # def self.speaker(ip)
14
+ # Device::Speaker.new(ip)
15
+ # end
12
16
 
13
- def self.discover
14
- Sonos::Discovery.new.discover
15
- end
17
+ # # Get the Sonos system
18
+ # def self.system
19
+ # @system ||= Sonos::System.new
20
+ # end
16
21
  end
data/sonos.gemspec CHANGED
@@ -11,12 +11,14 @@ Gem::Specification.new do |gem|
11
11
  gem.description = 'Control Sonos speakers with Ruby'
12
12
  gem.summary = gem.description
13
13
  gem.homepage = 'https://github.com/soffes/sonos'
14
+ gem.license = 'MIT'
14
15
 
15
16
  gem.files = `git ls-files`.split($/)
16
17
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
18
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
19
  gem.require_paths = ['lib']
19
20
 
21
+ gem.required_ruby_version = '>= 1.9.2'
20
22
  gem.add_dependency 'savon', '~> 2.0.2'
21
23
  gem.add_dependency 'nokogiri'
22
24
  gem.add_dependency 'thor'