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,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