sync_songs 0.0.1

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