rocksky 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 +8 -0
- data/LICENSE +21 -0
- data/README.md +224 -0
- data/exe/rocksky-console +33 -0
- data/lib/rocksky/client.rb +84 -0
- data/lib/rocksky/error.rb +58 -0
- data/lib/rocksky/http.rb +123 -0
- data/lib/rocksky/resources/actor.rb +60 -0
- data/lib/rocksky/resources/album.rb +22 -0
- data/lib/rocksky/resources/apikey.rb +28 -0
- data/lib/rocksky/resources/artist.rb +40 -0
- data/lib/rocksky/resources/base.rb +24 -0
- data/lib/rocksky/resources/charts.rb +33 -0
- data/lib/rocksky/resources/feed.rb +51 -0
- data/lib/rocksky/resources/graph.rb +34 -0
- data/lib/rocksky/resources/like.rb +26 -0
- data/lib/rocksky/resources/mirror.rb +22 -0
- data/lib/rocksky/resources/player.rb +73 -0
- data/lib/rocksky/resources/playlist.rb +45 -0
- data/lib/rocksky/resources/scrobble.rb +36 -0
- data/lib/rocksky/resources/shout.rb +58 -0
- data/lib/rocksky/resources/song.rb +38 -0
- data/lib/rocksky/resources/spotify.rb +37 -0
- data/lib/rocksky/resources/stats.rb +16 -0
- data/lib/rocksky/version.rb +3 -0
- data/lib/rocksky.rb +32 -0
- data/rocksky.gemspec +41 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e48d42415ab7937416c2e008a6b637b4e2aef4474f60aae824cf1e089a3814f3
|
|
4
|
+
data.tar.gz: eefc4a0023800e5f0d03a4fd41837589b9fc02bd49e4244bcd7a260bc88895a6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 11773d9ea99df5d975d9c81636a3cd0058ea69a4a2cfcb9aa7b2579fbdcd0350f7b7c80ef34e4dadadd5b55232fd4d458773a319449d034118e7f436fafab71b
|
|
7
|
+
data.tar.gz: 94cf1dba5ddb40fb27781044b27bf3cbe1e3cea3720e522541f4db5ab85d1431b1992af384db625128f13c3971a7a543f22f2747e0e011b936d4de3092219a57
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial release. Coverage for all `app.rocksky.*` XRPC endpoints across the
|
|
6
|
+
actor, album, apikey, artist, charts, feed, graph, like, mirror, player,
|
|
7
|
+
playlist, scrobble, shout, song, spotify, and stats namespaces.
|
|
8
|
+
- `rocksky-console` and `bin/console` IRB entrypoints.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tsiry Sandratraina <tsiry.sndr@rocksky.app>
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# rocksky (Ruby)
|
|
2
|
+
|
|
3
|
+
Idiomatic Ruby client for the [Rocksky](https://rocksky.app) XRPC API.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
client = Rocksky.new(token: ENV["ROCKSKY_TOKEN"])
|
|
7
|
+
|
|
8
|
+
client.actor.get_profile(did: "tsiry-sandratraina.com")
|
|
9
|
+
client.charts.get_top_artists(limit: 10, start_date: "2025-01-01")
|
|
10
|
+
client.scrobble.create_scrobble(title: "In Bloom", artist: "Nirvana")
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Every XRPC NSID maps to a method on a resource object. `app.rocksky.actor.getProfile`
|
|
14
|
+
becomes `client.actor.get_profile(...)`. `app.rocksky.scrobble.createScrobble`
|
|
15
|
+
becomes `client.scrobble.create_scrobble(...)`. No magic — just kwargs in,
|
|
16
|
+
parsed JSON out.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Add it to your `Gemfile`:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
gem "rocksky"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install directly:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem install rocksky
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Ruby 3.0+. The SDK depends only on Ruby's stdlib (`net/http`, `json`, `uri`).
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require "rocksky"
|
|
38
|
+
|
|
39
|
+
# Reads ROCKSKY_BASE_URL and ROCKSKY_TOKEN from the env when omitted.
|
|
40
|
+
client = Rocksky.new
|
|
41
|
+
|
|
42
|
+
profile = client.actor.get_profile(did: "tsiry-sandratraina.com")
|
|
43
|
+
puts profile["displayName"]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For authenticated calls, pass a Bluesky-issued Bearer token (see
|
|
47
|
+
[lexicons documentation](https://github.com/rocksky/rocksky/blob/main/LEXICONS.md)):
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
client = Rocksky.new(token: "eyJ...")
|
|
51
|
+
client.scrobble.create_scrobble(title: "In Bloom", artist: "Nirvana")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`with_token` derives a new client without mutating the original — useful in
|
|
55
|
+
web apps that share one base client across users:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
base = Rocksky.new
|
|
59
|
+
def for_user(base, token) = base.with_token(token)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Resources
|
|
63
|
+
|
|
64
|
+
| Resource | Methods |
|
|
65
|
+
|----------|---------|
|
|
66
|
+
| `client.actor` | `get_profile`, `get_actor_albums/artists/songs/scrobbles/playlists/loved_songs`, `get_actor_neighbours`, `get_actor_compatibility` |
|
|
67
|
+
| `client.album` | `get_album`, `get_albums`, `get_album_tracks` |
|
|
68
|
+
| `client.apikey` | `get_apikeys`, `create_apikey`, `update_apikey`, `remove_apikey` *(auth)* |
|
|
69
|
+
| `client.artist` | `get_artist`, `get_artists`, `get_artist_albums/tracks/listeners/recent_listeners` |
|
|
70
|
+
| `client.charts` | `get_scrobbles_chart`, `get_top_artists`, `get_top_tracks` |
|
|
71
|
+
| `client.feed` | `search`, `get_feed_generators/generator/feed`, `get_stories`, `get_recommendations`, `get_artist_recommendations`, `get_album_recommendations` |
|
|
72
|
+
| `client.graph` | `follow_account`, `unfollow_account`, `get_followers`, `get_follows`, `get_known_followers` *(auth)* |
|
|
73
|
+
| `client.like` | `like_song`, `dislike_song`, `like_shout`, `dislike_shout` *(auth)* |
|
|
74
|
+
| `client.mirror` | `get_mirror_sources`, `put_mirror_source` *(auth)* |
|
|
75
|
+
| `client.player` | `play`, `pause`, `next`, `previous`, `seek`, `play_file`, `play_directory`, `add_items_to_queue`, `get_currently_playing`, `get_playback_queue` |
|
|
76
|
+
| `client.playlist` | `get_playlist`, `get_playlists`, `create_playlist`, `remove_playlist`, `start_playlist`, `insert_files`, `insert_directory` |
|
|
77
|
+
| `client.scrobble` | `create_scrobble`, `get_scrobble`, `get_scrobbles` |
|
|
78
|
+
| `client.shout` | `create_shout`, `reply_shout`, `remove_shout`, `report_shout`, `get_*_shouts`, `get_shout_replies` |
|
|
79
|
+
| `client.song` | `get_song`, `get_songs`, `get_song_recent_listeners`, `match_song`, `create_song` |
|
|
80
|
+
| `client.spotify` | `play`, `pause`, `next`, `previous`, `seek`, `get_currently_playing` *(auth)* |
|
|
81
|
+
| `client.stats` | `get_stats`, `get_wrapped` |
|
|
82
|
+
|
|
83
|
+
For anything not covered, drop down to the raw transport:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
client.query("app.rocksky.actor.getProfile", did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr")
|
|
87
|
+
client.procedure("app.rocksky.like.likeSong", body: { uri: "at://..." })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Conventions
|
|
91
|
+
|
|
92
|
+
- **Keyword args** for every parameter. Ruby `snake_case` names map to the
|
|
93
|
+
lexicon's `camelCase` (e.g. `start_date:` → `startDate`).
|
|
94
|
+
- **`nil` is dropped.** Pass `nil` for any optional param and it won't be sent.
|
|
95
|
+
- **Arrays are CSV-joined.** Lexicon list params like `names:` accept Ruby arrays:
|
|
96
|
+
`client.artist.get_artists(names: %w[Nirvana Pixies])`.
|
|
97
|
+
- **Hashes in, Hashes out.** Responses come back as plain `Hash` (string keys) —
|
|
98
|
+
no DSL, no model classes. Match the shape of the lexicon JSON 1:1.
|
|
99
|
+
|
|
100
|
+
## Error handling
|
|
101
|
+
|
|
102
|
+
Every non-2xx response raises a subclass of `Rocksky::Error`:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
begin
|
|
106
|
+
client.song.get_song(uri: "at://does-not-exist")
|
|
107
|
+
rescue Rocksky::NotFound => e
|
|
108
|
+
puts "missing: #{e.message} (status=#{e.status}, nsid=#{e.nsid})"
|
|
109
|
+
rescue Rocksky::RateLimited
|
|
110
|
+
sleep 5; retry
|
|
111
|
+
rescue Rocksky::Error => e
|
|
112
|
+
warn "rocksky failure: #{e.class}: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Class | Status |
|
|
117
|
+
|-------|--------|
|
|
118
|
+
| `Rocksky::BadRequest` | 400 |
|
|
119
|
+
| `Rocksky::Unauthorized` | 401 |
|
|
120
|
+
| `Rocksky::Forbidden` | 403 |
|
|
121
|
+
| `Rocksky::NotFound` | 404 |
|
|
122
|
+
| `Rocksky::RateLimited` | 429 |
|
|
123
|
+
| `Rocksky::ServerError` | 5xx |
|
|
124
|
+
| `Rocksky::HTTPError` | any other non-2xx |
|
|
125
|
+
| `Rocksky::TransportError` | DNS/TCP/timeouts |
|
|
126
|
+
|
|
127
|
+
## IRB console
|
|
128
|
+
|
|
129
|
+
The gem ships with a `rocksky-console` executable: an IRB session
|
|
130
|
+
pre-loaded with a `client` bound to your environment.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
$ gem install rocksky
|
|
134
|
+
$ ROCKSKY_TOKEN=eyJ... rocksky-console
|
|
135
|
+
Rocksky 0.1.0 — interactive console
|
|
136
|
+
base_url : https://api.rocksky.app
|
|
137
|
+
token : present (set via ROCKSKY_TOKEN)
|
|
138
|
+
|
|
139
|
+
A client is bound to `client`. Try:
|
|
140
|
+
client.actor.get_profile(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr")
|
|
141
|
+
client.charts.get_top_artists(limit: 10)
|
|
142
|
+
|
|
143
|
+
irb> client.actor.get_profile(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr")
|
|
144
|
+
=> {"did"=>"did:plc:...", "handle"=>"tsiry-sandratraina.com", ...}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### From a checkout (development)
|
|
148
|
+
|
|
149
|
+
If you've cloned the repo, use `bin/console` instead. It loads the local
|
|
150
|
+
source tree, so edits to `lib/` are picked up on the next `reload!`-style
|
|
151
|
+
restart:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
$ cd sdk/ruby
|
|
155
|
+
$ bundle install
|
|
156
|
+
$ bin/console
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Ad-hoc IRB (no script)
|
|
160
|
+
|
|
161
|
+
You can always launch IRB yourself:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
$ irb -rrocksky
|
|
165
|
+
irb> client = Rocksky.new(token: ENV["ROCKSKY_TOKEN"])
|
|
166
|
+
irb> client.charts.get_top_tracks(limit: 5)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Useful IRB recipes
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
# Pretty-print responses
|
|
173
|
+
require "json"
|
|
174
|
+
puts JSON.pretty_generate(client.actor.get_profile(did: "did:plc:7vdlgi2bflelz7mmuxoqjfcr"))
|
|
175
|
+
|
|
176
|
+
# Inspect what the SDK is about to send
|
|
177
|
+
client = Rocksky.new(headers: { "X-Debug" => "1" })
|
|
178
|
+
|
|
179
|
+
# Try things against staging without touching prod
|
|
180
|
+
client = Rocksky.new(base_url: "https://api.staging.rocksky.app")
|
|
181
|
+
|
|
182
|
+
# Tighter timeouts in a script
|
|
183
|
+
client = Rocksky.new(open_timeout: 2, read_timeout: 5)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Configuration
|
|
187
|
+
|
|
188
|
+
| Option | Default | Env var |
|
|
189
|
+
|----------------|-------------------------------|-----------------------|
|
|
190
|
+
| `base_url` | `https://api.rocksky.app` | `ROCKSKY_BASE_URL` |
|
|
191
|
+
| `token` | `nil` | `ROCKSKY_TOKEN` |
|
|
192
|
+
| `headers` | `{}` | — |
|
|
193
|
+
| `user_agent` | `rocksky-ruby/<version>` | — |
|
|
194
|
+
| `open_timeout` | `10` seconds | — |
|
|
195
|
+
| `read_timeout` | `30` seconds | — |
|
|
196
|
+
|
|
197
|
+
## Examples
|
|
198
|
+
|
|
199
|
+
The `examples/` directory contains runnable scripts:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
bundle exec ruby examples/01_profile.rb tsiry-sandratraina.com
|
|
203
|
+
bundle exec ruby examples/03_charts.rb
|
|
204
|
+
ROCKSKY_TOKEN=... bundle exec ruby examples/02_scrobble.rb
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
See [examples/README.md](examples/README.md) for the full list.
|
|
208
|
+
|
|
209
|
+
## Development
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
$ bundle install
|
|
213
|
+
$ bundle exec rake test # run the suite
|
|
214
|
+
$ bin/console # IRB with the local source tree
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Tests use Minitest + WebMock — no live network access needed. Add a new
|
|
218
|
+
resource by dropping a file in `lib/rocksky/resources/`, wiring it into
|
|
219
|
+
`lib/rocksky.rb` and `lib/rocksky/client.rb`, and adding tests under
|
|
220
|
+
`test/resources/`.
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
[MIT](LICENSE) © Tsiry Sandratraina.
|
data/exe/rocksky-console
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# rocksky-console — IRB session pre-loaded with a `client` bound to your env.
|
|
3
|
+
#
|
|
4
|
+
# $ ROCKSKY_TOKEN=... rocksky-console
|
|
5
|
+
# irb> client.actor.get_profile(did: "alice.bsky.social")
|
|
6
|
+
#
|
|
7
|
+
# Without ROCKSKY_TOKEN, the client is unauthenticated (read-only endpoints
|
|
8
|
+
# still work).
|
|
9
|
+
|
|
10
|
+
require "irb"
|
|
11
|
+
require "rocksky"
|
|
12
|
+
|
|
13
|
+
client = Rocksky.new
|
|
14
|
+
|
|
15
|
+
puts <<~BANNER
|
|
16
|
+
Rocksky #{Rocksky::VERSION} — interactive console
|
|
17
|
+
base_url : #{client.base_url}
|
|
18
|
+
token : #{client.token ? "present (set via ROCKSKY_TOKEN)" : "none — set ROCKSKY_TOKEN for auth"}
|
|
19
|
+
|
|
20
|
+
A client is bound to `client`. Try:
|
|
21
|
+
client.actor.get_profile(did: "alice.bsky.social")
|
|
22
|
+
client.charts.get_top_artists(limit: 10)
|
|
23
|
+
|
|
24
|
+
BANNER
|
|
25
|
+
|
|
26
|
+
IRB.setup(nil)
|
|
27
|
+
IRB.conf[:USE_MULTILINE] = false if IRB.conf.key?(:USE_MULTILINE)
|
|
28
|
+
|
|
29
|
+
workspace = IRB::WorkSpace.new(binding)
|
|
30
|
+
workspace.main.instance_variable_set(:@client, client)
|
|
31
|
+
workspace.main.define_singleton_method(:client) { @client }
|
|
32
|
+
|
|
33
|
+
IRB::Irb.new(workspace).run(IRB.conf)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Rocksky
|
|
2
|
+
# Top-level client for the Rocksky XRPC API.
|
|
3
|
+
#
|
|
4
|
+
# client = Rocksky.new(token: ENV["ROCKSKY_TOKEN"])
|
|
5
|
+
# client.actor.get_profile(did: "alice.bsky.social")
|
|
6
|
+
#
|
|
7
|
+
# Resources are lazily instantiated and memoised: `client.actor`, `client.album`,
|
|
8
|
+
# `client.artist`, `client.scrobble`, etc.
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.rocksky.app".freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :base_url, :token, :headers, :user_agent,
|
|
13
|
+
:open_timeout, :read_timeout
|
|
14
|
+
|
|
15
|
+
def initialize(
|
|
16
|
+
base_url: nil,
|
|
17
|
+
token: nil,
|
|
18
|
+
headers: {},
|
|
19
|
+
user_agent: "rocksky-ruby/#{Rocksky::VERSION}",
|
|
20
|
+
open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
|
|
21
|
+
read_timeout: HTTP::DEFAULT_READ_TIMEOUT
|
|
22
|
+
)
|
|
23
|
+
@base_url = normalize_base_url(base_url || ENV["ROCKSKY_BASE_URL"] || DEFAULT_BASE_URL)
|
|
24
|
+
@token = token || ENV["ROCKSKY_TOKEN"]
|
|
25
|
+
@headers = headers.dup
|
|
26
|
+
@user_agent = user_agent
|
|
27
|
+
@open_timeout = open_timeout
|
|
28
|
+
@read_timeout = read_timeout
|
|
29
|
+
@http = HTTP.new(self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Return a derived client that uses the given token (everything else copied).
|
|
33
|
+
# Handy for sharing one client across users in a request-scoped server.
|
|
34
|
+
def with_token(new_token)
|
|
35
|
+
self.class.new(
|
|
36
|
+
base_url: base_url,
|
|
37
|
+
token: new_token,
|
|
38
|
+
headers: headers,
|
|
39
|
+
user_agent: user_agent,
|
|
40
|
+
open_timeout: open_timeout,
|
|
41
|
+
read_timeout: read_timeout
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ---- Raw XRPC access ---------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def query(nsid, **params)
|
|
48
|
+
@http.query(nsid, params)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def procedure(nsid, params: {}, body: nil)
|
|
52
|
+
@http.procedure(nsid, params, body)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ---- Resource accessors ------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def actor; @actor ||= Resources::Actor.new(@http); end
|
|
58
|
+
def album; @album ||= Resources::Album.new(@http); end
|
|
59
|
+
def apikey; @apikey ||= Resources::Apikey.new(@http); end
|
|
60
|
+
def artist; @artist ||= Resources::Artist.new(@http); end
|
|
61
|
+
def charts; @charts ||= Resources::Charts.new(@http); end
|
|
62
|
+
def feed; @feed ||= Resources::Feed.new(@http); end
|
|
63
|
+
def graph; @graph ||= Resources::Graph.new(@http); end
|
|
64
|
+
def like; @like ||= Resources::Like.new(@http); end
|
|
65
|
+
def mirror; @mirror ||= Resources::Mirror.new(@http); end
|
|
66
|
+
def player; @player ||= Resources::Player.new(@http); end
|
|
67
|
+
def playlist; @playlist ||= Resources::Playlist.new(@http); end
|
|
68
|
+
def scrobble; @scrobble ||= Resources::Scrobble.new(@http); end
|
|
69
|
+
def shout; @shout ||= Resources::Shout.new(@http); end
|
|
70
|
+
def song; @song ||= Resources::Song.new(@http); end
|
|
71
|
+
def spotify; @spotify ||= Resources::Spotify.new(@http); end
|
|
72
|
+
def stats; @stats ||= Resources::Stats.new(@http); end
|
|
73
|
+
|
|
74
|
+
def inspect
|
|
75
|
+
"#<Rocksky::Client base_url=#{base_url.inspect} token=#{token ? "[FILTERED]" : nil.inspect}>"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def normalize_base_url(url)
|
|
81
|
+
url.to_s.sub(%r{/+\z}, "")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Rocksky
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :status, :body, :nsid
|
|
4
|
+
|
|
5
|
+
def initialize(message, status: nil, body: nil, nsid: nil)
|
|
6
|
+
super(message)
|
|
7
|
+
@status = status
|
|
8
|
+
@body = body
|
|
9
|
+
@nsid = nsid
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.from_response(status, body, nsid: nil)
|
|
13
|
+
message = extract_message(body) || "request failed"
|
|
14
|
+
klass = klass_for(status)
|
|
15
|
+
klass.new(message, status: status, body: body, nsid: nsid)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_transport(cause, nsid: nil)
|
|
19
|
+
TransportError.new(
|
|
20
|
+
"transport error: #{cause.class}: #{cause.message}",
|
|
21
|
+
body: nil,
|
|
22
|
+
nsid: nsid
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.klass_for(status)
|
|
27
|
+
case status
|
|
28
|
+
when 400 then BadRequest
|
|
29
|
+
when 401 then Unauthorized
|
|
30
|
+
when 403 then Forbidden
|
|
31
|
+
when 404 then NotFound
|
|
32
|
+
when 429 then RateLimited
|
|
33
|
+
when 500..599 then ServerError
|
|
34
|
+
else HTTPError
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
private_class_method :klass_for
|
|
38
|
+
|
|
39
|
+
def self.extract_message(body)
|
|
40
|
+
case body
|
|
41
|
+
when Hash
|
|
42
|
+
body["message"] || body["error"] || body[:message] || body[:error]
|
|
43
|
+
when String
|
|
44
|
+
body unless body.empty?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
private_class_method :extract_message
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class HTTPError < Error; end
|
|
51
|
+
class BadRequest < HTTPError; end
|
|
52
|
+
class Unauthorized < HTTPError; end
|
|
53
|
+
class Forbidden < HTTPError; end
|
|
54
|
+
class NotFound < HTTPError; end
|
|
55
|
+
class RateLimited < HTTPError; end
|
|
56
|
+
class ServerError < HTTPError; end
|
|
57
|
+
class TransportError < Error; end
|
|
58
|
+
end
|
data/lib/rocksky/http.rb
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Rocksky
|
|
6
|
+
# Low-level XRPC transport. Most users should go through the resource
|
|
7
|
+
# accessors on {Rocksky::Client} (e.g. `client.actor.get_profile(...)`).
|
|
8
|
+
class HTTP
|
|
9
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
10
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
11
|
+
|
|
12
|
+
attr_reader :client
|
|
13
|
+
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /xrpc/<nsid>?...
|
|
19
|
+
def query(nsid, params = {})
|
|
20
|
+
request(method: :get, nsid: nsid, params: params)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# POST /xrpc/<nsid>?... with optional JSON body.
|
|
24
|
+
def procedure(nsid, params = {}, body = nil)
|
|
25
|
+
request(method: :post, nsid: nsid, params: params, body: body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def request(method:, nsid:, params: {}, body: nil)
|
|
31
|
+
uri = build_uri(nsid, params)
|
|
32
|
+
req = build_request(method, uri, body)
|
|
33
|
+
apply_headers(req)
|
|
34
|
+
|
|
35
|
+
response = perform(uri, req)
|
|
36
|
+
handle_response(response, nsid)
|
|
37
|
+
rescue Timeout::Error, Errno::ECONNREFUSED, SocketError, IOError,
|
|
38
|
+
Net::OpenTimeout, Net::ReadTimeout => e
|
|
39
|
+
raise Error.from_transport(e, nsid: nsid)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_uri(nsid, params)
|
|
43
|
+
url = "#{client.base_url}/xrpc/#{nsid}"
|
|
44
|
+
uri = URI.parse(url)
|
|
45
|
+
encoded = encode_params(params)
|
|
46
|
+
uri.query = URI.encode_www_form(encoded) unless encoded.empty?
|
|
47
|
+
uri
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_request(method, uri, body)
|
|
51
|
+
req =
|
|
52
|
+
case method
|
|
53
|
+
when :get then Net::HTTP::Get.new(uri.request_uri)
|
|
54
|
+
when :post then Net::HTTP::Post.new(uri.request_uri)
|
|
55
|
+
else raise ArgumentError, "unsupported method: #{method}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if body && !body.empty?
|
|
59
|
+
req["content-type"] = "application/json"
|
|
60
|
+
req.body = JSON.generate(body)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
req
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def apply_headers(req)
|
|
67
|
+
req["accept"] = "application/json"
|
|
68
|
+
req["user-agent"] = client.user_agent if client.user_agent
|
|
69
|
+
req["authorization"] = "Bearer #{client.token}" if client.token
|
|
70
|
+
client.headers.each { |name, value| req[name] = value }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def perform(uri, req)
|
|
74
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
75
|
+
http.use_ssl = (uri.scheme == "https")
|
|
76
|
+
http.open_timeout = client.open_timeout
|
|
77
|
+
http.read_timeout = client.read_timeout
|
|
78
|
+
http.request(req)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_response(response, nsid)
|
|
82
|
+
status = response.code.to_i
|
|
83
|
+
body = parse_body(response)
|
|
84
|
+
|
|
85
|
+
return body if status.between?(200, 299)
|
|
86
|
+
|
|
87
|
+
raise Error.from_response(status, body, nsid: nsid)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_body(response)
|
|
91
|
+
return nil if response.body.nil? || response.body.empty?
|
|
92
|
+
|
|
93
|
+
content_type = response["content-type"].to_s
|
|
94
|
+
if content_type.include?("application/json")
|
|
95
|
+
JSON.parse(response.body)
|
|
96
|
+
else
|
|
97
|
+
response.body
|
|
98
|
+
end
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
response.body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Drop nil values; convert symbol keys to strings; join arrays with commas
|
|
104
|
+
# (matches the lexicon-defined array encoding used by other SDKs).
|
|
105
|
+
def encode_params(params)
|
|
106
|
+
return {} if params.nil?
|
|
107
|
+
|
|
108
|
+
Hash(params).each_with_object({}) do |(key, value), out|
|
|
109
|
+
next if value.nil?
|
|
110
|
+
|
|
111
|
+
out[key.to_s] = encode_value(value)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def encode_value(value)
|
|
116
|
+
case value
|
|
117
|
+
when Array then value.join(",")
|
|
118
|
+
when true, false then value.to_s
|
|
119
|
+
else value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Rocksky
|
|
2
|
+
module Resources
|
|
3
|
+
# `app.rocksky.actor.*` endpoints.
|
|
4
|
+
class Actor < Base
|
|
5
|
+
# Fetch a profile by DID or handle.
|
|
6
|
+
def get_profile(did:)
|
|
7
|
+
query("app.rocksky.actor.getProfile", did: did)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Albums an actor has scrobbled.
|
|
11
|
+
def get_actor_albums(did:, limit: nil, offset: nil, start_date: nil, end_date: nil)
|
|
12
|
+
query("app.rocksky.actor.getActorAlbums",
|
|
13
|
+
did: did, limit: limit, offset: offset,
|
|
14
|
+
startDate: start_date, endDate: end_date)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Artists an actor has scrobbled.
|
|
18
|
+
def get_actor_artists(did:, limit: nil, offset: nil, start_date: nil, end_date: nil)
|
|
19
|
+
query("app.rocksky.actor.getActorArtists",
|
|
20
|
+
did: did, limit: limit, offset: offset,
|
|
21
|
+
startDate: start_date, endDate: end_date)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Songs an actor has scrobbled.
|
|
25
|
+
def get_actor_songs(did:, limit: nil, offset: nil, start_date: nil, end_date: nil)
|
|
26
|
+
query("app.rocksky.actor.getActorSongs",
|
|
27
|
+
did: did, limit: limit, offset: offset,
|
|
28
|
+
startDate: start_date, endDate: end_date)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Songs an actor has loved.
|
|
32
|
+
def get_actor_loved_songs(did:, limit: nil, offset: nil)
|
|
33
|
+
query("app.rocksky.actor.getActorLovedSongs",
|
|
34
|
+
did: did, limit: limit, offset: offset)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Scrobbles for an actor.
|
|
38
|
+
def get_actor_scrobbles(did:, limit: nil, offset: nil)
|
|
39
|
+
query("app.rocksky.actor.getActorScrobbles",
|
|
40
|
+
did: did, limit: limit, offset: offset)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Playlists for an actor.
|
|
44
|
+
def get_actor_playlists(did:, limit: nil, offset: nil)
|
|
45
|
+
query("app.rocksky.actor.getActorPlaylists",
|
|
46
|
+
did: did, limit: limit, offset: offset)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Musical neighbours of an actor.
|
|
50
|
+
def get_actor_neighbours(did:)
|
|
51
|
+
query("app.rocksky.actor.getActorNeighbours", did: did)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Compatibility score between the authenticated user and another actor.
|
|
55
|
+
def get_actor_compatibility(did:)
|
|
56
|
+
query("app.rocksky.actor.getActorCompatibility", did: did)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Rocksky
|
|
2
|
+
module Resources
|
|
3
|
+
# `app.rocksky.album.*` endpoints.
|
|
4
|
+
class Album < Base
|
|
5
|
+
# Fetch an album by AT-URI.
|
|
6
|
+
def get_album(uri:)
|
|
7
|
+
query("app.rocksky.album.getAlbum", uri: uri)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# List albums.
|
|
11
|
+
def get_albums(limit: nil, offset: nil, genre: nil)
|
|
12
|
+
query("app.rocksky.album.getAlbums",
|
|
13
|
+
limit: limit, offset: offset, genre: genre)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Tracks belonging to an album.
|
|
17
|
+
def get_album_tracks(uri:)
|
|
18
|
+
query("app.rocksky.album.getAlbumTracks", uri: uri)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Rocksky
|
|
2
|
+
module Resources
|
|
3
|
+
# `app.rocksky.apikey.*` endpoints. All require an authenticated client.
|
|
4
|
+
class Apikey < Base
|
|
5
|
+
# List your API keys.
|
|
6
|
+
def get_apikeys(limit: nil, offset: nil)
|
|
7
|
+
query("app.rocksky.apikey.getApikeys", limit: limit, offset: offset)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Create a new API key.
|
|
11
|
+
def create_apikey(name:, description: nil)
|
|
12
|
+
body = { name: name, description: description }.compact
|
|
13
|
+
procedure("app.rocksky.apikey.createApikey", body: body)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Update an API key.
|
|
17
|
+
def update_apikey(id:, name: nil, description: nil)
|
|
18
|
+
body = { id: id, name: name, description: description }.compact
|
|
19
|
+
procedure("app.rocksky.apikey.updateApikey", body: body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Remove an API key.
|
|
23
|
+
def remove_apikey(id:)
|
|
24
|
+
procedure("app.rocksky.apikey.removeApikey", params: { id: id })
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|