sync_songs 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +8 -0
- data/CONTRIBUTING.org +14 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +51 -0
- data/LICENSE.org +2 -0
- data/README.org +117 -0
- data/Rakefile +19 -0
- data/bin/sync_songs +69 -0
- data/development.org +53 -0
- data/lib/sync_songs/cli.rb +280 -0
- data/lib/sync_songs/controller.rb +435 -0
- data/lib/sync_songs/services/csv_cli.rb +26 -0
- data/lib/sync_songs/services/csv_controller.rb +64 -0
- data/lib/sync_songs/services/csv_set.rb +65 -0
- data/lib/sync_songs/services/grooveshark_cli.rb +26 -0
- data/lib/sync_songs/services/grooveshark_controller.rb +77 -0
- data/lib/sync_songs/services/grooveshark_set.rb +107 -0
- data/lib/sync_songs/services/lastfm_cli.rb +46 -0
- data/lib/sync_songs/services/lastfm_controller.rb +78 -0
- data/lib/sync_songs/services/lastfm_set.rb +177 -0
- data/lib/sync_songs/services/service_controller.rb +55 -0
- data/lib/sync_songs/song.rb +99 -0
- data/lib/sync_songs/song_set.rb +30 -0
- data/lib/sync_songs/version.rb +6 -0
- data/lib/sync_songs.rb +27 -0
- data/plan.org +17 -0
- data/sync_songs.gemspec +25 -0
- data/test/unit/sample_data/sample_data.rb +23 -0
- data/test/unit/services/test_csv_set.rb +54 -0
- data/test/unit/services/test_service_controller.rb +30 -0
- data/test/unit/suite_data.rb +12 -0
- data/test/unit/test_song.rb +129 -0
- data/test/unit/test_song_set.rb +134 -0
- metadata +165 -0
@@ -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
|