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,435 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ # Public: Classes for syncing sets of songs.
4
+ module SyncSongs
5
+ # Public: Controls syncing and diffing of sets of songs.
6
+ class Controller
7
+ # Public: Directions for sync between two services.
8
+ DIRECTIONS = {:'<' => 'write from right to left',
9
+ :'>' => 'write from left to right',
10
+ :'=' => 'write both ways',
11
+ :'|' => 'do not write in any way'}
12
+ # Public: The form for the input parameter of the constructor.
13
+ INPUT_FORM = '[user|file path]:service:type'
14
+
15
+ attr_reader :ui, :mutex
16
+
17
+ # Public: Creates a controller.
18
+ #
19
+ # ui - The user interface to use.
20
+ # input - A Set of Strings representing users or file paths paired
21
+ # with a service and a type on the form user:service:type,
22
+ # e.g. "user1:grooveshark:favorites",
23
+ # "user1:lastfm:loved" (default: nil).
24
+ def initialize(ui, input = nil)
25
+ @ui = ui
26
+ @input = input
27
+
28
+ @ui.possible_directions = DIRECTIONS
29
+
30
+ parseInput if @input
31
+
32
+ # Services to sync between. Stored in a hash so that one one may
33
+ # check if a service is already stored and if that is the case
34
+ # get a key to it. That way @directions can store keys to
35
+ # @services.
36
+ @services = {}
37
+
38
+ # A Set of Struct::Direction (see sync_songs.rb). Each direction
39
+ # should be unique therefore they are stored in a set.
40
+ @directions = Set.new
41
+
42
+ # For synchronization of access to shared data when using
43
+ # threads, e.g. of writing of search results.
44
+ @mutex = Mutex.new
45
+ end
46
+
47
+ # Public: Syncs the song sets of the input services.
48
+ def sync
49
+ @ui.verboseMessage('Preparing to sync song sets')
50
+
51
+ @ui.message('Enter direction to write in')
52
+ prepareServices
53
+
54
+ searchPreferences
55
+ addPreferences
56
+
57
+ getData
58
+ addData
59
+
60
+ @ui.verboseMessage('Success')
61
+ end
62
+
63
+ # Public: Diffs the song sets of the input services.
64
+ def diff
65
+ @ui.verboseMessage('Preparing to diff song sets')
66
+
67
+ @ui.message('Enter direction to diff in')
68
+ prepareServices
69
+
70
+ searchPreferences
71
+
72
+ getData
73
+ showDifference
74
+
75
+ @ui.verboseMessage('Success')
76
+ end
77
+
78
+ # Public: Shows supported services.
79
+ def showSupportedServices
80
+ @ui.supportedServices
81
+ end
82
+
83
+
84
+ # Public: Returns a hash of services associated with their
85
+ # supported types associated with supported action.
86
+ #
87
+ # Examples
88
+ #
89
+ # Controller.supportedServices
90
+ # # => {:grooveshark=>{:favorites=>:rw}, :lastfm=>{:loved=>:rw,
91
+ # :favorites=>:rw}}
92
+ def self.supportedServices
93
+ services = {}
94
+
95
+ # Get the classes that extends ServiceController.
96
+ classes = ObjectSpace.each_object(Class).select { |c| c < ServiceController }
97
+
98
+ # Associate the class name with its supported services.
99
+ classes.each do |c|
100
+ class_name = c.name.split('::').last
101
+
102
+ # Only accept classes that ends with 'Controller'.
103
+ if match = class_name.match(/(\w+)Controller\Z/)
104
+ services[match[1].downcase.to_sym] = c::SERVICES
105
+ end
106
+ end
107
+
108
+ services
109
+ end
110
+
111
+ private
112
+
113
+ # Internal: Prepare services for handling.
114
+ def prepareServices
115
+ # Get directions to sync in.
116
+ getDirections
117
+
118
+ # Translate directions to be able to check support.
119
+ directionsToServices
120
+
121
+ checkSupport
122
+ end
123
+
124
+ # Internal: Checks if the action and the type and for the input
125
+ # service are supported, e.g. if reading (action) from favorites
126
+ # (type) at Grooveshark (service) is supported. Fails if something
127
+ # is not supported.
128
+ def checkSupport
129
+ supp_services = Controller.supportedServices
130
+
131
+ @services.each do |_, s|
132
+ msg = ' is not supported'
133
+
134
+ # Is the service supported?
135
+ msg = "#{s.name}#{msg}"
136
+ @ui.fail(msg, 1) unless supp_services.key?(s.name)
137
+
138
+ # Is the type supported?
139
+ supp_types = supp_services[s.name]
140
+ msg = "#{s.type} for #{msg}"
141
+ @ui.fail(msg, 1) unless supp_types.key?(s.type)
142
+
143
+ # Is the action supported?
144
+ msg = "#{s.action} to #{msg}"
145
+ supp_action = supp_types[s.type]
146
+ @ui.fail(msg, 1) unless supp_action == s.action || supp_action == :rw
147
+ end
148
+ end
149
+
150
+ # Internal: Gets the data for each service.
151
+ def getData
152
+ @ui.message('Getting data. This might take a while.')
153
+ getCurrentData
154
+ getSearchResults
155
+ getDataToAdd
156
+ end
157
+
158
+ # Internal: Gets the current data from each service, e.g. the
159
+ # current favorites from Grooveshark and Last.fm. The data is
160
+ # stored in each service controller. Exceptions for services
161
+ # should not be handled here but in each service controller.
162
+ def getCurrentData
163
+ threads = []
164
+
165
+ @services.each do |_, service|
166
+ threads << Thread.new(service) do |s|
167
+ @mutex.synchronize do
168
+ @ui.verboseMessage("Getting #{s.type} from #{s.user} #{s.name}...")
169
+ end
170
+ s.send(s.type)
171
+ @mutex.synchronize do
172
+ @ui.verboseMessage("Got #{s.set.size} songs from "\
173
+ "#{s.user} #{s.name} #{s.type}")
174
+ end
175
+ end
176
+ end
177
+
178
+ threads.each { |t| t.join }
179
+ end
180
+
181
+ # Internal: Gets the search result from the services that should
182
+ # be synced to. The data is stored in the search_result of each
183
+ # service controller.
184
+ def getSearchResults
185
+ threads = []
186
+
187
+ @directions.each do |direction|
188
+ if direction.direction == :'<' || direction.direction == :'='
189
+ threads << Thread.new(direction) do |d|
190
+ search(@services[d.services.first],
191
+ @services[d.services.last])
192
+ end
193
+ end
194
+ if direction.direction == :'>' || direction.direction == :'='
195
+ threads << Thread.new(direction) do |d|
196
+ search(@services[d.services.last],
197
+ @services[d.services.first])
198
+ end
199
+ end
200
+ end
201
+
202
+ threads.each { |t| t.join }
203
+ end
204
+
205
+ # Internal: Searches for songs that are exclusive to service2 at
206
+ # service1, e.g. gets the search result on Grooveshark of the
207
+ # songs that are exclusive to Last.fm. Exceptions for services
208
+ # should not be handled here but in each service controller.
209
+ #
210
+ # s1 - Key to a service to search at.
211
+ # s2 - Key to a service with songs to search for.
212
+ def search(s1, s2)
213
+ @mutex.synchronize do
214
+ @ui.verboseMessage("Searching at #{s1.name} for songs from "\
215
+ "#{s2.user} #{s2.name} #{s2.type}...")
216
+ end
217
+ result = s1.search(s1.set.exclusiveTo(s2.set),
218
+ s1.strict_search)
219
+
220
+ # Access to search result should be synchronized.
221
+ @mutex.synchronize do
222
+ s1.search_result.merge(result)
223
+ @ui.verboseMessage("Found #{s1.search_result.size} "\
224
+ "candidates for #{s2.user} #{s2.name} "\
225
+ "#{s2.type} at #{s1.name}")
226
+ end
227
+ end
228
+
229
+ # Internal: Ask for preferences of options for adding songs.
230
+ def addPreferences
231
+ @services.each do |_, s|
232
+ # Add preferences are only relevant when one is writing to a
233
+ # service.
234
+ if s.action == :w || s.action == :rw
235
+ s.interactive = @ui.interactive("#{s.user} #{s.name} #{s.type}")
236
+ end
237
+ end
238
+ end
239
+
240
+ # Internal: Ask for preferences of options for searching for
241
+ # songs.
242
+ def searchPreferences
243
+ @services.each do |_, s|
244
+ # Search preferences are only relevant when one is writing to
245
+ # a service.
246
+ if s.action == :w || s.action == :rw
247
+ s.strict_search = @ui.strict_search("#{s.user} #{s.name} #{s.type}")
248
+ end
249
+ end
250
+ end
251
+
252
+ # Internal: Gets data to be synced to each service.
253
+ def getDataToAdd
254
+ @services.each do |_, s|
255
+ if s.interactive # Add songs interactively
256
+ interactiveAdd(s)
257
+ else # or add them all without asking.
258
+ s.songs_to_add = s.search_result
259
+ end
260
+ end
261
+ end
262
+
263
+ # Internal: Adds the data to be synced to each service.
264
+ def addData
265
+ @ui.message('Adding data. This might take a while.')
266
+ threads = []
267
+
268
+ @services.each do |_, service|
269
+ threads << Thread.new(service) do |s|
270
+ if s.songs_to_add && !s.songs_to_add.empty?
271
+ @mutex.synchronize do
272
+ @ui.verboseMessage("Adding #{s.type} to #{s.name} #{s.user}...")
273
+ end
274
+ addSongs(s)
275
+ @mutex.synchronize do
276
+ @ui.verboseMessage("Finished adding #{s.type} to "\
277
+ "#{s.name} #{s.user}")
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+ threads.each { |t| t.join }
284
+
285
+ sayAddedSongs
286
+ end
287
+
288
+ # Internal: Adds songs to the given service. Exceptions should not
289
+ # be handled here but in each service controller.
290
+ #
291
+ # s - The service to add songs to.
292
+ def addSongs(s)
293
+ s.added_songs = s.send("addTo#{s.type.capitalize}",
294
+ s.songs_to_add)
295
+ end
296
+
297
+ # Internal: For each found missing song in a service, ask whether
298
+ # to add it to that service.
299
+ #
300
+ # s - The service to add songs to.
301
+ def interactiveAdd(s)
302
+ service_desc = "#{s.user} #{s.name} #{s.type}"
303
+
304
+ if s.search_result.size > 0
305
+ @ui.emMessage('Choose whether to add the following '\
306
+ "#{s.search_result.size} songs to "\
307
+ "#{service_desc}:")
308
+ s.songs_to_add = @ui.askAddSongs(service_desc,
309
+ s.search_result)
310
+ end
311
+ end
312
+
313
+ # Internal: Shows the difference for the services.
314
+ def showDifference
315
+ @services.each do |_, s|
316
+ if s.songs_to_add
317
+ @ui.emMessage("#{s.songs_to_add.size} songs missing on "\
318
+ "#{s.user} #{s.name} #{s.type}:")
319
+ s.songs_to_add.each do |song|
320
+ @ui.message(song)
321
+ end
322
+ end
323
+ end
324
+ end
325
+
326
+ # Internal: Store directions to sync in each service controller.
327
+ def directionsToServices
328
+ @directions.each do |d|
329
+ support = []
330
+
331
+ case d.direction
332
+ when :'<' then support << :w << :r
333
+ when :'>' then support << :r << :w
334
+ when :'=' then support << :rw << :rw
335
+ end
336
+
337
+ d.services.each do |s|
338
+ @services[s].action = support.shift
339
+ end
340
+ end
341
+ end
342
+
343
+ # Internal: Shows a message of which songs that was added.
344
+ def sayAddedSongs
345
+ counts_msg = []
346
+ v_msg = []
347
+
348
+ @services.each do |_, s|
349
+ if s.added_songs
350
+ counts_msg << "Added #{s.added_songs.size} songs to "\
351
+ "#{s.user} #{s.name} #{s.type}"
352
+ v_msg << s.added_songs.map { |song| "Added #{song} to #{s.user} #{s.name} #{s.type}" }
353
+ end
354
+ end
355
+
356
+ if v_msg.empty? && counts_msg.empty?
357
+ @ui.message('Nothing done')
358
+ else
359
+ @ui.verboseMessage(v_msg)
360
+ @ui.message(counts_msg)
361
+ end
362
+ end
363
+
364
+ # Internal: Parse the input from an array with elements of the
365
+ # form user@service:type to a set of arrays of the form [[:user1,
366
+ # :service, :type], [:user1, :service, :type]] and complain if the
367
+ # input is bad.
368
+ def parseInput
369
+ parsed_input = Set.new
370
+
371
+ @input.each do |s|
372
+ # Split the delimited input except where the delimiter is
373
+ # escaped and remove the escaping characters.
374
+ split_input = s.split(/(?<!\\):/).map { |e| e.gsub(/\\:/, ':').to_sym }
375
+
376
+ @ui.fail('You must supply services on the form '\
377
+ "#{INPUT_FORM}", 2) if split_input.size != 3
378
+
379
+ parsed_input << split_input
380
+ end
381
+
382
+ @ui.fail('You must supply at least two distinct'\
383
+ ' services.', 2) if parsed_input.size < 2
384
+
385
+ @input = parsed_input
386
+ end
387
+
388
+ # Internal: Get directions to sync in and store them and the
389
+ # related services.
390
+ def getDirections
391
+ questions = []
392
+
393
+ # Get directions for every possible combination of services.
394
+ @input.to_a.combination(2) do |c|
395
+ questions << [c.first, '?', c.last]
396
+ end
397
+
398
+ answers = @ui.askDirections(questions)
399
+
400
+ # Store the given answer.
401
+ answers.each do |a|
402
+ @directions << Struct::Direction.new([storeService(a.first),
403
+ storeService(a.last)],
404
+ a[1].to_sym)
405
+ end
406
+ end
407
+
408
+ # Internal: Initializes and stores a service in @services and
409
+ # returns its key.
410
+ #
411
+ # s - An array containing user/file name, type and action.
412
+ #
413
+ # Returns a reference to the stored service.
414
+ def storeService(s)
415
+ key = s.join.to_sym
416
+
417
+ # Only initialize the service if it is not already initialized.
418
+ @services[key] = initializeService(s) if not @services[key]
419
+
420
+ key
421
+ end
422
+
423
+ # Internal: Try to initialize the given service and return a
424
+ # reference to it.
425
+ #
426
+ # s - An array containing user/file name, type and action.
427
+ #
428
+ # Returns a reference to a service.
429
+ def initializeService(s)
430
+ SyncSongs.const_get("#{s[1].capitalize}Controller").new(self, *s)
431
+ rescue NameError => e
432
+ @ui.fail("#{s[1]} is not supported.", 1, e)
433
+ end
434
+ end
435
+ end
@@ -0,0 +1,26 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'highline/import'
4
+
5
+ # Public: Classes for syncing sets of songs.
6
+ module SyncSongs
7
+ # Public: Controller for a set of songs in a CSV file.
8
+ class CsvCLI
9
+
10
+ # Public: Creates a CLI.
11
+ #
12
+ # controller - A Controller for a CSV set of songs.
13
+ # ui - General user interface to use.
14
+ def initialize(controller, ui)
15
+ @controller = controller
16
+ @ui = ui
17
+ end
18
+
19
+ # Asks for a String naming a column separator and returns it.
20
+ def column_separator
21
+ ask("Column separator for #{@controller.user} "\
22
+ "#{@controller.name} "\
23
+ "#{@controller.type}? ") { |q| q.default = ',' }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,64 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require_relative 'csv_cli'
4
+ require_relative 'csv_set'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: Controller for a set of songs in a CSV file.
9
+ class CsvController < ServiceController
10
+
11
+ # Public: Hash of types of services associated with what they
12
+ # support.
13
+ SERVICES = {library: :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
+ file_path = @user.to_s
26
+ @service_ui = CsvCLI.new(self, @ui)
27
+
28
+ col_sep = @service_ui.column_separator
29
+ @set = if col_sep.empty?
30
+ CsvSet.new(file_path)
31
+ else
32
+ CsvSet.new(file_path, col_sep)
33
+ end
34
+ end
35
+
36
+ # Public: Wrapper for CSV library.
37
+ def library
38
+ @set.library
39
+ rescue ArgumentError, Errno::EACCES, Errno::ENOENT => e
40
+ @ui.fail("Failed to get #{type} from #{name} #{user}\n"\
41
+ "#{e.message.strip}", 1, e)
42
+ end
43
+
44
+ # Public: Wrapper for adding to CSV library.
45
+ #
46
+ # other - A SongSet to add from.
47
+ def addToLibrary(other)
48
+ @set.addToLibrary(other)
49
+ rescue Errno::EACCES, Errno::ENOENT => e
50
+ @ui.fail("Failed to add #{type} to #{name} #{user}\n"\
51
+ "#{e.message.strip}", 1, e)
52
+ end
53
+
54
+ # Public: Wrapper for searching for the given song in the CSV song
55
+ # set.
56
+ #
57
+ # other - SongSet to search for.
58
+ # strict_search - True if search should be strict (default:
59
+ # true). Has no effect. Exist for compatibility.
60
+ def search(other, strict_search = true)
61
+ @set.search(other, strict_search = true)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,65 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'csv'
4
+ require_relative '../song_set'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: A set of songs in a CSV file.
9
+ class CsvSet < SongSet
10
+
11
+ # Public: Creates a CSV set.
12
+ #
13
+ # file_path - A String naming a path to a file to treat as a song
14
+ # set.
15
+ # col_sep - A String naming a column separator to use.
16
+ def initialize(file_path, col_sep = ',')
17
+ super()
18
+ @file_path = file_path
19
+ @col_sep = col_sep
20
+ @options = {col_sep: @col_sep}
21
+ end
22
+
23
+ # Public: Get the library, i.e. all songs, from the CSV file.
24
+ #
25
+ # Raises Errno::EACCES when permission is denied.
26
+ # Raises ArgumentError when a column separator not reflecting the
27
+ # one used in the given file is given.
28
+ #
29
+ # Returns self.
30
+ def library
31
+ CSV.foreach(@file_path, @options) { |row| add(Song.new(*row)) unless row.empty? }
32
+ self
33
+ end
34
+
35
+ # Public: Add the songs in the given set to the library in the CSV
36
+ # file, i.e. simply add the songs to the CSV file.
37
+ #
38
+ # other - A SongSet to add from.
39
+ #
40
+ # Raises Errno::EACCES if permission is denied.
41
+ # Raises Errno::ENOENT if the file does not exist.
42
+ #
43
+ # Returns an array of the songs that was added.
44
+ def addToLibrary(other)
45
+ CSV.open(@file_path, 'w', @options) do |csv|
46
+ other.each do |s|
47
+ csv << [s.name, s.artist, s.album, s.duration, s.id]
48
+ end
49
+ end
50
+
51
+ other.to_a
52
+ end
53
+
54
+ # Public: Searches for the given SongSet in the CSV file. Since
55
+ # any song can be stored in a CSV file no search has to made, thus
56
+ # the input SongSet is simply returned.
57
+ #
58
+ # other - SongSet to search for.
59
+ # strict_search - True if search should be strict (default:
60
+ # true). Has no effect. Exist for compatibility.
61
+ def search(other, strict_search = true)
62
+ other
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,26 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'highline/import'
4
+
5
+ # Public: Classes for syncing sets of songs.
6
+ module SyncSongs
7
+ # Public: Command-line interface for a Grooveshark set of songs.
8
+ class GroovesharkCLI
9
+
10
+ # Public: Creates a CLI.
11
+ #
12
+ # controller - A Controller for a Grooveshark set of songs.
13
+ # ui - General user interface to use.
14
+ def initialize(controller, ui)
15
+ @controller = controller
16
+ @ui = ui
17
+ end
18
+
19
+ # Public: Asks for a String naming a Grooveshark password and
20
+ # returns it.
21
+ def password
22
+ ask('Grooveshark password for '\
23
+ "#{@controller.user}? ") { |q| q.echo = false }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,77 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require_relative 'grooveshark_cli'
4
+ require_relative 'grooveshark_set'
5
+
6
+ # Public: Classes for syncing sets of songs.
7
+ module SyncSongs
8
+ # Public: Controller for a Grooveshark set of songs.
9
+ class GroovesharkController < ServiceController
10
+
11
+ # Public: Hash of types of services associated with what they
12
+ # support.
13
+ SERVICES = {favorites: :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 = GroovesharkCLI.new(self, @ui)
26
+
27
+ @logged_in = false
28
+ tryLogin until @logged_in
29
+ end
30
+
31
+ # Public: Wrapper for Grooveshark favorites.
32
+ def favorites
33
+ @set.favorites
34
+ rescue Grooveshark::GeneralError => 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 Grooveshark favorites.
40
+ #
41
+ # other - A SongSet to add from.
42
+ #
43
+ # Returns an array of the songs that was added.
44
+ def addToFavorites(other)
45
+ @set.addToFavorites(other)
46
+ rescue Grooveshark::GeneralError => e
47
+ @ui.fail("Failed to add #{type} to #{name} #{user}\n"\
48
+ "#{e.message.strip}", 1, e)
49
+ end
50
+
51
+ # Public: Wrapper for searching for the given song set at
52
+ # Grooveshark.
53
+ #
54
+ # other - SongSet to search for.
55
+ # strict_search - True if search should be strict (default: true).
56
+ #
57
+ # Returns a SongSet.
58
+ def search(other, strict_search = true)
59
+ @set.search(other, strict_search = true)
60
+ rescue Grooveshark::ApiError, Grooveshark::GeneralError => e
61
+ @ui.fail("Failed to search #{name} #{user}\n#{e.message.strip}", 1, e)
62
+ end
63
+
64
+ private
65
+
66
+ # Internal: Tries to login to Grooveshark and prints and error
67
+ # message if it fails.
68
+ def tryLogin
69
+ @set = GroovesharkSet.new(@user, @service_ui.password)
70
+ @logged_in = true
71
+ rescue Grooveshark::InvalidAuthentication => e
72
+ @ui.failMessage("Grooveshark: #{e.message}")
73
+ rescue SocketError => e
74
+ @ui.fail('Failed to connect to Grooveshark', 1, e)
75
+ end
76
+ end
77
+ end