pandoru 0.1.0

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,526 @@
1
+ # Pandora API Client Builders
2
+ #
3
+ # This module provides a set of builder classes that can turn various
4
+ # configuration formats into a fully built APIClient.
5
+
6
+ require 'pathname'
7
+
8
+ module Pandoru
9
+ module ClientBuilders
10
+ # Abstract Key/Value Translating Hash
11
+ class TranslatingHash < Hash
12
+ def self.key_translations
13
+ @key_translations ||= {}
14
+ end
15
+
16
+ def self.value_translations
17
+ @value_translations ||= {}
18
+ end
19
+
20
+ def self.translate_key(key, value_translations)
21
+ @key_translations = key_translations
22
+ end
23
+
24
+ def self.translate_value(value_translations)
25
+ @value_translations = value_translations
26
+ end
27
+
28
+ def initialize(initial = nil)
29
+ super()
30
+
31
+ if initial
32
+ if initial.respond_to?(:each_pair)
33
+ initial.each_pair { |k, v| put(k, v) }
34
+ else
35
+ initial.each { |k, v| put(k, v) }
36
+ end
37
+ end
38
+ end
39
+
40
+ def was_translated(from_key, to_key)
41
+ # Override in subclasses for logging
42
+ end
43
+
44
+ def translate_key(key)
45
+ key_str = key.to_s.strip.upcase
46
+ to_key = self.class.key_translations[key_str]
47
+
48
+ if to_key
49
+ was_translated(key_str, to_key)
50
+ to_key
51
+ else
52
+ key_str
53
+ end
54
+ end
55
+
56
+ def translate_value(key, value)
57
+ value = value.strip if value.respond_to?(:strip)
58
+ translator = self.class.value_translations[key]
59
+ translator ? translator.call(value) : value
60
+ end
61
+
62
+ def put(key, value)
63
+ translated_key = translate_key(key)
64
+ translated_value = translate_value(translated_key, value)
65
+ store(translated_key, translated_value)
66
+ end
67
+
68
+ def []=(key, value)
69
+ put(key, value)
70
+ end
71
+ end
72
+
73
+ # Settings Dictionary
74
+ class SettingsDict < TranslatingHash
75
+ @key_translations = {
76
+ "USERNAME" => "PARTNER_USER",
77
+ "PASSWORD" => "PARTNER_PASSWORD",
78
+ "DEFAULT_AUDIO_QUALITY" => "AUDIO_QUALITY"
79
+ }
80
+
81
+ @value_translations = {}
82
+ end
83
+
84
+ # Pianobar Settings Dictionary
85
+ class PianobarSettingsDict < TranslatingHash
86
+ @key_translations = {
87
+ "DECRYPT_PASSWORD" => "DECRYPTION_KEY",
88
+ "ENCRYPT_PASSWORD" => "ENCRYPTION_KEY",
89
+ "RPC_HOST" => "API_HOST",
90
+ "CONTROL_PROXY" => "PROXY"
91
+ }
92
+
93
+ @value_translations = {
94
+ "API_HOST" => ->(v) { "#{v}/services/json/" },
95
+ "AUDIO_QUALITY" => ->(v) { "#{v}Quality" }
96
+ }
97
+ end
98
+
99
+ # Abstract API Client Builder
100
+ # Provides the basic functions for building an API client. Expects a
101
+ # hash of standard configuration options.
102
+ #
103
+ # Required values:
104
+ # * DECRYPTION_KEY - Pandora API decryption key
105
+ # * ENCRYPTION_KEY - Pandora API encryption key
106
+ # * PARTNER_USER - Pandora API partner username
107
+ # * PARTNER_PASSWORD - Pandora API partner password
108
+ # * DEVICE - Pandora API device type identifier
109
+ #
110
+ # Optional values:
111
+ # * API_HOST - API hostname and path to API
112
+ # * PROXY - HTTP/HTTPS proxy hostname
113
+ # * AUDIO_QUALITY - A supported audio quality (see APIClient)
114
+ class APIClientBuilder
115
+ DEFAULT_CLIENT_CLASS = Client::APIClient
116
+
117
+ def initialize(client_class: nil)
118
+ @client_class = client_class || DEFAULT_CLIENT_CLASS
119
+ end
120
+
121
+ def build_from_settings_hash(settings)
122
+ cryptor = Transport::Encryptor.new(
123
+ settings["DECRYPTION_KEY"],
124
+ settings["ENCRYPTION_KEY"]
125
+ )
126
+
127
+ transport = Transport::APITransport.new(
128
+ cryptor,
129
+ api_host: settings["API_HOST"],
130
+ proxy: settings["PROXY"]
131
+ )
132
+
133
+ @client_class.new(
134
+ transport,
135
+ settings["PARTNER_USER"],
136
+ settings["PARTNER_PASSWORD"],
137
+ settings["DEVICE"],
138
+ default_audio_quality: settings["AUDIO_QUALITY"] || Client::BaseAPIClient::MED_AUDIO_QUALITY
139
+ )
140
+ end
141
+ end
142
+
143
+ # Settings Translating Hash
144
+ # Maps old setting keys to new ones. Should be removed when ready to break
145
+ # backwards compatibility.
146
+ class SettingsHash < TranslatingHash
147
+ KEY_TRANSLATIONS = {
148
+ "USERNAME" => "PARTNER_USER",
149
+ "PASSWORD" => "PARTNER_PASSWORD",
150
+ "DEFAULT_AUDIO_QUALITY" => "AUDIO_QUALITY"
151
+ }.freeze
152
+
153
+ VALUE_TRANSLATIONS = {}.freeze
154
+
155
+ def was_translated(from_key, to_key)
156
+ Pandoru.logger&.warn("Setting key '#{from_key}' is deprecated, use '#{to_key}' instead")
157
+ end
158
+ end
159
+
160
+ # Settings Hash Client Builder
161
+ # Builds an API client based on a translated settings hash.
162
+ class SettingsHashBuilder < APIClientBuilder
163
+ def initialize(settings, **kwargs)
164
+ super(**kwargs)
165
+ @settings = SettingsHash.new(settings)
166
+ end
167
+
168
+ def build
169
+ build_from_settings_hash(@settings)
170
+ end
171
+ end
172
+
173
+ # Abstract File Based Client Builder
174
+ # Provides base functionality for client builders that load their settings
175
+ # from files.
176
+ class FileBasedClientBuilder < APIClientBuilder
177
+ DEFAULT_CONFIG_FILE = ""
178
+
179
+ attr_reader :path, :authenticate
180
+
181
+ def initialize(path: nil, authenticate: true, **kwargs)
182
+ super(**kwargs)
183
+ @path = path
184
+ @authenticate = authenticate
185
+ end
186
+
187
+ def path=(new_path)
188
+ @path = new_path
189
+ @config = nil # Reset cached config
190
+ end
191
+
192
+ def config
193
+ @config ||= parse_config
194
+ end
195
+
196
+ def parse_config
197
+ raise NotImplementedError, "Subclasses must implement parse_config"
198
+ end
199
+
200
+ def build
201
+ client = build_from_settings_hash(config)
202
+
203
+ if authenticate && config["USERNAME"] && config["PASSWORD"]
204
+ client.login(config["USERNAME"], config["PASSWORD"])
205
+ end
206
+
207
+ client
208
+ end
209
+
210
+ private
211
+
212
+ def config_file_path
213
+ return @path if @path
214
+
215
+ default_path = self.class::DEFAULT_CONFIG_FILE
216
+ File.expand_path(default_path)
217
+ end
218
+ end
219
+
220
+ # Pydora Config Format Client Builder
221
+ # Builds API client for original pydora configuration format.
222
+ class PydoraConfigFileBuilder < FileBasedClientBuilder
223
+ DEFAULT_CONFIG_FILE = "~/.pydora.cfg"
224
+
225
+ def self.default_config
226
+ {
227
+ "DECRYPTION_KEY" => "",
228
+ "ENCRYPTION_KEY" => "",
229
+ "PARTNER_USER" => "",
230
+ "PARTNER_PASSWORD" => "",
231
+ "DEVICE" => "",
232
+ "USERNAME" => "",
233
+ "PASSWORD" => "",
234
+ "API_HOST" => Transport::DEFAULT_API_HOST,
235
+ "AUDIO_QUALITY" => Client::BaseAPIClient::MED_AUDIO_QUALITY
236
+ }
237
+ end
238
+
239
+ def parse_config
240
+ config_path = config_file_path
241
+
242
+ unless File.exist?(config_path)
243
+ raise ArgumentError, "Config file not found: #{config_path}"
244
+ end
245
+
246
+ config = self.class.default_config.dup
247
+ current_section = nil
248
+
249
+ File.readlines(config_path).each do |line|
250
+ line = line.strip
251
+ next if line.empty? || line.start_with?('#')
252
+
253
+ if line.start_with?('[') && line.end_with?(']')
254
+ current_section = line[1...-1]
255
+ next
256
+ end
257
+
258
+ if line.include?('=')
259
+ key, value = line.split('=', 2)
260
+ key = key.strip.upcase
261
+ value = value.strip
262
+
263
+ # Remove quotes if present
264
+ value = value[1...-1] if (value.start_with?('"') && value.end_with?('"')) ||
265
+ (value.start_with?("'") && value.end_with?("'"))
266
+
267
+ config[key] = value
268
+ end
269
+ end
270
+
271
+ # Apply any translations
272
+ SettingsDict.new(config)
273
+ end
274
+ end
275
+
276
+ # Pianobar Config File Client Builder
277
+ # Builds an API client from a Pianobar config file.
278
+ class PianobarConfigFileBuilder < FileBasedClientBuilder
279
+ DEFAULT_CONFIG_FILE = "~/.config/pianobar/config"
280
+
281
+ def parse_config
282
+ config_path = config_file_path
283
+
284
+ unless File.exist?(config_path)
285
+ raise ArgumentError, "Config file not found: #{config_path}"
286
+ end
287
+
288
+ settings = PianobarSettingsDict.new
289
+
290
+ File.readlines(config_path).each do |line|
291
+ line = line.strip
292
+ next if line.empty? || line.start_with?('#')
293
+
294
+ if line.include?('=')
295
+ key, value = line.split('=', 2)
296
+ settings.put(key.strip, value.strip)
297
+ end
298
+ end
299
+
300
+ # Extract user credentials
301
+ user_settings = {
302
+ "USERNAME" => settings.delete("USER"),
303
+ "PASSWORD" => settings.delete("PASSWORD")
304
+ }
305
+
306
+ settings["USER"] = user_settings
307
+ settings
308
+ end
309
+ end
310
+
311
+ # Convenience methods for creating clients
312
+ module_function
313
+
314
+ def from_config_file(path, **options)
315
+ case File.extname(path)
316
+ when '.cfg'
317
+ PydoraConfigFileBuilder.new(path: path, **options).build
318
+ else
319
+ PianobarConfigFileBuilder.new(path: path, **options).build
320
+ end
321
+ end
322
+
323
+ def from_settings_hash(settings, **options)
324
+ SettingsHashBuilder.new(settings, **options).build
325
+ end
326
+
327
+ # Default Pandora partner settings - these would need to be populated
328
+ # with actual partner credentials
329
+ # The canonical "android" partner. Note the distinction between the partner
330
+ # *username* ("android") and the *device model* ("android-generic") — they
331
+ # are different fields. Sending "android-generic" as the username fails
332
+ # partnerLogin with INVALID_PARTNER_LOGIN. The keys are oriented for the
333
+ # Encryptor's (decryption_key, encryption_key) argument order.
334
+ DEFAULT_SETTINGS = {
335
+ "PARTNER_USER" => "android",
336
+ "PARTNER_PASSWORD" => "AC7IBG09A3DTSYM4R41UJWL07VLN8JI7",
337
+ "DEVICE" => "android-generic",
338
+ "DECRYPTION_KEY" => "R=U!LH$O2B#",
339
+ "ENCRYPTION_KEY" => "6#26FRL$ZWD",
340
+ "API_HOST" => "tuner.pandora.com"
341
+ }.freeze
342
+
343
+ def default_client(**options)
344
+ settings = DEFAULT_SETTINGS.merge(options)
345
+ from_settings_hash(settings)
346
+ end
347
+ end
348
+
349
+ # Main ClientBuilder class for external API
350
+ class ClientBuilder
351
+ attr_reader :config
352
+
353
+ def initialize(config_data = {})
354
+ base_defaults = {
355
+ device: 'android-generic',
356
+ encrypt_password: true,
357
+ rpc_host: 'tuner.pandora.com',
358
+ rpc_path: '/services/json/',
359
+ rpc_tls_port: 443
360
+ }
361
+
362
+ if config_data.is_a?(String)
363
+ # File path provided
364
+ loaded_config = load_config_file(config_data)
365
+ @config = base_defaults.merge(loaded_config)
366
+ elsif config_data.is_a?(Hash)
367
+ # Hash data provided
368
+ @config = base_defaults.merge(config_data.transform_keys(&:to_sym))
369
+ else
370
+ @config = base_defaults.dup
371
+ end
372
+ end
373
+
374
+ def build
375
+ # Convert config to string keys for the internal builders
376
+ string_config = @config.transform_keys(&:to_s).transform_keys(&:upcase)
377
+
378
+ # Map common config keys to the expected API format
379
+ mapped_config = {
380
+ 'ENCRYPTION_KEY' => string_config['ENCRYPTION_KEY'] || '6#26FRL$ZWD',
381
+ 'DECRYPTION_KEY' => string_config['DECRYPTION_KEY'] || 'R=U!LH$O2B#',
382
+ 'PARTNER_USER' => string_config['PARTNER_USER'] || 'android',
383
+ 'PARTNER_PASSWORD' => string_config['PARTNER_PASSWORD'] || 'AC7IBG09A3DTSYM4R41UJWL07VLN8JI7',
384
+ 'DEVICE' => string_config['DEVICE'] || 'android-generic',
385
+ 'API_HOST' => (string_config['RPC_HOST'] || string_config['HOST'] || 'tuner.pandora.com'),
386
+ 'PROXY' => string_config['CONTROL_PROXY'],
387
+ 'AUDIO_QUALITY' => string_config['AUDIO_QUALITY'] || Client::BaseAPIClient::MED_AUDIO_QUALITY
388
+ }.compact
389
+
390
+ # Handle custom port
391
+ if string_config['RPC_TLS_PORT']
392
+ port = string_config['RPC_TLS_PORT'].to_i
393
+ if port > 0
394
+ mapped_config['API_HOST'] = "https://#{mapped_config['API_HOST']}:#{port}/services/json/"
395
+ end
396
+ elsif mapped_config['API_HOST'] == 'tuner.pandora.com'
397
+ # Only add HTTPS and path for the default tuner.pandora.com to enable TLS
398
+ mapped_config['API_HOST'] = 'https://tuner.pandora.com/services/json/'
399
+ end
400
+
401
+ # Build using the simple approach
402
+ ClientBuilders.from_settings_hash(mapped_config)
403
+ end
404
+
405
+ private
406
+
407
+ def load_config_file(file_path)
408
+ unless File.exist?(file_path)
409
+ raise InvalidConfigError, "Configuration file not found: #{file_path}"
410
+ end
411
+
412
+ case File.extname(file_path)
413
+ when '.json'
414
+ load_json_config(file_path)
415
+ when '.cfg'
416
+ # Check if it's pydora format (has sections) or pianobar format (simple key=value)
417
+ content = File.read(file_path)
418
+ if content.include?('[') && content.match(/^\s*\[.*\]\s*$/)
419
+ load_pydora_config(file_path)
420
+ else
421
+ load_pianobar_config(file_path)
422
+ end
423
+ else
424
+ load_pianobar_config(file_path)
425
+ end
426
+ end
427
+
428
+ def load_json_config(file_path)
429
+ begin
430
+ data = JSON.parse(File.read(file_path))
431
+ data.transform_keys(&:to_sym)
432
+ rescue JSON::ParserError => e
433
+ raise InvalidConfigError, "Invalid JSON in config file: #{e.message}"
434
+ end
435
+ end
436
+
437
+ def load_pydora_config(file_path)
438
+ config = {}
439
+ current_section = nil
440
+
441
+ File.readlines(file_path).each do |line|
442
+ line = line.strip
443
+ next if line.empty? || line.start_with?('#')
444
+
445
+ if line.start_with?('[') && line.end_with?(']')
446
+ current_section = line[1...-1]
447
+ next
448
+ end
449
+
450
+ if line.include?('=')
451
+ key, value = line.split('=', 2)
452
+ key = key.strip.downcase.to_sym
453
+ value = value.strip
454
+
455
+ # Remove quotes if present
456
+ value = value[1...-1] if (value.start_with?('"') && value.end_with?('"')) ||
457
+ (value.start_with?("'") && value.end_with?("'"))
458
+
459
+ config[key] = value
460
+ end
461
+ end
462
+
463
+ config
464
+ end
465
+
466
+ def load_pianobar_config(file_path)
467
+ config = {}
468
+
469
+ File.readlines(file_path).each do |line|
470
+ line = line.strip
471
+ next if line.empty? || line.start_with?('#')
472
+
473
+ if line.include?('=')
474
+ key, value = line.split('=', 2)
475
+ key = key.strip.downcase
476
+ value = value.strip
477
+
478
+ # Map keys to expected format
479
+ case key
480
+ when 'user'
481
+ config[:username] = value
482
+ when 'encrypt_password'
483
+ config[:encrypt_password] = convert_to_boolean(value)
484
+ when 'rpc_tls_port'
485
+ config[:rpc_tls_port] = value.to_i
486
+ else
487
+ config[key.to_sym] = value
488
+ end
489
+ end
490
+ end
491
+
492
+ config
493
+ end
494
+
495
+ def convert_to_boolean(value)
496
+ case value.to_s.downcase
497
+ when '1', 'true', 'yes', 'on'
498
+ true
499
+ when '0', 'false', 'no', 'off'
500
+ false
501
+ else
502
+ value
503
+ end
504
+ end
505
+
506
+ # Method expected by tests for translating pianobar config format
507
+ def translate_pianobar_config(data)
508
+ result = {}
509
+
510
+ data.each do |key, value|
511
+ case key.to_s.downcase
512
+ when 'user'
513
+ result[:username] = value
514
+ when 'encrypt_password'
515
+ result[:encrypt_password] = convert_to_boolean(value)
516
+ when 'rpc_tls_port'
517
+ result[:rpc_tls_port] = value.to_i
518
+ else
519
+ result[key.to_s.downcase.to_sym] = value
520
+ end
521
+ end
522
+
523
+ result
524
+ end
525
+ end
526
+ end
@@ -0,0 +1,147 @@
1
+ # Pandora API Exceptions
2
+
3
+ module Pandoru
4
+ # Base exception for all Pandoru errors
5
+ class PandoruError < StandardError
6
+ def initialize(message = nil)
7
+ super(message || self.class.name.split('::').last)
8
+ end
9
+ end
10
+
11
+ # Network related errors
12
+ class NetworkError < PandoruError
13
+ def initialize(message = 'Network error')
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ # Configuration related errors
19
+ class InvalidConfigError < PandoruError
20
+ def initialize(message = 'Invalid configuration')
21
+ super(message)
22
+ end
23
+ end
24
+
25
+ # Parameter related errors
26
+ class ParameterMissing < PandoruError; end
27
+
28
+ # API related errors
29
+ class APIError < PandoruError
30
+ attr_reader :error_code
31
+
32
+ def initialize(message = nil, error_code = nil)
33
+ @error_code = error_code
34
+ super(message)
35
+ end
36
+ end
37
+
38
+ # Specific API errors
39
+ class InvalidAuthToken < APIError
40
+ def initialize(message = 'Invalid auth token', code = 1001)
41
+ super(message, code)
42
+ end
43
+ end
44
+
45
+ class InvalidPartnerLogin < APIError
46
+ def initialize(message = 'Invalid partner login', code = 1002)
47
+ super(message, code)
48
+ end
49
+ end
50
+
51
+ class InvalidUserLogin < APIError
52
+ def initialize(message = 'Invalid user login', code = 1012)
53
+ super(message, code)
54
+ end
55
+ end
56
+
57
+ class InvalidRequestError < APIError
58
+ def initialize(message = 'Invalid request', code = 5)
59
+ super(message, code)
60
+ end
61
+ end
62
+
63
+ # All Pandora API error codes from Python reference
64
+ API_ERRORS = {
65
+ 0 => "Internal Server Error",
66
+ 1 => "Maintenance Mode",
67
+ 2 => "Missing API Method",
68
+ 3 => "Missing Auth Token",
69
+ 4 => "Missing Partner ID",
70
+ 5 => "Missing User ID",
71
+ 6 => "Secure Protocol Required",
72
+ 7 => "Certificate Required",
73
+ 8 => "Parameter Type Mismatch",
74
+ 9 => "Parameter Missing",
75
+ 10 => "Parameter Value Invalid",
76
+ 11 => "API Version Not Supported",
77
+ 12 => "Pandora not available in this country",
78
+ 13 => "Bad Sync Time",
79
+ 14 => "Unknown Method Name",
80
+ 15 => "Wrong Protocol - (http/https)",
81
+ 1000 => "Read Only Mode",
82
+ 1001 => "Invalid Auth Token",
83
+ 1002 => "Invalid Partner Login",
84
+ 1003 => "Listener Not Authorized - Subscription or Trial Expired",
85
+ 1004 => "User Not Authorized",
86
+ 1005 => "Station limit reached",
87
+ 1006 => "Station does not exist",
88
+ 1009 => "Device Not Found",
89
+ 1010 => "Partner Not Authorized",
90
+ 1011 => "Invalid Username",
91
+ 1012 => "Invalid Password",
92
+ 1023 => "Device Model Invalid",
93
+ 1039 => "Too many requests for a new playlist",
94
+ 9999 => "Authentication Required"
95
+ }.freeze
96
+
97
+ # Error code mappings to exception classes
98
+ API_ERROR_MAP = {
99
+ 0 => InvalidPartnerLogin,
100
+ 1 => InvalidAuthToken,
101
+ 2 => InvalidUserLogin,
102
+ 5 => InvalidRequestError,
103
+ 1001 => InvalidAuthToken,
104
+ 1002 => InvalidPartnerLogin,
105
+ 1012 => InvalidUserLogin
106
+ }
107
+
108
+ # Factory method to create appropriate error
109
+ def self.api_error_for_code(code)
110
+ API_ERROR_MAP[code] || APIError
111
+ end
112
+
113
+ def self.create_api_error(message, code = nil)
114
+ error_class = api_error_for_code(code)
115
+ error_class.new(message, code)
116
+ end
117
+
118
+ # Create exception classes for all error codes
119
+ API_ERRORS.each do |code, message|
120
+ # Convert message to class name (e.g., "Invalid Auth Token" -> "InvalidAuthToken")
121
+ class_name = message.gsub(/[^a-zA-Z0-9\s]/, '').split.map(&:capitalize).join
122
+ next if const_defined?(class_name) # Skip if already defined
123
+
124
+ # Create the exception class
125
+ exception_class = Class.new(APIError) do
126
+ define_method(:initialize) do |extended_message = "", error_code = code|
127
+ super("#{message}#{extended_message.empty? ? '' : ": #{extended_message}"}", error_code)
128
+ end
129
+ end
130
+
131
+ const_set(class_name, exception_class)
132
+ API_ERROR_MAP[code] = exception_class unless API_ERROR_MAP.key?(code)
133
+ end
134
+
135
+ # Freeze the error map after all classes are created
136
+ API_ERROR_MAP.freeze
137
+
138
+ # Aliases for common exceptions
139
+ module Errors
140
+ PandoraException = PandoruError
141
+ InvalidAuthToken = Pandoru::InvalidAuthToken
142
+ InvalidPartnerLogin = Pandoru::InvalidPartnerLogin
143
+ InvalidUserLogin = Pandoru::InvalidUserLogin
144
+ ParameterMissing = Pandoru::ParameterMissing
145
+ NetworkError = Pandoru::NetworkError
146
+ end
147
+ end