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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2e2bd7b5d026ed3c16ee35173ca22f10839f4f486acd3259c8f5ed43c9b96053
4
+ data.tar.gz: 25ce0f4f8a3d6bcbf78dadf9a4eb7fa20ae3a0e77bb68e979ffd9a3acc1e2f91
5
+ SHA512:
6
+ metadata.gz: 8561e8704472f4c3bbec08198a85d84bca87687fa43bad8a08239f49228e7890aaaff55b0d66bd591420b22639de25c40a1aa421124743452b1939601d900b09
7
+ data.tar.gz: cd0f0849b4a1128801be0debc5613933d4757dad32327cf5b914a093e8501e3be84596bb12a58b75db29dd4b970ae6d0a29ab730d4cf28d721522bf71188ddfd
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based
4
+ on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - 2026-05-25
8
+
9
+ Initial public release. A Ruby port of pydora (tracking upstream `pydora 2.3.1`)
10
+ targeting Pandora's partner/device JSON API (`tuner.pandora.com/services/json/`).
11
+
12
+ ### Added
13
+ - `Station` model now parses extended attributes: music seeds
14
+ (`seed_artists`, `seed_songs`, `seed_genres`) and feedback
15
+ (`thumbs_up`, `thumbs_down`) via new `StationSeed`/`StationSeeds`,
16
+ `SongFeedback`/`StationFeedback` sub-models.
17
+ - `TrackExplanation` model for `track.explainTrack`, exposing `focus_traits`
18
+ (the Music-Genome-derived trait tags) with the trailing filler entry
19
+ stripped. `APIClient#explain_track` now returns this model.
20
+ - `base64` declared as an explicit runtime dependency (removed from Ruby's
21
+ default gems in 3.4).
22
+
23
+ ### Fixed
24
+ - Partner authentication: the default partner **username** is now `android`
25
+ (the canonical partner) rather than `android-generic` (which is the
26
+ *device model*). The previous value caused `partnerLogin` to fail with
27
+ INVALID_PARTNER_LOGIN.
28
+ - Corrected the encryption/decryption key orientation in the bundled default
29
+ partner settings.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Dale Stevens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,263 @@
1
+ # Pandoru
2
+
3
+ **Pandoru** is a Ruby port of the Python `pydora` library, providing a comprehensive client for the unofficial Pandora music streaming API. This gem allows you to interact with Pandora programmatically to manage stations, get playlists, search for music, and control playback.
4
+
5
+ > **Note**: This is an unofficial API client. Use at your own risk and respect Pandora's terms of service.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - **Station Management**: Get station lists, create/delete stations, rename stations
12
+ - **Playlist Access**: Retrieve playlists with track metadata and audio URLs
13
+ - **Music Search**: Search for songs, artists, and albums
14
+ - **User Interaction**: Thumbs up/down, bookmarks, sleep songs
15
+ - **Feedback Management**: Add/remove track feedback
16
+ - **Station Seeds & Genome Traits**: Inspect a station's seed artists/songs/genres and a track's Music-Genome focus traits (`explain_track`)
17
+ - **Genre Exploration**: Browse and create stations from genre seeds
18
+ - **Multiple Audio Qualities**: Support for low, medium, and high quality audio streams
19
+ - **Ruby Idioms**: Built with Ruby best practices and idiomatic patterns
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ Add this line to your application's Gemfile:
26
+
27
+ ```ruby
28
+ gem 'pandoru'
29
+ ```
30
+
31
+ Then execute:
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ Or install it yourself as:
38
+
39
+ ```bash
40
+ gem install pandoru
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Usage
46
+
47
+ ### Configuration
48
+
49
+ Pandoru requires Pandora partner credentials to function. You can configure these in several ways:
50
+
51
+ #### 1. Using a Configuration Hash
52
+
53
+ ```ruby
54
+ require 'pandoru'
55
+
56
+ # Note: These are example credentials - you need real ones
57
+ settings = {
58
+ "PARTNER_USER" => "your-partner-user",
59
+ "PARTNER_PASSWORD" => "your-partner-password",
60
+ "DEVICE" => "your-device-type",
61
+ "DECRYPTION_KEY" => "your-decryption-key",
62
+ "ENCRYPTION_KEY" => "your-encryption-key"
63
+ }
64
+
65
+ client = Pandoru::ClientBuilder.from_settings_hash(settings)
66
+ client.login("your_email@example.com", "your_password")
67
+ ```
68
+
69
+ #### 2. Using Configuration Files
70
+
71
+ Pandoru supports both pydora-style and pianobar-style configuration files:
72
+
73
+ ```ruby
74
+ # From a pydora config file
75
+ client = Pandoru::ClientBuilder.from_config_file("~/.pydora.cfg")
76
+
77
+ # From a pianobar config file
78
+ client = Pandoru::ClientBuilder.from_config_file("~/.config/pianobar/config")
79
+ ```
80
+
81
+ ### Basic Operations
82
+
83
+ #### Managing Stations
84
+
85
+ ```ruby
86
+ # Get all user stations
87
+ stations = client.get_station_list
88
+
89
+ # Create a new station from search results
90
+ search_results = client.search("Radiohead")
91
+ station = client.create_station(search_token: search_results.first.music_token)
92
+
93
+ # Rename a station
94
+ client.rename_station(station.token, "My Radiohead Station")
95
+
96
+ # Delete a station
97
+ client.delete_station(station.token)
98
+ ```
99
+
100
+ #### Working with Playlists
101
+
102
+ ```ruby
103
+ # Get a playlist from a station
104
+ playlist = client.get_playlist(station.token)
105
+
106
+ playlist.each do |track|
107
+ puts "#{track.artist_name} - #{track.song_name}"
108
+ puts "Audio URL: #{track.audio_url}"
109
+
110
+ # Rate the track
111
+ track.thumbs_up if track.allow_feedback
112
+
113
+ # Bookmark the song or artist
114
+ track.bookmark_song
115
+ track.bookmark_artist
116
+ end
117
+ ```
118
+
119
+ #### Searching for Music
120
+
121
+ ```ruby
122
+ # Search for music
123
+ results = client.search("The Beatles", include_near_matches: true)
124
+
125
+ results.songs.each do |song|
126
+ puts "Song: #{song.song_name} by #{song.artist_name}"
127
+ end
128
+
129
+ results.artists.each do |artist|
130
+ puts "Artist: #{artist.artist_name}"
131
+ end
132
+ ```
133
+
134
+ #### Managing Bookmarks
135
+
136
+ ```ruby
137
+ # Get user bookmarks
138
+ bookmarks = client.get_bookmarks
139
+
140
+ bookmarks.song_bookmarks.each do |bookmark|
141
+ puts "Bookmarked song: #{bookmark.song_name} by #{bookmark.artist_name}"
142
+ end
143
+
144
+ bookmarks.artist_bookmarks.each do |bookmark|
145
+ puts "Bookmarked artist: #{bookmark.artist_name}"
146
+ end
147
+ ```
148
+
149
+ ### Advanced Features
150
+
151
+ #### Genre Stations
152
+
153
+ ```ruby
154
+ # Browse genre stations
155
+ genre_stations = client.get_genre_stations
156
+
157
+ genre_stations.categories.each do |category|
158
+ puts "Category: #{category}"
159
+ genre_stations.stations_for_category(category).each do |station|
160
+ puts " Station: #{station.name}"
161
+ end
162
+ end
163
+ ```
164
+
165
+ #### Audio Quality
166
+
167
+ ```ruby
168
+ # Set default audio quality when creating client
169
+ client = Pandoru::ClientBuilder.from_settings_hash(settings.merge(
170
+ "AUDIO_QUALITY" => "highQuality" # or "mediumQuality", "lowQuality"
171
+ ))
172
+
173
+ # Get specific quality audio URL for a track
174
+ track = playlist.first
175
+ high_quality_url = track.audio_url("highQuality")
176
+ medium_quality_url = track.audio_url("mediumQuality")
177
+ low_quality_url = track.audio_url("lowQuality")
178
+ ```
179
+
180
+ #### Error Handling
181
+
182
+ ```ruby
183
+ begin
184
+ client.login("user@example.com", "password")
185
+ rescue Pandoru::Errors::InvalidUserLogin
186
+ puts "Invalid username or password"
187
+ rescue Pandoru::Errors::PandoraException => e
188
+ puts "Pandora API error: #{e.message} (Code: #{e.code})"
189
+ end
190
+ ```
191
+
192
+ ---
193
+
194
+ ## API Reference
195
+
196
+ ### Client Classes
197
+
198
+ - `Pandoru::Client::APIClient` - High-level API client with all Pandora operations
199
+ - `Pandoru::Client::BaseAPIClient` - Lower-level client for advanced usage
200
+
201
+ ### Models
202
+
203
+ - `Pandoru::Models::Station` - Represents a Pandora station
204
+ - `Pandoru::Models::StationList` - Collection of user stations
205
+ - `Pandoru::Models::Playlist` - Collection of playlist items
206
+ - `Pandoru::Models::PlaylistItem` - Individual track in a playlist
207
+ - `Pandoru::Models::SearchResult` - Search results container
208
+ - `Pandoru::Models::BookmarkList` - User's bookmarked songs and artists
209
+
210
+ ### Client Builders
211
+
212
+ - `Pandoru::ClientBuilder.from_settings_hash(hash)` - Create client from settings hash
213
+ - `Pandoru::ClientBuilder.from_config_file(path)` - Create client from config file
214
+ - `Pandoru::ClientBuilder.default_client()` - Create client with default settings
215
+
216
+ ---
217
+
218
+ ## Architecture
219
+
220
+ Pandoru is architected similarly to the original pydora library:
221
+
222
+ - **Transport Layer**: Handles HTTP communication and encryption/decryption
223
+ - **Client Layer**: Provides high-level API methods
224
+ - **Models Layer**: Represents Pandora data structures with Ruby idioms
225
+ - **Builders Layer**: Factory classes for creating configured clients
226
+
227
+ The Ruby port maintains compatibility with pydora's API while providing Ruby-style interfaces and error handling.
228
+
229
+ ---
230
+
231
+ ## Development
232
+
233
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt.
234
+
235
+ To install this gem onto your local machine, run `bundle exec rake install`.
236
+
237
+ ---
238
+
239
+ ## Contributing
240
+
241
+ Bug reports and pull requests are welcome on GitHub at https://github.com/TwilightCoders/pandoru.
242
+
243
+ 1. Fork it
244
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
245
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
246
+ 4. Push to the branch (`git push origin my-new-feature`)
247
+ 5. Create new Pull Request
248
+
249
+ ---
250
+
251
+ ## License
252
+
253
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
254
+
255
+ ---
256
+
257
+ ## Acknowledgments
258
+
259
+ This Ruby gem is a port of the excellent [pydora](https://github.com/mcrute/pydora) Python library by Mike Crute and contributors. All credit for the original API reverse engineering and design goes to the pydora project.
260
+
261
+ ## Disclaimer
262
+
263
+ This project is not affiliated with or endorsed by Pandora Media, Inc. Use of this library may violate Pandora's Terms of Service. Use at your own risk.
@@ -0,0 +1,298 @@
1
+ # Pandora API Client
2
+ #
3
+ # This module contains the top level API client that is responsible for calling
4
+ # the API and returning the results in model format. There is a base API client
5
+ # that is useful for lower level programming such as calling methods that aren't
6
+ # directly supported by the higher level API client.
7
+ #
8
+ # The high level API client is what most clients should use and provides API
9
+ # calls that map directly to the Pandora API and return model objects with
10
+ # mappings from the raw JSON structures to Ruby objects.
11
+ #
12
+ # For simplicity use a client builder from Pandoru::ClientBuilder to create an
13
+ # instance of a client.
14
+
15
+ module Pandoru
16
+ module Client
17
+ # Base Pandora API Client
18
+ # The base API client has lower level methods that are composed together to
19
+ # provide higher level functionality.
20
+ class BaseAPIClient
21
+ LOW_AUDIO_QUALITY = "lowQuality"
22
+ MED_AUDIO_QUALITY = "mediumQuality"
23
+ HIGH_AUDIO_QUALITY = "highQuality"
24
+
25
+ ALL_QUALITIES = [LOW_AUDIO_QUALITY, MED_AUDIO_QUALITY, HIGH_AUDIO_QUALITY].freeze
26
+
27
+ attr_reader :transport, :partner_user, :partner_password, :device, :default_audio_quality
28
+ attr_accessor :username, :password
29
+
30
+ def initialize(transport, partner_user = nil, partner_password = nil, device = nil, default_audio_quality: MED_AUDIO_QUALITY)
31
+ @transport = transport
32
+ @partner_user = partner_user
33
+ @partner_password = partner_password
34
+ @device = device
35
+ @default_audio_quality = default_audio_quality
36
+ @username = nil
37
+ @password = nil
38
+ end
39
+
40
+ def login(username, password)
41
+ @username = username
42
+ @password = password
43
+ authenticate
44
+ end
45
+
46
+ def call(method, **kwargs)
47
+ begin
48
+ @transport.call(method, **kwargs)
49
+ rescue Errors::InvalidAuthToken
50
+ authenticate
51
+ @transport.call(method, **kwargs)
52
+ end
53
+ end
54
+
55
+ def self.get_qualities(start_at, return_all_if_invalid: true)
56
+ begin
57
+ idx = ALL_QUALITIES.index(start_at)
58
+ ALL_QUALITIES[0..idx]
59
+ rescue ArgumentError
60
+ return_all_if_invalid ? ALL_QUALITIES.dup : []
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def partner_login
67
+ partner = @transport.call(
68
+ "auth.partnerLogin",
69
+ username: @partner_user,
70
+ password: @partner_password,
71
+ deviceModel: @device,
72
+ version: (@transport.class.const_defined?(:API_VERSION) ? @transport.class::API_VERSION : "5")
73
+ )
74
+
75
+ @transport.set_partner(partner)
76
+ partner
77
+ end
78
+
79
+ def authenticate
80
+ partner_login
81
+
82
+ user = @transport.call(
83
+ "auth.userLogin",
84
+ loginType: "user",
85
+ username: @username,
86
+ password: @password,
87
+ includePandoraOneInfo: true,
88
+ includeSubscriptionExpiration: true,
89
+ returnCapped: true,
90
+ includeAdAttributes: true,
91
+ includeAdvertiserAttributes: true,
92
+ xplatformAdCapable: true
93
+ )
94
+
95
+ @transport.set_user(user)
96
+ user
97
+ rescue Errors::InvalidPartnerLogin => e
98
+ raise Errors::InvalidUserLogin.new(e.message)
99
+ end
100
+ end
101
+
102
+ # High Level Pandora API Client
103
+ # The high level API client implements the entire functional API for Pandora.
104
+ # This is what clients should actually use.
105
+ class APIClient < BaseAPIClient
106
+ def get_station_list
107
+ data = call("user.getStationList", includeStationArtUrl: true)
108
+ Models::StationList.from_json(self, data)
109
+ end
110
+
111
+ def get_station_list_checksum
112
+ data = call("user.getStationListChecksum")
113
+ data["checksum"]
114
+ end
115
+
116
+ def get_playlist(station_token, additional_urls: nil)
117
+ params = {
118
+ stationToken: station_token,
119
+ includeTrackLength: true,
120
+ xplatformAdCapable: true,
121
+ audioAdPodCapable: true
122
+ }
123
+
124
+ if additional_urls
125
+ urls = additional_urls.map { |url| url.respond_to?(:value) ? url.value : url }
126
+ params[:additionalAudioUrl] = urls.join(",")
127
+ end
128
+
129
+ data = call("station.getPlaylist", **params)
130
+
131
+ # Add additional URLs parameter to each item for ad processing
132
+ if additional_urls
133
+ data["items"]&.each { |item| item["_paramAdditionalUrls"] = additional_urls }
134
+ end
135
+
136
+ playlist = Models::Playlist.from_json(self, data)
137
+
138
+ # Process ad items
139
+ playlist.each_with_index do |track, i|
140
+ if track.is_ad?
141
+ ad_track = get_ad_item(station_token, track.ad_token)
142
+ playlist[i] = ad_track
143
+ end
144
+ end
145
+
146
+ playlist
147
+ end
148
+
149
+ def get_bookmarks
150
+ data = call("user.getBookmarks")
151
+ Models::BookmarkList.from_json(self, data)
152
+ end
153
+
154
+ def get_station(station_token)
155
+ data = call("station.getStation",
156
+ stationToken: station_token,
157
+ includeExtendedAttributes: true)
158
+ Models::Station.from_json(self, data)
159
+ end
160
+
161
+ def add_artist_bookmark(track_token)
162
+ call("bookmark.addArtistBookmark", trackToken: track_token)
163
+ end
164
+
165
+ def add_song_bookmark(track_token)
166
+ call("bookmark.addSongBookmark", trackToken: track_token)
167
+ end
168
+
169
+ def delete_song_bookmark(bookmark_token)
170
+ call("bookmark.deleteSongBookmark", bookmarkToken: bookmark_token)
171
+ end
172
+
173
+ def delete_artist_bookmark(bookmark_token)
174
+ call("bookmark.deleteArtistBookmark", bookmarkToken: bookmark_token)
175
+ end
176
+
177
+ def search(search_text, include_near_matches: false, include_genre_stations: false)
178
+ data = call("music.search",
179
+ searchText: search_text,
180
+ includeNearMatches: include_near_matches,
181
+ includeGenreStations: include_genre_stations)
182
+ Models::SearchResult.from_json(self, data)
183
+ end
184
+
185
+ def add_feedback(track_token, positive)
186
+ call("station.addFeedback",
187
+ trackToken: track_token,
188
+ isPositive: positive)
189
+ end
190
+
191
+ def add_music(music_token, station_token)
192
+ call("station.addMusic",
193
+ musicToken: music_token,
194
+ stationToken: station_token)
195
+ end
196
+
197
+ def create_station(search_token: nil, artist_token: nil, track_token: nil, song_token: nil)
198
+ params = {}
199
+
200
+ if search_token
201
+ params[:musicToken] = search_token
202
+ elsif artist_token
203
+ params[:musicToken] = artist_token
204
+ elsif track_token
205
+ params[:musicToken] = track_token
206
+ elsif song_token
207
+ params[:musicToken] = song_token
208
+ else
209
+ raise ArgumentError, "Must provide one of: search_token, artist_token, track_token, song_token"
210
+ end
211
+
212
+ data = call("station.createStation", **params)
213
+ Models::Station.from_json(self, data)
214
+ end
215
+
216
+ def delete_feedback(feedback_id)
217
+ call("station.deleteFeedback", feedbackId: feedback_id)
218
+ end
219
+
220
+ def delete_music(seed_id)
221
+ call("station.deleteMusic", seedId: seed_id)
222
+ end
223
+
224
+ def delete_station(station_token)
225
+ call("station.deleteStation", stationToken: station_token)
226
+ end
227
+
228
+ def get_genre_stations
229
+ data = call("station.getGenreStations")
230
+ Models::GenreStationList.from_json(self, data)
231
+ end
232
+
233
+ def get_genre_stations_checksum
234
+ data = call("station.getGenreStationsChecksum")
235
+ data["checksum"]
236
+ end
237
+
238
+ def rename_station(station_token, name)
239
+ call("station.renameStation",
240
+ stationToken: station_token,
241
+ stationName: name)
242
+ end
243
+
244
+ def explain_track(track_token)
245
+ data = call("track.explainTrack", trackToken: track_token)
246
+ Models::TrackExplanation.from_json(self, data)
247
+ end
248
+
249
+ def set_quick_mix(*station_ids)
250
+ call("user.setQuickMix", quickMixStationIds: station_ids.flatten)
251
+ end
252
+
253
+ def sleep_song(track_token)
254
+ call("user.sleepSong", trackToken: track_token)
255
+ end
256
+
257
+ def share_station(station_id, station_token, *emails)
258
+ call("station.shareStation",
259
+ stationId: station_id,
260
+ stationToken: station_token,
261
+ emails: emails.flatten)
262
+ end
263
+
264
+ def transform_shared_station(station_token)
265
+ call("station.transformSharedStation", stationToken: station_token)
266
+ end
267
+
268
+ def share_music(music_token, *emails)
269
+ call("music.shareMusic",
270
+ musicToken: music_token,
271
+ emails: emails.flatten)
272
+ end
273
+
274
+ def get_ad_item(station_id, ad_token)
275
+ raise Errors::ParameterMissing, "station_id must be defined, got: '#{station_id}'" if station_id.nil? || station_id.empty?
276
+
277
+ ad_data = get_ad_metadata(ad_token)
278
+ ad_item = Models::AdItem.from_json(self, ad_data)
279
+ ad_item.station_id = station_id
280
+ ad_item.ad_token = ad_token
281
+ ad_item
282
+ end
283
+
284
+ def get_ad_metadata(ad_token)
285
+ call("ad.getAdMetadata",
286
+ adToken: ad_token,
287
+ returnAdTrackingTokens: true,
288
+ supportAudioAds: true)
289
+ end
290
+
291
+ def register_ad(station_id, tokens)
292
+ call("ad.registerAd",
293
+ stationId: station_id,
294
+ adTrackingTokens: tokens)
295
+ end
296
+ end
297
+ end
298
+ end