sync_songs 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.
@@ -0,0 +1,107 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'grooveshark'
4
+ require_relative '../song_set'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: A set of Grooveshark songs.
9
+ class GroovesharkSet < SongSet
10
+
11
+ # Public: Creates a Grooveshark set by logging in to Grooveshark
12
+ # with the given user.
13
+ #
14
+ # user - A String naming a Grooveshark user.
15
+ # password - A String naming the password of the user.
16
+ #
17
+ # Raises Grooveshark::InvalidAuthentication if authentication
18
+ # fails.
19
+ # Raises SocketError if the network connection fails.
20
+ def initialize(user, password)
21
+ super()
22
+
23
+ # Setup a Grooveshark session.
24
+ @client = Grooveshark::Client.new
25
+ @session = @client.session
26
+
27
+ login(user, password)
28
+ end
29
+
30
+ # Public: Get the user's favorites from Grooveshark.
31
+ #
32
+ # Raises Grooveshark::GeneralError if the network connection fails.
33
+ #
34
+ # Returns self.
35
+ def favorites
36
+ @user.favorites.each { |s| add(Song.new(s.name, s.artist, s.album)) }
37
+ self
38
+ end
39
+
40
+ # Public: Add the songs in the given set to the user's favorite
41
+ # on Grooveshark.
42
+ #
43
+ # other - A SongSet to add from.
44
+ #
45
+ # Raises Grooveshark::GeneralError if the network connection fails.
46
+ #
47
+ # Returns an array of the songs that was added.
48
+ def addToFavorites(other)
49
+ songsAdded = []
50
+
51
+ other.each do |s|
52
+ songsAdded << s if s.id && @user.add_favorite(s.id)
53
+ end
54
+
55
+ songsAdded
56
+ end
57
+
58
+ # Public: Searches for the given song set at Grooveshark.
59
+ #
60
+ # other - SongSet to search for.
61
+ # strict_search - True if search should be strict (default: true).
62
+ #
63
+ # Raises Grooveshark::GeneralError if the network connection
64
+ # fails.
65
+ # Raises Grooveshark::ApiError if token is invalid.
66
+ #
67
+ # Returns a SongSet.
68
+ def search(other, strict_search = true)
69
+ result = SongSet.new
70
+
71
+ # Search for songs that are not already in this set and return
72
+ # them if they are sufficiently similar.
73
+ other.each do |song|
74
+ @client.search_songs(song.to_search_term).each do |s|
75
+ other = Song.new(s.name, s.artist, s.album,
76
+ Float(s.duration), s.id)
77
+
78
+ if strict_search
79
+ next unless song.eql?(other)
80
+ else
81
+ next unless song.similar?(other)
82
+ end
83
+ result << other
84
+ end
85
+ end
86
+ result
87
+ end
88
+
89
+ private
90
+
91
+ # Internal: Tries to login to Grooveshark with the given user.
92
+ #
93
+ # user - A String naming a Grooveshark user.
94
+ # password - A String naming the password of the user.
95
+ #
96
+ # Raises Grooveshark::InvalidAuthentication if authentication
97
+ # fails.
98
+ def login(user, password)
99
+ @user = @client.login(user, password)
100
+ rescue Grooveshark::InvalidAuthentication => e
101
+ raise Grooveshark::InvalidAuthentication, "#{e.message} An "\
102
+ 'authenticated user is required for getting data from '\
103
+ 'Grooveshark'
104
+ raise
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,46 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'highline/import'
4
+ require 'launchy'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: Command-line interface for a Last.fm set of songs.
9
+ class LastfmCLI
10
+
11
+ # Public: Creates a CLI.
12
+ #
13
+ # controller - A Controller for a Last.fm set of songs.
14
+ # ui - General user interface to use.
15
+ def initialize(controller, ui)
16
+ @controller = controller
17
+ @ui = ui
18
+ end
19
+
20
+ # Public: Asks for a String naming a Last.fm API key and returns
21
+ # it.
22
+ def apiKey
23
+ ask("Last.fm API key for #{@controller.user}? ") { |q| q.echo = false }
24
+ end
25
+
26
+ # Public: Asks for a String naming a Last.fm API key and returns
27
+ # it.
28
+ def apiSecret
29
+ ask('Last.fm API secret for '\
30
+ "#{@controller.user}? ") { |q| q.echo = false }
31
+ end
32
+
33
+ # Public: Asks the user to authorize this tool with Last.fm and
34
+ # wait for input.
35
+ #
36
+ # url - A String naming a URL to use for authorization.
37
+ def authorize(url)
38
+ Launchy.open(url)
39
+ agree('A page asking for authorization with Last.fm should be '\
40
+ 'open in your web browser. You need to approve it '\
41
+ 'before proceeding. Continue? (y/n) ') do |q|
42
+ q.responses[:not_valid] = 'Enter y for yes or n for no'
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require_relative 'lastfm_cli'
4
+ require_relative 'lastfm_set'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: Controller for a Last.fm set of songs.
9
+ class LastfmController < ServiceController
10
+
11
+ # Public: Hash of types of services associated with what they
12
+ # support.
13
+ SERVICES = {loved: :rw}
14
+
15
+ # Public: Creates a controller.
16
+ #
17
+ # user - A String naming the user name or the file path for the
18
+ # service.
19
+ # name - A String naming the name of the service.
20
+ # type - A String naming the service type.
21
+ # ui - General user interface to use.
22
+ def initialize(user, name, type, ui)
23
+ super(user, name, type, ui)
24
+
25
+ @service_ui = LastfmCLI.new(self, @ui)
26
+ @set = LastfmSet.new(@service_ui.apiKey,
27
+ @service_ui.apiSecret,
28
+ @user)
29
+ end
30
+
31
+ # Public: Wrapper for Last.fm loved songs.
32
+ def loved
33
+ @set.loved
34
+ rescue ArgumentError, EOFError, Lastfm::ApiError, SocketError, Timeout::Error => e
35
+ @ui.fail("Failed to get #{type} from #{name} #{user}\n"\
36
+ "#{e.message.strip}", 1, e)
37
+ end
38
+
39
+ # Public: Wrapper for adding to Last.fm loved songs. Authorizes
40
+ # the session before adding songs.
41
+ #
42
+ # other - A SongSet to add from.
43
+ def addToLoved(other)
44
+ # TODO Store token somewhere instead and only call URL if there is no
45
+ # stored token.
46
+ authorized = nil
47
+
48
+ @mutex.synchronize do
49
+ authorized = @service_ui.authorize(@set.authorizeURL)
50
+ end
51
+
52
+ if authorized
53
+ begin
54
+ @set.authorizeSession
55
+ @set.addToLoved(other)
56
+ rescue Lastfm::ApiError, SocketError => e
57
+ @ui.fail("Failed to add #{type} to #{name} #{user}\n"\
58
+ "#{e.message.strip}", 1, e)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Public: Wrapper for searching for the given song set at Last.fm.
64
+ #
65
+ # other - SongSet to search for.
66
+ # limit - Maximum limit for search results (default:
67
+ # @set.limit).
68
+ # strict_search - True if search should be strict (default: true).
69
+ #
70
+ # Returns a SongSet.
71
+ def search(other, limit = @set.limit, strict_search = true)
72
+ @set.search(other, limit, strict_search)
73
+ rescue ArgumentError, EOFError, Errno::EINVAL, SocketError,
74
+ Timeout::Error => e
75
+ @ui.fail("Failed to search #{name} #{user}\n#{e.message.strip}", 1, e)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,177 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'lastfm'
4
+ require_relative '../song_set'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: A set of Grooveshark songs.
9
+ class LastfmSet < SongSet
10
+
11
+ # Public: Default limit for API calls.
12
+ DEFAULT_LIMIT = 1_000_000
13
+
14
+ attr_reader :limit
15
+
16
+ # Public: Creates a Last.fm set by logging in to Last.fm with the
17
+ # specified user.
18
+ #
19
+ # api_key - Last.fm API key.
20
+ # api_secret - Last.fm secret for API key.
21
+ # username - The username of the Last.fm user.
22
+ # limit - The maximum number of results from calls (default:
23
+ # DEFAULT_LIMIT).
24
+ def initialize(api_key, api_secret, username = nil, limit = DEFAULT_LIMIT)
25
+ super()
26
+ @api_key = api_key
27
+ @username = username
28
+ @lastfm = Lastfm.new(api_key, api_secret)
29
+ @limit = limit
30
+ end
31
+
32
+ # Public: Get the user's loved songs from Last.fm. The reason this
33
+ # takes ages to complete is that it first has to search tracks to
34
+ # find them and then get info for each found track to be able to
35
+ # get the album title.
36
+ #
37
+ # username - The username of the user to authenticate (default:
38
+ # @username).
39
+ #
40
+ # limit - The maximum number of loved tracks to get (default:
41
+ # @limit).
42
+ #
43
+ # Raises ArgumentError from xml-simple some reason.
44
+ # Raises EOFError when end of file is reached.
45
+ # Raises Lastfm::ApiError if the username is invalid or there is a
46
+ # temporary error.
47
+ # Raises SocketError if the connection fails.
48
+ # Raises Timeout::Error if the connection fails.
49
+ #
50
+ # Returns self.
51
+ def loved(username = @username, limit = @limit)
52
+ lov = @lastfm.user.get_loved_tracks(user: username,
53
+ api_key: @api_key,
54
+ limit: limit)
55
+
56
+ if lov # Remove if API is fixed.
57
+ lov = [lov] unless lov.is_a?(Array) # Remove if API is fixed.
58
+
59
+ lov.each do |l|
60
+
61
+ # Get metadata for loved track.
62
+ s = @lastfm.track.get_info(track: l['name'],
63
+ artist: l['artist']['name'])
64
+
65
+ add(Song.new(s['name'], s['artist']['name'],
66
+ # Not all Last.fm tracks belong to an album.
67
+ s.key?('album') ? s['album']['title'] : nil,
68
+ Float(s['duration']) / 1_000, s['id']))
69
+ end
70
+ end
71
+
72
+ self
73
+ end
74
+
75
+ # Public: Add the songs in the given set to the given user's loved
76
+ # songs on Last.fm. This method requires an authorized session
77
+ # which is gotten by getting the user to authorize via the url
78
+ # given by authorizeURL and then running authorize.
79
+ #
80
+ # other - A SongSet to add from.
81
+ #
82
+ # Raises Lastfm::ApiError if the Last.fm token has not been
83
+ # authorized or if the song is not recognized.
84
+ # Raises SocketError if the network connection fails.
85
+ #
86
+ # Returns an array of the songs that was added.
87
+ def addToLoved(other)
88
+ songsAdded = []
89
+
90
+ if @lastfm.session
91
+ other.each do |s|
92
+ songsAdded << s if @lastfm.track.love(track: s.name,
93
+ artist: s.artist)
94
+ end
95
+ end
96
+
97
+ songsAdded
98
+ end
99
+
100
+ # Public: Searches for the given song set at Last.fm. The reason
101
+ # this takes ages to complete is that it first has to search
102
+ # tracks to find them and then get info for each found track to be
103
+ # able to get the album title.
104
+ #
105
+ # other - SongSet to search for.
106
+ # limit - Maximum limit for search results (default:
107
+ # @limit).
108
+ # strict_search - True if search should be strict (default: true).
109
+ #
110
+ # Raises ArgumentError from xml-simple some reason.
111
+ # Raises EOFError when end of file is reached.
112
+ # Raises Errno::EINVAL if the network connection fails.
113
+ # Raises SocketError if the network connection fails.
114
+ # Raises Timeout::Error if the network connection fails.
115
+ #
116
+ # Returns a SongSet.
117
+ def search(other, limit = @limit, strict_search = true)
118
+ result = SongSet.new
119
+
120
+ # Search for songs and return them if they are sufficiently
121
+ # similar.
122
+ other.each do |song|
123
+ # The optional parameter artist for track.search does not seem
124
+ # to work so it is not used.
125
+ search_result = @lastfm.track.search(track: song.to_search_term,
126
+ limit: limit)['results']['trackmatches']['track'].compact
127
+
128
+ found_songs = []
129
+
130
+ search_result.each do |r|
131
+ found_songs << @lastfm.track.get_info(track: r['name'],
132
+ artist: r['artist'])
133
+ end
134
+
135
+ unless found_songs.empty?
136
+ found_songs.each do |f|
137
+ other = Song.new(f['name'], f['artist']['name'],
138
+ # Not all Last.fm tracks belong to an album.
139
+ f.key?('album') ? f['album']['title'] : nil,
140
+ Float(f['duration']) / 1_000, f['id'])
141
+ if strict_search
142
+ next unless song.eql?(other)
143
+ else
144
+ next unless song.similar?(other)
145
+ end
146
+ result << other
147
+ end
148
+ end
149
+ end
150
+ result
151
+ end
152
+
153
+ # Public: Return an URL for authorizing a Last.fm session.
154
+ #
155
+ # Raises SocketError if the network connection fails.
156
+ def authorizeURL
157
+ @token = @lastfm.auth.get_token
158
+ "http://www.last.fm/api/auth/?api_key=#@api_key&token=#@token"
159
+ end
160
+
161
+
162
+ # Public: Authorize a Last.fm session (needed for certain calls to
163
+ # Last.fm, such as addToLoved). Get the user to authorize via the
164
+ # URL returned by authorizeURL before calling this method.
165
+ #
166
+ # Raises SocketError if the network connection fails.
167
+ def authorizeSession
168
+ if @token
169
+ @lastfm.session = @lastfm.auth.get_session(token: @token)['key']
170
+ else
171
+ fail StandardError, "Before calling #{__method__} a token "\
172
+ 'must be authorized, e.g. by calling authorizeURL and '\
173
+ 'getting the user to authorize via that URL'
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,55 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require_relative '../song_set'
4
+
5
+ # Public: Classes for syncing sets of songs.
6
+ module SyncSongs
7
+ # Public: Controller for a service.
8
+ class ServiceController
9
+
10
+ attr_reader :user, :name, :type, :set, :ui
11
+ attr_accessor :action, :strict_search, :interactive, :search_result,
12
+ :songs_to_add, :added_songs
13
+
14
+ # Public: Create a service controller.
15
+ #
16
+ # controller - The main controller.
17
+ # user - A String naming the user name or the file path for
18
+ # the service.
19
+ # name - A String naming the name of the service.
20
+ # type - A String naming the service type.
21
+ def initialize(controller, user, name, type)
22
+ @controller = controller
23
+ @user = user
24
+ @name = name
25
+ @type = type
26
+ @action = action
27
+ @ui = @controller.ui # General user interface to use.
28
+ @mutex = @controller.mutex
29
+
30
+ @search_result = SongSet.new
31
+ @songs_to_add = SongSet.new
32
+ end
33
+
34
+ # Public: Returns true if this service controller is equal to the
35
+ # compared service controller. This method and hash are defined so
36
+ # that Sets of service controllers behave reasonably, i.e. service
37
+ # controller for the same user/file, name and type should be
38
+ # treated as equal.
39
+ #
40
+ # other - Service controller that this song is compared with.
41
+ def eql?(other)
42
+ user.casecmp(other.user) == 0 &&
43
+ name.casecmp(other.name) == 0 &&
44
+ type.casecmp(other.type) == 0
45
+ end
46
+
47
+ # Public: Makes a hash value for this object and returns it. This
48
+ # method and eql? are defined so that Sets of service controllers
49
+ # behave reasonably, i.e. service controller for the same
50
+ # user/file, name and type should be treated as equal.
51
+ def hash
52
+ [user, name, type].join('').downcase.hash
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,99 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # Public: Classes for syncing sets of songs.
4
+ module SyncSongs
5
+ # Public: Stores a song.
6
+ class Song
7
+ include Comparable
8
+
9
+ # Public: Returns the name of the song and the artist performing the
10
+ # song.
11
+ attr_reader :name, :artist, :album, :duration, :id
12
+
13
+ # Public: Creates a song. Leading and trailing whitespace is
14
+ # removed as it has no semantic significance for songs.
15
+ #
16
+ # name - The String name of the song.
17
+ # artist - The String artist performing the song.
18
+ # album - The String album the song is found on (default: nil).
19
+ # duration - The Float duration of the song in seconds (default:
20
+ # nil).
21
+ # id - An String id of for the song (default: nil).
22
+ # Is service relative.
23
+ #
24
+ # Raises ArgumentError if the artist or the name is empty.
25
+ def initialize(name, artist, album = nil, duration = nil, id = nil)
26
+ @name = name.strip
27
+ @artist = artist.strip
28
+ @album = album.strip if album
29
+ @duration = Float(duration) if duration
30
+ @id = id
31
+
32
+ if @name.empty? || @artist.empty?
33
+ fail ArgumentError, 'Songs must have a non-empty name and artist'
34
+ end
35
+ end
36
+
37
+ # Public: Comparison -- returns -1 if other song is greater than,
38
+ # 0 if other song equal to and +1 if other song is less than this
39
+ # song.
40
+ #
41
+ # other - Song that this song is compared with.
42
+ def <=>(other)
43
+ comp = name.casecmp(other.name)
44
+
45
+ comp = artist.casecmp(other.artist) if comp == 0
46
+
47
+ if comp == 0 && album && other.album
48
+ comp = album.casecmp(other.album)
49
+ end
50
+
51
+ comp
52
+ end
53
+
54
+ # Public: Returns true if this song is equal to the compared song.
55
+ #
56
+ # other - Song that this song is compared with.
57
+ def eql?(other)
58
+ (self <=> other) == 0
59
+ end
60
+
61
+ # Public: Returns true if this song includes the other song.
62
+ #
63
+ # other - Song that this song is compared with.
64
+ def include?(other)
65
+ name.downcase.include?(other.name.downcase) &&
66
+ artist.downcase.include?(other.artist.downcase)
67
+ end
68
+
69
+ # Public: Returns true if this song is similar to the compared
70
+ # song.
71
+ #
72
+ # other - Song that this song is compared with.
73
+ def similar?(other)
74
+ # Since the other song is more probably a song from a search in
75
+ # a big database with many versions of every song the following
76
+ # test order should perform better.
77
+ other.include?(self) || include?(other)
78
+ end
79
+
80
+ # Public: Makes a hash value for this object and returns it.
81
+ def hash
82
+ [artist, name, album].compact.join('').downcase.hash
83
+ end
84
+
85
+ # Public: Returns the song formatted as a string.
86
+ def to_s
87
+ s = [artist, name, album].compact
88
+ s << Time.at(duration).utc.strftime('%H:%M:%S') if duration
89
+ s.join(' - ')
90
+ end
91
+
92
+ # Public: Returns the song formatted as appropriately for use in a
93
+ # search query.
94
+ def to_search_term
95
+ # When including album search on Last.fm barely finds anything.
96
+ [artist, name].compact.join(' ')
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,30 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'delegate'
4
+ require 'set'
5
+ require_relative 'song'
6
+
7
+ # Public: Classes for syncing sets of songs.
8
+ module SyncSongs
9
+ # Public: A set of songs.
10
+ class SongSet < SimpleDelegator
11
+
12
+ # Public: Creates a new set.
13
+ #
14
+ # *songs - If songs are provided they are added to the set
15
+ # (default: nil).
16
+ def initialize(*songs)
17
+ @songs = Set.new
18
+ super(@songs)
19
+ songs.each { |song| @songs << song } if songs
20
+ end
21
+
22
+ # Public: Returns songs that are in the given list but not in this
23
+ # list, i.e. songs that are exclusive to the given list.
24
+ #
25
+ # other - SongList to compare this list to.
26
+ def exclusiveTo(other)
27
+ other - @songs
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # Public: Classes for syncing sets of songs.
4
+ module SyncSongs
5
+ VERSION = '0.0.1'
6
+ end
data/lib/sync_songs.rb ADDED
@@ -0,0 +1,27 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'rubygems'
4
+ require 'bundler/setup'
5
+
6
+ PATH = './sync_songs/'
7
+ SERVICES_PATH = "#{PATH}services/"
8
+
9
+ # Load the library.
10
+ require_relative "#{PATH}cli"
11
+ require_relative "#{PATH}controller"
12
+ require_relative "#{PATH}version"
13
+
14
+ require_relative "#{SERVICES_PATH}service_controller"
15
+
16
+ # Load sub classes of ServiceController.
17
+ require_relative "#{SERVICES_PATH}csv_controller"
18
+ require_relative "#{SERVICES_PATH}grooveshark_controller"
19
+ require_relative "#{SERVICES_PATH}lastfm_controller"
20
+
21
+ # Internal: A sync direction.
22
+ Struct.new('Direction',
23
+ # Internal: An array of two keys two services to sync
24
+ # between.
25
+ :services,
26
+ # Internal: The direction to sync in.
27
+ :direction)
data/plan.org ADDED
@@ -0,0 +1,17 @@
1
+ # -*- mode:org; indent-tabs-mode:nil; tab-width:2 -*-
2
+ #+title: Plan
3
+
4
+ * TODO Plan
5
+ - Use https://gist.github.com/fnichol/1912050
6
+ - Remove =lov = [lov] unless lov.is_a?(Array)= from last_fm_set.rb if https://github.com/youpy/ruby-lastfm/issues/52 is fixed.
7
+ - Go through all documentation and fix it.
8
+ - Fix all warnings upstream.
9
+ - Add tests for all new things.
10
+ - Add tests for songs with album.
11
+ - Use watir for testing last.fm auth.
12
+ - Add examples to documentation as in the Tomdoc specification.
13
+ - Add argument types to documentation, from Tomdoc: "The expected type (or types) of each argument SHOULD be clearly indicated in the explanation. When you specify a type, use the proper classname of the type (for instance, use 'String' instead of 'string' to refer to a String type)."
14
+ - Make singleton classes that can carry API keys and passwords for a particular user so that one can check if such an instance is running and use it rather than asking the user for the same password again.
15
+ - Consider adding support for the following: librefm, rhythmbox, gogoyoko, jamendo.
16
+ - Use YAML.dump and lib tmpdir to store Last.fm token?
17
+ - Integrate with Travis CI to automate tests.
@@ -0,0 +1,25 @@
1
+ # -*- coding: utf-8; mode: ruby -*-
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'sync_songs/version'
6
+
7
+ Gem::Specification.new do |gem|
8
+ gem.name = 'sync_songs'
9
+ gem.version = SyncSongs::VERSION
10
+ gem.authors = ['Sleft']
11
+ gem.email = ['fte08eas@student.lu.se']
12
+ gem.description = 'Sync sets of songs'
13
+ gem.summary = 'SyncSongs'
14
+ gem.homepage = 'https://github.com/Sleft/sync_songs'
15
+
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
+ gem.require_paths = ['lib']
20
+ gem.add_runtime_dependency 'grooveshark', ['~>0.2.7']
21
+ gem.add_runtime_dependency 'highline', ['~>1.6.16']
22
+ gem.add_runtime_dependency 'lastfm', ['~>1.17.0']
23
+ gem.add_runtime_dependency 'launchy', ['~>2.2.0']
24
+ gem.add_runtime_dependency 'thor', ['~>0.18.1']
25
+ end