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