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,249 @@
|
|
|
1
|
+
require_relative '_base'
|
|
2
|
+
|
|
3
|
+
module Pandoru
|
|
4
|
+
module Models
|
|
5
|
+
# A single station seed — an artist, song, or genre the station was
|
|
6
|
+
# built from. Returned under the "music" key of a getStation response
|
|
7
|
+
# when includeExtendedAttributes is set.
|
|
8
|
+
class StationSeed < Base
|
|
9
|
+
field :seed_id, 'seedId'
|
|
10
|
+
field :music_token, 'musicToken'
|
|
11
|
+
field :pandora_id, 'pandoraId'
|
|
12
|
+
field :pandora_type, 'pandoraType'
|
|
13
|
+
field :genre_name, 'genreName'
|
|
14
|
+
field :song_name, 'songName'
|
|
15
|
+
field :artist_name, 'artistName'
|
|
16
|
+
field :art_url, 'artUrl'
|
|
17
|
+
|
|
18
|
+
def song?
|
|
19
|
+
!song_name.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def artist?
|
|
23
|
+
song_name.nil? && !artist_name.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def genre?
|
|
27
|
+
!genre_name.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Human-readable label, handy for clustering/debugging.
|
|
31
|
+
def label
|
|
32
|
+
return genre_name if genre?
|
|
33
|
+
[artist_name, song_name].compact.join(' - ')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The seed sets for a station, grouped by kind.
|
|
38
|
+
class StationSeeds < Base
|
|
39
|
+
attr_accessor :genres, :songs, :artists
|
|
40
|
+
|
|
41
|
+
def self.from_json(api_client, data)
|
|
42
|
+
return nil unless data
|
|
43
|
+
instance = new(data, api_client)
|
|
44
|
+
instance.genres = StationSeed.from_json_list(api_client, data['genres'])
|
|
45
|
+
instance.songs = StationSeed.from_json_list(api_client, data['songs'])
|
|
46
|
+
instance.artists = StationSeed.from_json_list(api_client, data['artists'])
|
|
47
|
+
instance
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# All seeds flattened into one array.
|
|
51
|
+
def all
|
|
52
|
+
Array(genres) + Array(artists) + Array(songs)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A single thumbs-up/down on a song.
|
|
57
|
+
class SongFeedback < Base
|
|
58
|
+
field :feedback_id, 'feedbackId'
|
|
59
|
+
field :song_identity, 'songIdentity'
|
|
60
|
+
field :is_positive, 'isPositive', type: :boolean
|
|
61
|
+
field :pandora_id, 'pandoraId'
|
|
62
|
+
field :album_art_url, 'albumArtUrl'
|
|
63
|
+
field :music_token, 'musicToken'
|
|
64
|
+
field :song_name, 'songName'
|
|
65
|
+
field :artist_name, 'artistName'
|
|
66
|
+
field :pandora_type, 'pandoraType'
|
|
67
|
+
date_field :date_created, 'dateCreated'
|
|
68
|
+
|
|
69
|
+
def positive?
|
|
70
|
+
is_positive == true
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A station's feedback (thumbs) returned under the "feedback" key of an
|
|
75
|
+
# extended getStation response.
|
|
76
|
+
class StationFeedback < Base
|
|
77
|
+
attr_accessor :thumbs_up, :thumbs_down
|
|
78
|
+
field :total_thumbs_up, 'totalThumbsUp'
|
|
79
|
+
field :total_thumbs_down, 'totalThumbsDown'
|
|
80
|
+
|
|
81
|
+
def self.from_json(api_client, data)
|
|
82
|
+
return nil unless data
|
|
83
|
+
instance = new(data, api_client)
|
|
84
|
+
instance.populate_from_json(data)
|
|
85
|
+
instance.thumbs_up = SongFeedback.from_json_list(api_client, data['thumbsUp'])
|
|
86
|
+
instance.thumbs_down = SongFeedback.from_json_list(api_client, data['thumbsDown'])
|
|
87
|
+
instance
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Station < Base
|
|
92
|
+
field :station_id, 'stationId'
|
|
93
|
+
field :station_name, 'stationName'
|
|
94
|
+
field :station_token, 'stationToken'
|
|
95
|
+
field :art_url, 'artUrl'
|
|
96
|
+
field :detail_url, 'stationDetailUrl'
|
|
97
|
+
field :sharing_url, 'stationSharingUrl'
|
|
98
|
+
|
|
99
|
+
field :allow_add_music, 'allowAddMusic', type: :boolean
|
|
100
|
+
field :allow_delete, 'allowDelete', type: :boolean
|
|
101
|
+
field :allow_rename, 'allowRename', type: :boolean
|
|
102
|
+
field :allow_edit_description, 'allowEditDescription', type: :boolean
|
|
103
|
+
|
|
104
|
+
field :is_creator, 'isCreator', type: :boolean
|
|
105
|
+
field :is_shared, 'isShared', type: :boolean
|
|
106
|
+
field :is_quickmix, 'isQuickMix', type: :boolean
|
|
107
|
+
field :is_genre_station, 'isGenreStation', type: :boolean
|
|
108
|
+
field :is_thumbprint, 'isThumbprint', type: :boolean
|
|
109
|
+
|
|
110
|
+
field :thumb_count, 'thumbCount'
|
|
111
|
+
date_field :date_created, 'dateCreated'
|
|
112
|
+
|
|
113
|
+
# Populated only by getStation with includeExtendedAttributes; nil for
|
|
114
|
+
# stations returned in a getStationList response.
|
|
115
|
+
attr_accessor :seeds, :feedback
|
|
116
|
+
|
|
117
|
+
# Convenience aliases
|
|
118
|
+
alias_method :id, :station_id
|
|
119
|
+
alias_method :name, :station_name
|
|
120
|
+
alias_method :token, :station_token
|
|
121
|
+
|
|
122
|
+
def self.from_json(api_client, data)
|
|
123
|
+
station = super
|
|
124
|
+
return station unless station && data
|
|
125
|
+
station.seeds = StationSeeds.from_json(api_client, data['music'])
|
|
126
|
+
station.feedback = StationFeedback.from_json(api_client, data['feedback'])
|
|
127
|
+
station
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Seed accessors that are always safe to call (empty array when the
|
|
131
|
+
# station was loaded without extended attributes).
|
|
132
|
+
def seed_artists
|
|
133
|
+
seeds&.artists || []
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def seed_songs
|
|
137
|
+
seeds&.songs || []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def seed_genres
|
|
141
|
+
seeds&.genres || []
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def thumbs_up
|
|
145
|
+
feedback&.thumbs_up || []
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def thumbs_down
|
|
149
|
+
feedback&.thumbs_down || []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_playlist
|
|
153
|
+
return nil unless @api_client
|
|
154
|
+
@api_client.get_playlist(token)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def rename(new_name)
|
|
158
|
+
return false unless allow_rename && @api_client
|
|
159
|
+
@api_client.rename_station(token, new_name)
|
|
160
|
+
@name = new_name
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def delete
|
|
165
|
+
return false unless allow_delete && @api_client
|
|
166
|
+
@api_client.delete_station(token)
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def add_seed(music_token)
|
|
171
|
+
return false unless allow_add_music && @api_client
|
|
172
|
+
@api_client.add_music(token, music_token)
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class StationList < Collection
|
|
178
|
+
field :checksum, 'checksum'
|
|
179
|
+
|
|
180
|
+
def self.from_json(api_client, data)
|
|
181
|
+
instance = new(data, api_client)
|
|
182
|
+
instance.populate_from_json(data)
|
|
183
|
+
|
|
184
|
+
if data['stations']
|
|
185
|
+
stations = Station.from_json_list(api_client, data['stations'])
|
|
186
|
+
stations.each { |station| instance << station }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
instance
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def find_by_name(name)
|
|
193
|
+
find { |station| station.name == name }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def quickmix_stations
|
|
197
|
+
select(&:is_quickmix)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def user_stations
|
|
201
|
+
reject(&:is_quickmix)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class GenreStation < Base
|
|
206
|
+
field :id, 'stationId'
|
|
207
|
+
field :name, 'stationName'
|
|
208
|
+
field :token, 'stationToken'
|
|
209
|
+
field :category, 'categoryName'
|
|
210
|
+
|
|
211
|
+
def create_station
|
|
212
|
+
return nil unless @api_client
|
|
213
|
+
@api_client.create_station(search_token: token)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
class GenreStationList < Collection
|
|
218
|
+
field :checksum, 'checksum'
|
|
219
|
+
|
|
220
|
+
def self.from_json(api_client, data)
|
|
221
|
+
instance = new(data, api_client)
|
|
222
|
+
instance.populate_from_json(data)
|
|
223
|
+
|
|
224
|
+
if data['categories']
|
|
225
|
+
data['categories'].each do |category|
|
|
226
|
+
category_name = category['categoryName']
|
|
227
|
+
next unless category['stations']
|
|
228
|
+
|
|
229
|
+
category['stations'].each do |station_data|
|
|
230
|
+
station_data['categoryName'] = category_name
|
|
231
|
+
station = GenreStation.from_json(api_client, station_data)
|
|
232
|
+
instance << station
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
instance
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def categories
|
|
241
|
+
map(&:category).uniq
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def stations_for_category(category)
|
|
245
|
+
select { |station| station.category == category }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative '_base'
|
|
2
|
+
|
|
3
|
+
module Pandoru
|
|
4
|
+
module Models
|
|
5
|
+
# A single Music-Genome-derived trait from track.explainTrack, e.g.
|
|
6
|
+
# "electric rock instrumentation" or "a subtle use of vocal harmony".
|
|
7
|
+
class FocusTrait < Base
|
|
8
|
+
field :focus_trait_id, 'focusTraitId'
|
|
9
|
+
field :focus_trait_name, 'focusTraitName'
|
|
10
|
+
|
|
11
|
+
def to_s
|
|
12
|
+
focus_trait_name.to_s
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The result of track.explainTrack: the human-readable traits the Music
|
|
17
|
+
# Genome Project used to justify playing a track. This is the closest the
|
|
18
|
+
# API gets to exposing genome data — discrete trait tags, not a vector.
|
|
19
|
+
class TrackExplanation < Base
|
|
20
|
+
attr_accessor :explanations
|
|
21
|
+
|
|
22
|
+
def self.from_json(api_client, data)
|
|
23
|
+
return nil unless data
|
|
24
|
+
instance = new(data, api_client)
|
|
25
|
+
instance.explanations =
|
|
26
|
+
FocusTrait.from_json_list(api_client, data['explanations'])
|
|
27
|
+
instance
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The genome-derived trait name strings, with the trailing filler entry
|
|
31
|
+
# removed. The API always appends a non-attribute entry as the last
|
|
32
|
+
# explanation ("...many other similarities identified in the Music
|
|
33
|
+
# Genome Project"); it carries no focusTraitId, so we drop it.
|
|
34
|
+
def focus_traits
|
|
35
|
+
traits = explanations || []
|
|
36
|
+
traits = traits[0...-1] if traits.last && traits.last.focus_trait_id.nil?
|
|
37
|
+
traits.map(&:focus_trait_name).compact
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require_relative 'models/_base'
|
|
2
|
+
require_relative 'models/station'
|
|
3
|
+
require_relative 'models/playlist'
|
|
4
|
+
require_relative 'models/search'
|
|
5
|
+
require_relative 'models/bookmark'
|
|
6
|
+
require_relative 'models/track_explanation'
|
|
7
|
+
|
|
8
|
+
module Pandoru
|
|
9
|
+
module Models
|
|
10
|
+
# Convenience method to create models from API responses
|
|
11
|
+
def self.from_json(model_class, data, api_client = nil)
|
|
12
|
+
model_class.from_json(data, api_client)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_json_list(model_class, data_list, api_client = nil)
|
|
16
|
+
model_class.from_json_list(data_list, api_client)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# Pandora API Transport
|
|
2
|
+
#
|
|
3
|
+
# This module contains the very low level transport agent for the Pandora API.
|
|
4
|
+
# The transport is concerned with the details of a raw HTTP call to the Pandora
|
|
5
|
+
# API along with the request and response encryption by way of an Encryptor
|
|
6
|
+
# object. The result from a transport is a JSON object for the API or an
|
|
7
|
+
# exception.
|
|
8
|
+
#
|
|
9
|
+
# API consumers should use one of the API clients in the Pandoru::Client package.
|
|
10
|
+
|
|
11
|
+
require 'faraday'
|
|
12
|
+
require 'faraday/retry'
|
|
13
|
+
require 'json'
|
|
14
|
+
require 'uri'
|
|
15
|
+
require 'time'
|
|
16
|
+
require 'crypt/blowfish'
|
|
17
|
+
require 'base64'
|
|
18
|
+
require 'cgi'
|
|
19
|
+
|
|
20
|
+
module Pandoru
|
|
21
|
+
module Transport
|
|
22
|
+
DEFAULT_API_HOST = "tuner.pandora.com/services/json/"
|
|
23
|
+
|
|
24
|
+
# Function decorator implementing retrying logic for handling connection errors
|
|
25
|
+
module Retries
|
|
26
|
+
def self.retry_on_error(max_tries = 3, exceptions = [StandardError])
|
|
27
|
+
lambda do |method|
|
|
28
|
+
lambda do |*args, &block|
|
|
29
|
+
attempts = 0
|
|
30
|
+
begin
|
|
31
|
+
attempts += 1
|
|
32
|
+
method.call(*args, &block)
|
|
33
|
+
rescue *exceptions => e
|
|
34
|
+
if attempts < max_tries
|
|
35
|
+
sleep(delay_exponential(0.5, 2, attempts))
|
|
36
|
+
retry
|
|
37
|
+
else
|
|
38
|
+
raise e
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.delay_exponential(base, growth_factor, attempts)
|
|
46
|
+
if base == "rand"
|
|
47
|
+
base = rand
|
|
48
|
+
elsif base <= 0
|
|
49
|
+
raise ArgumentError, "Base must be greater than 0"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
base * (growth_factor ** (attempts - 1))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Blowfish cryptography for Pandora API encryption/decryption
|
|
57
|
+
class BlowfishCryptor
|
|
58
|
+
BLOCK_SIZE = 8
|
|
59
|
+
|
|
60
|
+
def initialize(key)
|
|
61
|
+
@cipher = Crypt::Blowfish.new(key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def encrypt(data)
|
|
65
|
+
padded_data = add_padding(data)
|
|
66
|
+
|
|
67
|
+
# Encrypt block by block for consistency with decrypt
|
|
68
|
+
blocks = padded_data.scan(/.{#{BLOCK_SIZE}}/m)
|
|
69
|
+
encrypted = blocks.map { |block| @cipher.encrypt_block(block) }.join
|
|
70
|
+
|
|
71
|
+
encode_hex(encrypted)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def decrypt(data, strip_padding: true)
|
|
75
|
+
return '' if data.empty?
|
|
76
|
+
decoded_data = decode_hex(data)
|
|
77
|
+
|
|
78
|
+
# Decrypt block by block (decrypt_string only does one block!)
|
|
79
|
+
blocks = decoded_data.scan(/.{#{BLOCK_SIZE}}/m)
|
|
80
|
+
decrypted = blocks.map { |block| @cipher.decrypt_block(block) }.join
|
|
81
|
+
|
|
82
|
+
strip_padding ? self.class.strip_padding(decrypted) : decrypted
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def add_padding(data)
|
|
88
|
+
pad_size = BLOCK_SIZE - (data.bytesize % BLOCK_SIZE)
|
|
89
|
+
padding = pad_size.chr * pad_size
|
|
90
|
+
data + padding
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.strip_padding(data)
|
|
94
|
+
pad_size = data[-1].ord
|
|
95
|
+
computed_padding = pad_size.chr * pad_size
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "Invalid padding" unless data[-pad_size..-1] == computed_padding
|
|
98
|
+
|
|
99
|
+
data[0...-pad_size]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def decode_hex(data)
|
|
103
|
+
return '' if data.empty?
|
|
104
|
+
[data.upcase].pack('H*')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def encode_hex(data)
|
|
108
|
+
data.unpack1('H*').downcase.encode('utf-8')
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Pandora Blowfish Encryptor
|
|
113
|
+
class Encryptor
|
|
114
|
+
def initialize(decryption_key, encryption_key)
|
|
115
|
+
@bf_out = BlowfishCryptor.new(encryption_key)
|
|
116
|
+
@bf_in = BlowfishCryptor.new(decryption_key)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encrypt(data)
|
|
120
|
+
@bf_out.encrypt(data)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decrypt(data)
|
|
124
|
+
JSON.parse(@bf_in.decrypt(data))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def decrypt_sync_time(data)
|
|
128
|
+
decrypted = @bf_in.decrypt(data, strip_padding: false)
|
|
129
|
+
# Extract the sync time (skip first 4 bytes, take all but last 2)
|
|
130
|
+
# This matches Python's [4:-2] slice
|
|
131
|
+
time_str = decrypted[4...-2]
|
|
132
|
+
# The sync time is stored as an ASCII string of the Unix timestamp
|
|
133
|
+
time_str.to_i
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Pandora API Transport with retries
|
|
138
|
+
class APITransport
|
|
139
|
+
API_VERSION = "5"
|
|
140
|
+
|
|
141
|
+
REQUIRE_RESET = %w[auth.partnerLogin].freeze
|
|
142
|
+
NO_ENCRYPT = %w[auth.partnerLogin].freeze
|
|
143
|
+
REQUIRE_TLS = %w[
|
|
144
|
+
auth.partnerLogin
|
|
145
|
+
auth.userLogin
|
|
146
|
+
station.getPlaylist
|
|
147
|
+
user.createUser
|
|
148
|
+
].freeze
|
|
149
|
+
|
|
150
|
+
attr_reader :cryptor, :api_host
|
|
151
|
+
attr_accessor :partner_auth_token, :user_auth_token, :partner_id, :user_id,
|
|
152
|
+
:start_time, :server_sync_time
|
|
153
|
+
|
|
154
|
+
def initialize(cryptor = nil, api_host: DEFAULT_API_HOST, proxy: nil)
|
|
155
|
+
@cryptor = cryptor
|
|
156
|
+
api_host ||= DEFAULT_API_HOST
|
|
157
|
+
|
|
158
|
+
# Parse host and path
|
|
159
|
+
if api_host.include?('/services/json')
|
|
160
|
+
# Extract host from full URL
|
|
161
|
+
if api_host.include?('://')
|
|
162
|
+
# Protocol://host format
|
|
163
|
+
uri_parts = api_host.split('/')
|
|
164
|
+
@api_host = uri_parts[2]
|
|
165
|
+
else
|
|
166
|
+
# host/path format
|
|
167
|
+
@api_host = api_host.split('/')[0]
|
|
168
|
+
end
|
|
169
|
+
@api_path = '/services/json/'
|
|
170
|
+
else
|
|
171
|
+
@api_host = api_host
|
|
172
|
+
@api_path = '/services/json/'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Set port and TLS based on URL
|
|
176
|
+
if api_host.include?('https:') || api_host.include?(':443') || api_host == 'tuner.pandora.com'
|
|
177
|
+
@api_port = 443
|
|
178
|
+
@api_tls = true
|
|
179
|
+
else
|
|
180
|
+
@api_port = 80
|
|
181
|
+
@api_tls = false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Strip protocol from host for compatibility
|
|
185
|
+
@api_host = @api_host.gsub(%r{^https?://}, '')
|
|
186
|
+
# Remove path if it got included
|
|
187
|
+
@api_host = @api_host.split('/')[0]
|
|
188
|
+
|
|
189
|
+
# Extract port from hostname if present
|
|
190
|
+
if @api_host.include?(':')
|
|
191
|
+
host_parts = @api_host.split(':')
|
|
192
|
+
@api_host = host_parts[0]
|
|
193
|
+
@api_port = host_parts[1].to_i
|
|
194
|
+
@api_tls = (@api_port == 443)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
@encryption_padding = "\x00" * 16
|
|
198
|
+
@connection = build_connection(proxy)
|
|
199
|
+
reset
|
|
200
|
+
@start_time = Time.now.to_f # Set after reset so it doesn't get cleared
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def reset
|
|
204
|
+
@partner_auth_token = nil
|
|
205
|
+
@user_auth_token = nil
|
|
206
|
+
@partner_id = nil
|
|
207
|
+
@user_id = nil
|
|
208
|
+
@start_time = nil
|
|
209
|
+
@server_sync_time = nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def set_partner(data)
|
|
213
|
+
self.sync_time = data["syncTime"]
|
|
214
|
+
@partner_auth_token = data["partnerAuthToken"]
|
|
215
|
+
@partner_id = data["partnerId"]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def set_user(data)
|
|
219
|
+
@user_id = data["userId"]
|
|
220
|
+
@user_auth_token = data["userAuthToken"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def auth_token
|
|
224
|
+
@auth_token || @user_auth_token || @partner_auth_token
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def sync_time
|
|
228
|
+
return nil unless @server_sync_time
|
|
229
|
+
(@server_sync_time + (Time.now.to_f - @start_time)).to_i
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def call(method, **data)
|
|
233
|
+
start_request(method)
|
|
234
|
+
|
|
235
|
+
params = build_params(method)
|
|
236
|
+
url = build_url(method, params)
|
|
237
|
+
request_info = build_data(method, data)
|
|
238
|
+
|
|
239
|
+
result = make_http_request(url, request_info[:data], params, request_info[:encrypted])
|
|
240
|
+
parse_response(result)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def test_connectivity
|
|
244
|
+
return false unless @connection
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
test_url = "#{@api_tls ? 'https' : 'http'}://#{@api_host}:#{@api_tls ? 443 : 80}"
|
|
248
|
+
test_url(test_url)
|
|
249
|
+
rescue StandardError
|
|
250
|
+
false
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def test_url(url)
|
|
255
|
+
response = @connection.head(url)
|
|
256
|
+
response.status == 200
|
|
257
|
+
rescue
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
def sync_time=(sync_time_encrypted)
|
|
264
|
+
@server_sync_time = @cryptor.decrypt_sync_time(sync_time_encrypted)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def build_connection(proxy)
|
|
268
|
+
options = {
|
|
269
|
+
headers: { 'User-Agent' => 'pianobar-2022.04.01' }
|
|
270
|
+
}
|
|
271
|
+
options[:proxy] = proxy if proxy
|
|
272
|
+
|
|
273
|
+
Faraday.new(options) do |f|
|
|
274
|
+
f.request :retry, max: 3, interval: 0.5, backoff_factor: 2
|
|
275
|
+
f.adapter Faraday.default_adapter
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def start_request(method)
|
|
280
|
+
reset if REQUIRE_RESET.include?(method)
|
|
281
|
+
@start_time ||= Time.now.to_f
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def encrypt(data, key = nil)
|
|
285
|
+
if key
|
|
286
|
+
# Use provided key to create temporary blowfish cryptor
|
|
287
|
+
temp_cryptor = BlowfishCryptor.new(key)
|
|
288
|
+
temp_cryptor.encrypt(data)
|
|
289
|
+
elsif @cryptor
|
|
290
|
+
# Use existing cryptor's underlying blowfish cryptor
|
|
291
|
+
@cryptor.instance_variable_get(:@bf_out).encrypt(data)
|
|
292
|
+
else
|
|
293
|
+
raise ArgumentError, "No encryption key or cryptor available"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def decrypt(data, key = nil)
|
|
298
|
+
if key
|
|
299
|
+
# Use provided key to create temporary blowfish cryptor
|
|
300
|
+
temp_cryptor = BlowfishCryptor.new(key)
|
|
301
|
+
temp_cryptor.decrypt(data)
|
|
302
|
+
elsif @cryptor
|
|
303
|
+
# Use existing cryptor's underlying blowfish cryptor
|
|
304
|
+
@cryptor.instance_variable_get(:@bf_in).decrypt(data)
|
|
305
|
+
else
|
|
306
|
+
raise ArgumentError, "No decryption key or cryptor available"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def build_url(method, params = {})
|
|
311
|
+
protocol = REQUIRE_TLS.include?(method) ? "https" : "http"
|
|
312
|
+
port = @api_tls ? 443 : 80
|
|
313
|
+
# Only include port if it's non-standard
|
|
314
|
+
port_string = (protocol == "https" && port == 443) || (protocol == "http" && port == 80) ? "" : ":#{port}"
|
|
315
|
+
base_url = "#{protocol}://#{@api_host}#{port_string}#{@api_path}"
|
|
316
|
+
|
|
317
|
+
# Build query string with parameters in specific order: method first, then others
|
|
318
|
+
query_parts = []
|
|
319
|
+
query_parts << "method=#{CGI.escape(method)}"
|
|
320
|
+
query_parts << "auth_token=#{CGI.escape(auth_token)}" if auth_token
|
|
321
|
+
query_parts << "user_auth_token=#{CGI.escape(@user_auth_token)}" if @user_auth_token
|
|
322
|
+
query_parts << "partner_id=#{CGI.escape(@partner_id.to_s)}" if @partner_id
|
|
323
|
+
query_parts << "user_id=#{CGI.escape(@user_id.to_s)}" if @user_id
|
|
324
|
+
|
|
325
|
+
query_string = query_parts.join('&')
|
|
326
|
+
|
|
327
|
+
"#{base_url}?#{query_string}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def build_params(method)
|
|
331
|
+
remove_empty_values({
|
|
332
|
+
method: method,
|
|
333
|
+
auth_token: auth_token,
|
|
334
|
+
partner_id: @partner_id,
|
|
335
|
+
user_id: @user_id
|
|
336
|
+
})
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def build_data(method, data)
|
|
340
|
+
data = data.dup
|
|
341
|
+
data[:userAuthToken] = @user_auth_token if @user_auth_token
|
|
342
|
+
data[:partnerAuthToken] = @partner_auth_token if @partner_auth_token && !@user_auth_token
|
|
343
|
+
data[:syncTime] = sync_time
|
|
344
|
+
|
|
345
|
+
json_data = JSON.generate(remove_empty_values(data))
|
|
346
|
+
|
|
347
|
+
if NO_ENCRYPT.include?(method) || !@cryptor
|
|
348
|
+
{ data: json_data, encrypted: false }
|
|
349
|
+
else
|
|
350
|
+
{ data: @cryptor.encrypt(json_data), encrypted: true }
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def make_http_request(url, data, params, encrypted = false)
|
|
355
|
+
begin
|
|
356
|
+
response = @connection.post(url) do |req|
|
|
357
|
+
# Don't set req.params since URL already has query string
|
|
358
|
+
# Ensure body is properly encoded as UTF-8 string
|
|
359
|
+
req.body = data.to_s.force_encoding('UTF-8')
|
|
360
|
+
# Set Content-Type for JSON, but not for encrypted data
|
|
361
|
+
req.headers['Content-Type'] = 'application/json' unless encrypted
|
|
362
|
+
# Match pydora's headers
|
|
363
|
+
req.headers['Accept'] = '*/*'
|
|
364
|
+
req.headers['Connection'] = 'keep-alive'
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
response.raise_error unless response.success?
|
|
368
|
+
response.body
|
|
369
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
370
|
+
# Let the retry middleware handle transient errors first
|
|
371
|
+
# If we get here, retries have been exhausted
|
|
372
|
+
raise NetworkError, "Network error: #{e.message}"
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def parse_response(result)
|
|
377
|
+
parsed = JSON.parse(result)
|
|
378
|
+
|
|
379
|
+
if parsed["stat"] == "ok"
|
|
380
|
+
parsed["result"]
|
|
381
|
+
else
|
|
382
|
+
error_code = parsed["code"]
|
|
383
|
+
error_message = parsed["message"]
|
|
384
|
+
raise Pandoru.create_api_error(error_message, error_code)
|
|
385
|
+
end
|
|
386
|
+
rescue JSON::ParserError => e
|
|
387
|
+
raise NetworkError, "Invalid JSON response: #{e.message}"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def remove_empty_values(hash)
|
|
391
|
+
hash.reject { |_, v| v.nil? }
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|