pandoru 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e2bd7b5d026ed3c16ee35173ca22f10839f4f486acd3259c8f5ed43c9b96053
4
- data.tar.gz: 25ce0f4f8a3d6bcbf78dadf9a4eb7fa20ae3a0e77bb68e979ffd9a3acc1e2f91
3
+ metadata.gz: fd2045e11084de310e45882480a3e8472ebceac1e81ca927682f978f10458ffa
4
+ data.tar.gz: f279f57d936e055be8c819f154a9b863c45ffdf01ceb4970cd130035c480b033
5
5
  SHA512:
6
- metadata.gz: 8561e8704472f4c3bbec08198a85d84bca87687fa43bad8a08239f49228e7890aaaff55b0d66bd591420b22639de25c40a1aa421124743452b1939601d900b09
7
- data.tar.gz: cd0f0849b4a1128801be0debc5613933d4757dad32327cf5b914a093e8501e3be84596bb12a58b75db29dd4b970ae6d0a29ab730d4cf28d721522bf71188ddfd
6
+ metadata.gz: 654f2fcb4948e93307ce68650c7b53fbd29e20252dac628311e5509ec3b25bfb2b0f9116d51ed5da583cc72fecd91333b2518ea206117ef1418fd7a9eec05453
7
+ data.tar.gz: aed635c1e7eec1bc31b7790386f99c700d4964061dcc672535d9233e1ee81381050687d8829e37709ba491cfac190557f842a5cb42e6022eacf04728378a1b4f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project are documented here. The format is based
4
4
  on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] - 2026-05-25
8
+
9
+ ### Added
10
+ - `Pandoru::Credentials.resolve` — tiered credential resolution: explicit args →
11
+ `PANDORA_USERNAME`/`PANDORA_PASSWORD` env → OS secret store → JSON file at
12
+ `$PANDORU_CREDENTIALS` / `~/.config/pandoru/credentials.json` (first complete
13
+ pair wins; blank values fall through).
14
+ - `Pandoru::SecretStore` — portable OS secret storage (macOS Keychain, Linux
15
+ libsecret; Windows falls back to the file tier) so the password need not live
16
+ in plaintext.
17
+ - `pandoru-login` executable — prompt for credentials and store them in the OS
18
+ secret store.
19
+
20
+ ## [0.1.1] - 2026-05-25
21
+
22
+ ### Fixed
23
+ - Transport sent plaintext HTTP to the TLS port (`http://host:443`) for any
24
+ method not in `REQUIRE_TLS` (e.g. `user.getStationList`), which Pandora drops
25
+ with an empty reply — breaking every authenticated call except login and
26
+ `getPlaylist`. The request scheme now follows the transport's configured TLS
27
+ setting, so TLS hosts use https for all methods.
28
+ - `Station#add_seed` passed its arguments to `add_music` in the wrong order
29
+ (station and music tokens swapped), producing a malformed `station.addMusic`
30
+ request. It now calls `add_music(music_token, token)`.
31
+
7
32
  ## [0.1.0] - 2026-05-25
8
33
 
9
34
  Initial public release. A Ruby port of pydora (tracking upstream `pydora 2.3.1`)
@@ -27,3 +52,7 @@ targeting Pandora's partner/device JSON API (`tuner.pandora.com/services/json/`)
27
52
  INVALID_PARTNER_LOGIN.
28
53
  - Corrected the encryption/decryption key orientation in the bundled default
29
54
  partner settings.
55
+
56
+ [0.2.0]: https://github.com/TwilightCoders/pandoru/releases/tag/v0.2.0
57
+ [0.1.1]: https://github.com/TwilightCoders/pandoru/releases/tag/v0.1.1
58
+ [0.1.0]: https://github.com/TwilightCoders/pandoru/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,8 +1,13 @@
1
+ [![License ](https://img.shields.io/github/license/TwilightCoders/pandoru.svg)]()
2
+ [![Version ](https://img.shields.io/gem/v/pandoru.svg)](https://rubygems.org/gems/pandoru)
3
+ [![Build Status ](https://travis-ci.org/TwilightCoders/pandoru.svg)](https://travis-ci.org/TwilightCoders/pandoru)
4
+ [![Maintainability](https://qlty.sh/gh/TwilightCoders/projects/pandoru/maintainability.svg)](https://qlty.sh/gh/TwilightCoders/projects/pandoru)
5
+ [![Code Coverage ](https://qlty.sh/gh/TwilightCoders/projects/pandoru/coverage.svg)](https://qlty.sh/gh/TwilightCoders/projects/pandoru)
1
6
  # Pandoru
2
7
 
3
8
  **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
9
 
5
- > **Note**: This is an unofficial API client. Use at your own risk and respect Pandora's terms of service.
10
+ > ⚠️ **Note**: This is an unofficial API client. Use at your own risk and respect Pandora's terms of service.
6
11
 
7
12
  ---
8
13
 
@@ -260,4 +265,4 @@ This Ruby gem is a port of the excellent [pydora](https://github.com/mcrute/pydo
260
265
 
261
266
  ## Disclaimer
262
267
 
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.
268
+ > ⚠️ 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.
data/exe/pandoru-login ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Store Pandora credentials in the OS secret store (macOS Keychain, Linux
5
+ # libsecret) so no password lives in a plaintext file. The client reads them at
6
+ # runtime via Pandoru::Credentials.
7
+ #
8
+ # pandoru-login # prompt + store
9
+ # pandoru-login --delete # remove the stored item
10
+
11
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
12
+
13
+ require 'pandoru'
14
+ require 'io/console'
15
+
16
+ store = Pandoru::SecretStore
17
+
18
+ unless store.available?
19
+ warn "No OS secret store available on this platform (#{store.backend_name})."
20
+ warn 'Fall back to a credentials file — see README "Credentials".'
21
+ exit 1
22
+ end
23
+
24
+ if ARGV[0] == '--delete'
25
+ removed = store.delete
26
+ puts removed ? 'Removed Pandora credentials from the secret store.' : 'Nothing stored.'
27
+ exit removed ? 0 : 1
28
+ end
29
+
30
+ print 'Pandora email/username: '
31
+ username = $stdin.gets&.strip
32
+ abort 'Aborted — no username.' if username.nil? || username.empty?
33
+
34
+ print 'Password (hidden): '
35
+ password = $stdin.noecho(&:gets)&.chomp
36
+ puts
37
+ abort 'Aborted — no password.' if password.nil? || password.empty?
38
+
39
+ if store.store(username, password)
40
+ puts "✓ Stored in #{store.backend_name} (account: #{username})."
41
+ puts 'No password on disk — the client reads it at runtime.'
42
+ else
43
+ abort 'Failed to store credentials in the secret store.'
44
+ end
@@ -0,0 +1,109 @@
1
+ require 'json'
2
+
3
+ module Pandoru
4
+ # Resolves Pandora account credentials from the first source that supplies a
5
+ # complete pair, so the client/CLI works whether you prefer env vars, the OS
6
+ # secret store, or a config file. Empty values count as absent, so an
7
+ # unset/blank env var gracefully falls through to the next source rather than
8
+ # half-authenticating.
9
+ #
10
+ # Precedence (highest first), mirroring the common config-resolution standard
11
+ # (explicit wins; env for 12-factor/CI; XDG Base Directory for the file):
12
+ #
13
+ # 1. Explicit values passed in (tests, embedding).
14
+ # 2. ENV PANDORA_USERNAME / PANDORA_PASSWORD.
15
+ # 3. The OS secret store (Keychain / libsecret), if a credential is stored.
16
+ # 4. A JSON file { "username": ..., "password": ... } at the first that
17
+ # exists, in order:
18
+ # $PANDORU_CREDENTIALS (explicit path override)
19
+ # $XDG_CONFIG_HOME/pandoru/credentials.json (XDG; default ~/.config)
20
+ #
21
+ # A future tier should delegate to Pandoru::ClientBuilders for the existing
22
+ # pydora `.cfg` / pianobar config files rather than reimplementing that
23
+ # parsing — tracked as a follow-up.
24
+ class Credentials
25
+ class NotFound < StandardError; end
26
+
27
+ Resolved = Struct.new(:username, :password, :source, keyword_init: true)
28
+
29
+ USERNAME_KEYS = %w[username user email].freeze
30
+ PASSWORD_KEYS = %w[password pass].freeze
31
+
32
+ def self.resolve(username: nil, password: nil, env: ENV, home: Dir.home, secret_store: SecretStore)
33
+ new(env: env, home: home, secret_store: secret_store)
34
+ .resolve(username: username, password: password)
35
+ end
36
+
37
+ def initialize(env: ENV, home: Dir.home, secret_store: SecretStore)
38
+ @env = env
39
+ @home = home
40
+ @secret_store = secret_store
41
+ end
42
+
43
+ def resolve(username: nil, password: nil)
44
+ if present?(username) && present?(password)
45
+ return Resolved.new(username: username, password: password, source: :explicit)
46
+ end
47
+
48
+ if present?(@env['PANDORA_USERNAME']) && present?(@env['PANDORA_PASSWORD'])
49
+ return Resolved.new(username: @env['PANDORA_USERNAME'],
50
+ password: @env['PANDORA_PASSWORD'], source: :env)
51
+ end
52
+
53
+ if @secret_store && (creds = @secret_store.fetch)
54
+ return Resolved.new(username: creds[0], password: creds[1], source: :secret_store)
55
+ end
56
+
57
+ candidate_files.each do |path|
58
+ next unless File.file?(path)
59
+ creds = parse_file(path)
60
+ next unless creds
61
+ return Resolved.new(username: creds[0], password: creds[1], source: path)
62
+ end
63
+
64
+ raise NotFound, not_found_message
65
+ end
66
+
67
+ # Ordered list of JSON credential paths to try.
68
+ def candidate_files
69
+ paths = []
70
+ paths << @env['PANDORU_CREDENTIALS'] if present?(@env['PANDORU_CREDENTIALS'])
71
+ paths << File.join(xdg_config_home, 'pandoru', 'credentials.json')
72
+ paths.uniq
73
+ end
74
+
75
+ private
76
+
77
+ def xdg_config_home
78
+ present?(@env['XDG_CONFIG_HOME']) ? @env['XDG_CONFIG_HOME'] : File.join(@home, '.config')
79
+ end
80
+
81
+ # Returns [username, password] from a JSON file, or nil if unreadable or
82
+ # incomplete (so resolution continues to the next candidate).
83
+ def parse_file(path)
84
+ data = JSON.parse(File.read(path))
85
+ return nil unless data.is_a?(Hash)
86
+
87
+ username = USERNAME_KEYS.map { |k| data[k] }.find { |v| present?(v) }
88
+ password = PASSWORD_KEYS.map { |k| data[k] }.find { |v| present?(v) }
89
+ present?(username) && present?(password) ? [username, password] : nil
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+
94
+ def present?(value)
95
+ !value.nil? && !value.to_s.strip.empty?
96
+ end
97
+
98
+ def not_found_message
99
+ checked = ['PANDORA_USERNAME / PANDORA_PASSWORD (env)', 'OS secret store (run pandoru-login)'] + candidate_files
100
+ <<~MSG.strip
101
+ No Pandora credentials found. Checked, in order:
102
+ #{checked.map { |c| " - #{c}" }.join("\n")}
103
+ Store them with `pandoru-login`, provide a JSON file like
104
+ { "username": "you@example.com", "password": "…" }, or set the
105
+ PANDORA_USERNAME / PANDORA_PASSWORD environment variables.
106
+ MSG
107
+ end
108
+ end
109
+ end
@@ -169,7 +169,8 @@ module Pandoru
169
169
 
170
170
  def add_seed(music_token)
171
171
  return false unless allow_add_music && @api_client
172
- @api_client.add_music(token, music_token)
172
+ # add_music expects (music_token, station_token) — music token first.
173
+ @api_client.add_music(music_token, token)
173
174
  true
174
175
  end
175
176
  end
@@ -0,0 +1,173 @@
1
+ require 'open3'
2
+ require 'json'
3
+
4
+ module Pandoru
5
+ # Portable secret storage: keeps the Pandora credential out of any plaintext
6
+ # file by delegating to the host OS's native secret service. There's no solid
7
+ # cross-platform Ruby gem for this, so we shell out to each platform's tool,
8
+ # the way fastlane et al. do:
9
+ #
10
+ # macOS → security (Keychain)
11
+ # Linux → secret-tool (libsecret / Secret Service: GNOME Keyring, KWallet)
12
+ # Windows → Credential Manager (not yet wired — see Adapters::Windows)
13
+ #
14
+ # The credential is stored as a single JSON blob { "username", "password" }
15
+ # under one fixed key, so retrieval is uniform across backends and we never
16
+ # need to enumerate the store to discover the username. Everything degrades
17
+ # gracefully: with no working backend, fetch returns nil and the resolver
18
+ # falls through to the config file.
19
+ module SecretStore
20
+ SERVICE = 'pandoru'
21
+
22
+ # Shells out, returning [stdout, success?]. Injectable so adapters can be
23
+ # unit-tested without touching a real keychain.
24
+ DEFAULT_RUNNER = lambda do |cmd, stdin_data|
25
+ opts = { err: File::NULL }
26
+ opts[:stdin_data] = stdin_data if stdin_data
27
+ out, status = Open3.capture2(*cmd, **opts)
28
+ [out, status.success?]
29
+ rescue Errno::ENOENT
30
+ ['', false]
31
+ end
32
+
33
+ module_function
34
+
35
+ # The first available adapter for this host, or a Null adapter.
36
+ def adapter(runner: DEFAULT_RUNNER)
37
+ [Adapters::MacOS, Adapters::SecretTool, Adapters::Windows]
38
+ .map { |klass| klass.new(runner: runner) }
39
+ .find(&:available?) || Adapters::Null.new(runner: runner)
40
+ end
41
+
42
+ def available?(adapter: adapter())
43
+ !adapter.is_a?(Adapters::Null)
44
+ end
45
+
46
+ def backend_name(adapter: adapter())
47
+ adapter.name
48
+ end
49
+
50
+ # [username, password] from the store, or nil if absent/unreadable.
51
+ def fetch(service: SERVICE, adapter: adapter())
52
+ raw = adapter.read(service)
53
+ return nil if raw.nil? || raw.strip.empty?
54
+
55
+ data = JSON.parse(raw)
56
+ username = data['username']
57
+ password = data['password']
58
+ return nil unless present?(username) && present?(password)
59
+
60
+ [username, password]
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+
65
+ def store(username, password, service: SERVICE, adapter: adapter())
66
+ adapter.write(service, JSON.generate('username' => username, 'password' => password))
67
+ end
68
+
69
+ def delete(service: SERVICE, adapter: adapter())
70
+ adapter.delete(service)
71
+ end
72
+
73
+ def present?(value)
74
+ !value.nil? && !value.to_s.strip.empty?
75
+ end
76
+
77
+ # Per-OS adapters. Each maps the generic read/write/delete of an opaque
78
+ # secret string to the native CLI; all I/O goes through the injected runner.
79
+ module Adapters
80
+ class Base
81
+ def initialize(runner: DEFAULT_RUNNER)
82
+ @runner = runner
83
+ end
84
+
85
+ def name = self.class.name.split('::').last
86
+
87
+ private
88
+
89
+ def run(cmd, stdin_data: nil)
90
+ @runner.call(cmd, stdin_data)
91
+ end
92
+
93
+ def which?(tool)
94
+ _out, ok = run(['which', tool])
95
+ ok
96
+ end
97
+ end
98
+
99
+ # macOS Keychain via `security`. Account is fixed to the service name; the
100
+ # secret blob is the password slot.
101
+ class MacOS < Base
102
+ def name = 'macOS Keychain'
103
+
104
+ def available?
105
+ RUBY_PLATFORM.include?('darwin') && which?('security')
106
+ end
107
+
108
+ def read(service)
109
+ out, ok = run(['security', 'find-generic-password', '-s', service, '-w'])
110
+ ok ? out.chomp : nil
111
+ end
112
+
113
+ def write(service, secret)
114
+ _out, ok = run(['security', 'add-generic-password', '-U',
115
+ '-s', service, '-a', service, '-w', secret,
116
+ '-D', 'application password', '-l', service])
117
+ ok
118
+ end
119
+
120
+ def delete(service)
121
+ _out, ok = run(['security', 'delete-generic-password', '-s', service])
122
+ ok
123
+ end
124
+ end
125
+
126
+ # Linux libsecret via `secret-tool` (Secret Service: GNOME Keyring/KWallet).
127
+ class SecretTool < Base
128
+ def name = 'libsecret (secret-tool)'
129
+
130
+ def available?
131
+ RUBY_PLATFORM.include?('linux') && which?('secret-tool')
132
+ end
133
+
134
+ def read(service)
135
+ out, ok = run(['secret-tool', 'lookup', 'service', service])
136
+ ok ? out.chomp : nil
137
+ end
138
+
139
+ def write(service, secret)
140
+ _out, ok = run(['secret-tool', 'store', '--label', service, 'service', service],
141
+ stdin_data: secret)
142
+ ok
143
+ end
144
+
145
+ def delete(service)
146
+ _out, ok = run(['secret-tool', 'clear', 'service', service])
147
+ ok
148
+ end
149
+ end
150
+
151
+ # Windows Credential Manager. Left unwired: doing it without a gem means a
152
+ # PowerShell + Win32 CredRead/CredWrite P/Invoke shim, which can't be
153
+ # verified here. Reports unavailable so Windows falls back to the config
154
+ # file. Implement read/write/delete against `cmdkey`/PowerShell to enable.
155
+ class Windows < Base
156
+ def name = 'Windows Credential Manager (unsupported)'
157
+ def available? = false
158
+ def read(_service) = nil
159
+ def write(_service, _secret) = false
160
+ def delete(_service) = false
161
+ end
162
+
163
+ # No native store available — everything no-ops, resolver uses the file.
164
+ class Null < Base
165
+ def name = 'none'
166
+ def available? = false
167
+ def read(_service) = nil
168
+ def write(_service, _secret) = false
169
+ def delete(_service) = false
170
+ end
171
+ end
172
+ end
173
+ end
@@ -308,7 +308,12 @@ module Pandoru
308
308
  end
309
309
 
310
310
  def build_url(method, params = {})
311
- protocol = REQUIRE_TLS.include?(method) ? "https" : "http"
311
+ # Pandora now requires TLS on every endpoint, so follow the transport's
312
+ # configured scheme uniformly. The old per-method REQUIRE_TLS downgrade
313
+ # built http://host:443 for non-listed methods (e.g. user.getStationList)
314
+ # — plaintext to the TLS port, which Pandora drops (EOF). Protocol and
315
+ # port must agree.
316
+ protocol = @api_tls ? "https" : "http"
312
317
  port = @api_tls ? 443 : 80
313
318
  # Only include port if it's non-standard
314
319
  port_string = (protocol == "https" && port == 443) || (protocol == "http" && port == 80) ? "" : ":#{port}"
@@ -1,3 +1,3 @@
1
1
  module Pandoru
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/pandoru.rb CHANGED
@@ -4,6 +4,8 @@ require_relative 'pandoru/transport'
4
4
  require_relative 'pandoru/client'
5
5
  require_relative 'pandoru/client_builder'
6
6
  require_relative 'pandoru/models'
7
+ require_relative 'pandoru/secret_store'
8
+ require_relative 'pandoru/credentials'
7
9
  require 'pathname'
8
10
  require 'logger'
9
11
 
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pandoru
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dale Stevens
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -163,20 +163,51 @@ dependencies:
163
163
  - - "~>"
164
164
  - !ruby/object:Gem::Version
165
165
  version: '6.0'
166
+ - !ruby/object:Gem::Dependency
167
+ name: simplecov
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '0.22'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '0.22'
180
+ - !ruby/object:Gem::Dependency
181
+ name: simplecov-lcov
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '0.8'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '0.8'
166
194
  description: A comprehensive Ruby client for the Pandora music streaming API, providing
167
195
  access to stations, playlists, search, and user management features.
168
196
  email:
169
197
  - dale@twilightcoders.net
170
- executables: []
198
+ executables:
199
+ - pandoru-login
171
200
  extensions: []
172
201
  extra_rdoc_files: []
173
202
  files:
174
203
  - CHANGELOG.md
175
204
  - LICENSE
176
205
  - README.md
206
+ - exe/pandoru-login
177
207
  - lib/pandoru.rb
178
208
  - lib/pandoru/client.rb
179
209
  - lib/pandoru/client_builder.rb
210
+ - lib/pandoru/credentials.rb
180
211
  - lib/pandoru/errors.rb
181
212
  - lib/pandoru/models.rb
182
213
  - lib/pandoru/models/_base.rb
@@ -185,6 +216,7 @@ files:
185
216
  - lib/pandoru/models/search.rb
186
217
  - lib/pandoru/models/station.rb
187
218
  - lib/pandoru/models/track_explanation.rb
219
+ - lib/pandoru/secret_store.rb
188
220
  - lib/pandoru/transport.rb
189
221
  - lib/pandoru/version.rb
190
222
  homepage: https://github.com/TwilightCoders/pandoru
@@ -199,7 +231,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
199
231
  requirements:
200
232
  - - ">="
201
233
  - !ruby/object:Gem::Version
202
- version: '2.7'
234
+ version: '3.0'
203
235
  required_rubygems_version: !ruby/object:Gem::Requirement
204
236
  requirements:
205
237
  - - ">="