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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +263 -0
- data/lib/pandoru/client.rb +298 -0
- data/lib/pandoru/client_builder.rb +526 -0
- data/lib/pandoru/errors.rb +147 -0
- data/lib/pandoru/models/_base.rb +363 -0
- data/lib/pandoru/models/bookmark.rb +81 -0
- data/lib/pandoru/models/playlist.rb +91 -0
- data/lib/pandoru/models/search.rb +75 -0
- data/lib/pandoru/models/station.rb +249 -0
- data/lib/pandoru/models/track_explanation.rb +41 -0
- data/lib/pandoru/models.rb +19 -0
- data/lib/pandoru/transport.rb +395 -0
- data/lib/pandoru/version.rb +3 -0
- data/lib/pandoru.rb +69 -0
- metadata +212 -0
|
@@ -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
|