rocksky 0.1.0__tar.gz
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.
- rocksky-0.1.0/.gitignore +23 -0
- rocksky-0.1.0/.python-version +1 -0
- rocksky-0.1.0/LICENSE +21 -0
- rocksky-0.1.0/PKG-INFO +280 -0
- rocksky-0.1.0/README.md +249 -0
- rocksky-0.1.0/examples/follow_feed.py +40 -0
- rocksky-0.1.0/examples/quickstart.py +41 -0
- rocksky-0.1.0/examples/scrobble.py +47 -0
- rocksky-0.1.0/examples/search.py +59 -0
- rocksky-0.1.0/examples/with_builder.py +61 -0
- rocksky-0.1.0/examples/wrapped.py +35 -0
- rocksky-0.1.0/pyproject.toml +73 -0
- rocksky-0.1.0/src/rocksky/__init__.py +89 -0
- rocksky-0.1.0/src/rocksky/_http.py +234 -0
- rocksky-0.1.0/src/rocksky/_version.py +1 -0
- rocksky-0.1.0/src/rocksky/builder.py +157 -0
- rocksky-0.1.0/src/rocksky/client.py +157 -0
- rocksky-0.1.0/src/rocksky/errors.py +85 -0
- rocksky-0.1.0/src/rocksky/models.py +412 -0
- rocksky-0.1.0/src/rocksky/py.typed +0 -0
- rocksky-0.1.0/src/rocksky/resources/__init__.py +41 -0
- rocksky-0.1.0/src/rocksky/resources/_base.py +49 -0
- rocksky-0.1.0/src/rocksky/resources/actor.py +167 -0
- rocksky-0.1.0/src/rocksky/resources/album.py +36 -0
- rocksky-0.1.0/src/rocksky/resources/apikey.py +56 -0
- rocksky-0.1.0/src/rocksky/resources/artist.py +85 -0
- rocksky-0.1.0/src/rocksky/resources/charts.py +82 -0
- rocksky-0.1.0/src/rocksky/resources/feed.py +90 -0
- rocksky-0.1.0/src/rocksky/resources/graph.py +107 -0
- rocksky-0.1.0/src/rocksky/resources/like.py +29 -0
- rocksky-0.1.0/src/rocksky/resources/mirror.py +39 -0
- rocksky-0.1.0/src/rocksky/resources/player.py +119 -0
- rocksky-0.1.0/src/rocksky/resources/playlist.py +88 -0
- rocksky-0.1.0/src/rocksky/resources/scrobble.py +106 -0
- rocksky-0.1.0/src/rocksky/resources/shout.py +102 -0
- rocksky-0.1.0/src/rocksky/resources/song.py +125 -0
- rocksky-0.1.0/src/rocksky/resources/spotify.py +32 -0
- rocksky-0.1.0/src/rocksky/resources/stats.py +27 -0
- rocksky-0.1.0/src/rocksky/resources/storage.py +51 -0
- rocksky-0.1.0/tests/__init__.py +0 -0
- rocksky-0.1.0/tests/conftest.py +36 -0
- rocksky-0.1.0/tests/test_actor.py +89 -0
- rocksky-0.1.0/tests/test_apikey.py +65 -0
- rocksky-0.1.0/tests/test_builder.py +123 -0
- rocksky-0.1.0/tests/test_charts.py +60 -0
- rocksky-0.1.0/tests/test_client.py +99 -0
- rocksky-0.1.0/tests/test_errors.py +76 -0
- rocksky-0.1.0/tests/test_feed.py +92 -0
- rocksky-0.1.0/tests/test_models.py +76 -0
- rocksky-0.1.0/tests/test_retries_and_hooks.py +185 -0
- rocksky-0.1.0/tests/test_scrobble.py +99 -0
- rocksky-0.1.0/tests/test_song.py +78 -0
- rocksky-0.1.0/uv.lock +663 -0
rocksky-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
|
|
6
|
+
.venv/
|
|
7
|
+
.env
|
|
8
|
+
.env.local
|
|
9
|
+
|
|
10
|
+
build/
|
|
11
|
+
dist/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
.eggs/
|
|
14
|
+
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
|
|
21
|
+
.DS_Store
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.10
|
rocksky-0.1.0/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.
|
rocksky-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rocksky
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python SDK for the Rocksky XRPC API
|
|
5
|
+
Project-URL: Homepage, https://rocksky.app
|
|
6
|
+
Project-URL: Documentation, https://docs.rocksky.app
|
|
7
|
+
Project-URL: Repository, https://github.com/tsirysndr/rocksky
|
|
8
|
+
Project-URL: Issues, https://github.com/tsirysndr/rocksky/issues
|
|
9
|
+
Author: Rocksky
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: atproto,bluesky,lastfm,music,rocksky,scrobble
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: httpx>=0.27.0
|
|
28
|
+
Requires-Dist: pydantic>=2.6.0
|
|
29
|
+
Requires-Dist: typing-extensions>=4.10.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# rocksky — Python SDK
|
|
33
|
+
|
|
34
|
+
Async Python SDK for the [Rocksky](https://rocksky.app) XRPC API.
|
|
35
|
+
|
|
36
|
+
- **Async-first** (`asyncio`, built on `httpx.AsyncClient`)
|
|
37
|
+
- **Typed** — Pydantic v2 models for every common entity, snake_case API
|
|
38
|
+
- **Pythonic** — resource-style namespaces (`client.actor`, `client.scrobble`, …)
|
|
39
|
+
- **Escape hatch** — `client.call(method)` for any XRPC method not yet wrapped
|
|
40
|
+
- Works on **Python 3.10+**
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
This package is `uv`-native. From a project of your own:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv add rocksky
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or with `pip`:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install rocksky
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
To work on the SDK itself:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/tsirysndr/rocksky
|
|
60
|
+
cd rocksky/sdk/python
|
|
61
|
+
uv sync # creates .venv, installs runtime + dev deps
|
|
62
|
+
uv run pytest # run the test suite
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
from rocksky import Client
|
|
70
|
+
|
|
71
|
+
async def main() -> None:
|
|
72
|
+
async with Client() as rocksky:
|
|
73
|
+
me = await rocksky.actor.get_profile("tsiry-sandratraina.com")
|
|
74
|
+
print(me.display_name, "—", me.did)
|
|
75
|
+
|
|
76
|
+
recent = await rocksky.scrobble.list(did=me.did, limit=10)
|
|
77
|
+
for s in recent:
|
|
78
|
+
print(f" {s.artist} — {s.title}")
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Authenticating
|
|
84
|
+
|
|
85
|
+
The Rocksky API uses a JWT bearer token. Pass it to the client:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
async with Client(token="eyJhbGciOi…") as rocksky:
|
|
89
|
+
await rocksky.scrobble.create(
|
|
90
|
+
title="Hounds of Love",
|
|
91
|
+
artist="Kate Bush",
|
|
92
|
+
album="Hounds of Love",
|
|
93
|
+
duration=298000,
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
You can also flip tokens mid-session:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
rocksky.set_token(new_jwt)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Self-hosting / custom base URL
|
|
104
|
+
|
|
105
|
+
Default base URL is `https://api.rocksky.app`. Override for self-hosted instances:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
Client(base_url="http://localhost:8000", token=...)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Fluent builder
|
|
112
|
+
|
|
113
|
+
If you prefer chainable configuration over a wide keyword-arg constructor —
|
|
114
|
+
or you want to add retries, logging hooks, or custom headers — use
|
|
115
|
+
`ClientBuilder`:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from rocksky import ClientBuilder
|
|
119
|
+
|
|
120
|
+
rocksky = (
|
|
121
|
+
ClientBuilder()
|
|
122
|
+
.base_url("https://api.rocksky.app")
|
|
123
|
+
.token(os.environ["ROCKSKY_TOKEN"])
|
|
124
|
+
.timeout(10.0)
|
|
125
|
+
.user_agent("my-app/1.0")
|
|
126
|
+
.header("x-request-id", "trace-abc")
|
|
127
|
+
.retries(3, backoff=0.5) # retry transport errors + 5xx
|
|
128
|
+
.on_request(lambda r: log.debug("→ %s %s", r.method, r.url))
|
|
129
|
+
.on_response(lambda r: log.debug("← %s", r.status_code))
|
|
130
|
+
.build()
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async with rocksky:
|
|
134
|
+
profile = await rocksky.actor.get_profile("tsiry-sandratraina.com")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A few notes:
|
|
138
|
+
|
|
139
|
+
- Every setter returns `self`, so chain freely.
|
|
140
|
+
- Hooks may be sync **or** async — the SDK awaits them when needed. They fire
|
|
141
|
+
on every attempt (useful for tracing retries).
|
|
142
|
+
- `retries(n)` retries on `TransportError` and any `5xx` response with
|
|
143
|
+
exponential backoff (`backoff * 2**attempt`). `4xx` responses are surfaced
|
|
144
|
+
immediately.
|
|
145
|
+
- `Client.builder()` is a shortcut if you only imported `Client`.
|
|
146
|
+
- All builder options are also available as keyword args to `Client(...)` —
|
|
147
|
+
the builder is sugar, not the only path.
|
|
148
|
+
|
|
149
|
+
## Try it in IPython
|
|
150
|
+
|
|
151
|
+
The SDK is async-only, so the regular Python REPL needs `asyncio.run(...)` for every
|
|
152
|
+
call. IPython's autoawait is much friendlier — `await` works at the prompt:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
uv run --with ipython ipython
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Then:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
In [1]: %autoawait
|
|
162
|
+
Out[1]: {'autoawait': True, 'autoawait_loop': 'asyncio'}
|
|
163
|
+
|
|
164
|
+
In [2]: from rocksky import Client
|
|
165
|
+
|
|
166
|
+
In [3]: rocksky = Client() # base_url defaults to https://api.rocksky.app
|
|
167
|
+
|
|
168
|
+
In [4]: me = await rocksky.actor.get_profile("tsiry-sandratraina.com")
|
|
169
|
+
|
|
170
|
+
In [5]: me.display_name
|
|
171
|
+
Out[5]: 'Tsiry Sandratraina'
|
|
172
|
+
|
|
173
|
+
In [6]: recent = await rocksky.scrobble.list(did=me.did, limit=5)
|
|
174
|
+
|
|
175
|
+
In [7]: [(s.artist, s.title) for s in recent]
|
|
176
|
+
Out[7]: [('Kate Bush', 'Hounds of Love'), …]
|
|
177
|
+
|
|
178
|
+
In [8]: await rocksky.aclose() # tidy up when done
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Jupyter notebooks behave the same — `await` works at the top level of a cell out of
|
|
182
|
+
the box. For other shells (`ptpython`, plain `python -m asyncio`), see your REPL's
|
|
183
|
+
autoawait support.
|
|
184
|
+
|
|
185
|
+
## Resources
|
|
186
|
+
|
|
187
|
+
The client groups endpoints by namespace. Selected highlights:
|
|
188
|
+
|
|
189
|
+
| Namespace | Methods |
|
|
190
|
+
|-----------|---------|
|
|
191
|
+
| `actor` | `get_profile`, `get_albums`, `get_artists`, `get_songs`, `get_scrobbles`, `get_loved_songs`, `get_playlists`, `get_neighbours`, `get_compatibility` |
|
|
192
|
+
| `album` | `get`, `list`, `get_tracks` |
|
|
193
|
+
| `artist` | `get`, `list`, `get_albums`, `get_tracks`, `get_listeners`, `get_recent_listeners` |
|
|
194
|
+
| `song` | `get`, `list`, `match`, `get_recent_listeners`, `create` |
|
|
195
|
+
| `scrobble` | `get`, `list`, `create` |
|
|
196
|
+
| `charts` | `top_tracks`, `top_artists`, `scrobbles_chart` |
|
|
197
|
+
| `feed` | `get`, `search`, `stories`, `recommendations`, `artist_recommendations`, `album_recommendations`, `get_generator`, `list_generators` |
|
|
198
|
+
| `graph` | `follow`, `unfollow`, `get_followers`, `get_follows`, `get_known_followers` |
|
|
199
|
+
| `shout` | `create`, `reply`, `remove`, `report`, `for_profile`, `for_album`, `for_artist`, `for_track`, `replies` |
|
|
200
|
+
| `like` | `like_song`, `dislike_song`, `like_shout`, `dislike_shout` |
|
|
201
|
+
| `playlist` | `get`, `list`, `create`, `remove`, `start`, `insert_files`, `insert_directory` |
|
|
202
|
+
| `player` | `currently_playing`, `queue`, `play`, `pause`, `next`, `previous`, `seek`, `play_file`, `play_directory`, `add_items_to_queue`, `add_directory_to_queue` |
|
|
203
|
+
| `spotify` | `currently_playing`, `play`, `pause`, `next`, `previous`, `seek` |
|
|
204
|
+
| `apikey` | `list`, `create`, `update`, `remove` |
|
|
205
|
+
| `stats` | `get`, `wrapped` |
|
|
206
|
+
| `mirror` | `list_sources`, `put_source` |
|
|
207
|
+
| `dropbox` / `googledrive` | `list_files`, `get_file`, `download_file`, … |
|
|
208
|
+
|
|
209
|
+
For any endpoint that isn't wrapped (or hasn't been added yet), use the generic
|
|
210
|
+
escape hatch:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
raw = await rocksky.call(
|
|
214
|
+
"app.rocksky.feed.describeFeedGenerator", verb="GET"
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Errors
|
|
219
|
+
|
|
220
|
+
All errors derive from `RockskyError`:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from rocksky import (
|
|
224
|
+
APIError,
|
|
225
|
+
AuthenticationError, # 401
|
|
226
|
+
PermissionError, # 403
|
|
227
|
+
NotFoundError, # 404
|
|
228
|
+
RateLimitError, # 429
|
|
229
|
+
ServerError, # 5xx
|
|
230
|
+
TransportError, # network / timeout
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
await rocksky.song.get(uri="at://does-not-exist")
|
|
235
|
+
except NotFoundError as e:
|
|
236
|
+
print(e.status_code, e.error, e.message)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`APIError` exposes `status_code`, `method`, `error`, `message`, and `body`.
|
|
240
|
+
|
|
241
|
+
## Testing your code against the SDK
|
|
242
|
+
|
|
243
|
+
Inject your own `httpx.AsyncClient` so you can mount a mock transport:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
import httpx
|
|
247
|
+
from rocksky import Client
|
|
248
|
+
|
|
249
|
+
transport = httpx.MockTransport(lambda req: httpx.Response(200, json={"hits": []}))
|
|
250
|
+
external = httpx.AsyncClient(transport=transport)
|
|
251
|
+
|
|
252
|
+
async with Client(http_client=external) as rocksky:
|
|
253
|
+
await rocksky.feed.search("kate bush")
|
|
254
|
+
|
|
255
|
+
await external.aclose()
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The SDK's own tests use [`respx`](https://lundberg.github.io/respx/) — see the
|
|
259
|
+
`tests/` directory for patterns.
|
|
260
|
+
|
|
261
|
+
## Examples
|
|
262
|
+
|
|
263
|
+
Runnable example scripts live in [`examples/`](examples/):
|
|
264
|
+
|
|
265
|
+
- `examples/quickstart.py` — fetch a profile and recent scrobbles
|
|
266
|
+
- `examples/scrobble.py` — submit a scrobble (requires `ROCKSKY_TOKEN`)
|
|
267
|
+
- `examples/wrapped.py` — print someone's year-in-review summary
|
|
268
|
+
- `examples/search.py` — search and pretty-print hits
|
|
269
|
+
- `examples/follow_feed.py` — page through the follow-graph feed
|
|
270
|
+
- `examples/with_builder.py` — fluent builder with retries + request/response hooks
|
|
271
|
+
|
|
272
|
+
Run them with:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
uv run python examples/quickstart.py tsiry-sandratraina.com
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
[MIT](LICENSE) © Tsiry Sandratraina.
|
rocksky-0.1.0/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# rocksky — Python SDK
|
|
2
|
+
|
|
3
|
+
Async Python SDK for the [Rocksky](https://rocksky.app) XRPC API.
|
|
4
|
+
|
|
5
|
+
- **Async-first** (`asyncio`, built on `httpx.AsyncClient`)
|
|
6
|
+
- **Typed** — Pydantic v2 models for every common entity, snake_case API
|
|
7
|
+
- **Pythonic** — resource-style namespaces (`client.actor`, `client.scrobble`, …)
|
|
8
|
+
- **Escape hatch** — `client.call(method)` for any XRPC method not yet wrapped
|
|
9
|
+
- Works on **Python 3.10+**
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
This package is `uv`-native. From a project of your own:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add rocksky
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or with `pip`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install rocksky
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To work on the SDK itself:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/tsirysndr/rocksky
|
|
29
|
+
cd rocksky/sdk/python
|
|
30
|
+
uv sync # creates .venv, installs runtime + dev deps
|
|
31
|
+
uv run pytest # run the test suite
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from rocksky import Client
|
|
39
|
+
|
|
40
|
+
async def main() -> None:
|
|
41
|
+
async with Client() as rocksky:
|
|
42
|
+
me = await rocksky.actor.get_profile("tsiry-sandratraina.com")
|
|
43
|
+
print(me.display_name, "—", me.did)
|
|
44
|
+
|
|
45
|
+
recent = await rocksky.scrobble.list(did=me.did, limit=10)
|
|
46
|
+
for s in recent:
|
|
47
|
+
print(f" {s.artist} — {s.title}")
|
|
48
|
+
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Authenticating
|
|
53
|
+
|
|
54
|
+
The Rocksky API uses a JWT bearer token. Pass it to the client:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
async with Client(token="eyJhbGciOi…") as rocksky:
|
|
58
|
+
await rocksky.scrobble.create(
|
|
59
|
+
title="Hounds of Love",
|
|
60
|
+
artist="Kate Bush",
|
|
61
|
+
album="Hounds of Love",
|
|
62
|
+
duration=298000,
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You can also flip tokens mid-session:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
rocksky.set_token(new_jwt)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Self-hosting / custom base URL
|
|
73
|
+
|
|
74
|
+
Default base URL is `https://api.rocksky.app`. Override for self-hosted instances:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
Client(base_url="http://localhost:8000", token=...)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Fluent builder
|
|
81
|
+
|
|
82
|
+
If you prefer chainable configuration over a wide keyword-arg constructor —
|
|
83
|
+
or you want to add retries, logging hooks, or custom headers — use
|
|
84
|
+
`ClientBuilder`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from rocksky import ClientBuilder
|
|
88
|
+
|
|
89
|
+
rocksky = (
|
|
90
|
+
ClientBuilder()
|
|
91
|
+
.base_url("https://api.rocksky.app")
|
|
92
|
+
.token(os.environ["ROCKSKY_TOKEN"])
|
|
93
|
+
.timeout(10.0)
|
|
94
|
+
.user_agent("my-app/1.0")
|
|
95
|
+
.header("x-request-id", "trace-abc")
|
|
96
|
+
.retries(3, backoff=0.5) # retry transport errors + 5xx
|
|
97
|
+
.on_request(lambda r: log.debug("→ %s %s", r.method, r.url))
|
|
98
|
+
.on_response(lambda r: log.debug("← %s", r.status_code))
|
|
99
|
+
.build()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async with rocksky:
|
|
103
|
+
profile = await rocksky.actor.get_profile("tsiry-sandratraina.com")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A few notes:
|
|
107
|
+
|
|
108
|
+
- Every setter returns `self`, so chain freely.
|
|
109
|
+
- Hooks may be sync **or** async — the SDK awaits them when needed. They fire
|
|
110
|
+
on every attempt (useful for tracing retries).
|
|
111
|
+
- `retries(n)` retries on `TransportError` and any `5xx` response with
|
|
112
|
+
exponential backoff (`backoff * 2**attempt`). `4xx` responses are surfaced
|
|
113
|
+
immediately.
|
|
114
|
+
- `Client.builder()` is a shortcut if you only imported `Client`.
|
|
115
|
+
- All builder options are also available as keyword args to `Client(...)` —
|
|
116
|
+
the builder is sugar, not the only path.
|
|
117
|
+
|
|
118
|
+
## Try it in IPython
|
|
119
|
+
|
|
120
|
+
The SDK is async-only, so the regular Python REPL needs `asyncio.run(...)` for every
|
|
121
|
+
call. IPython's autoawait is much friendlier — `await` works at the prompt:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv run --with ipython ipython
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Then:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
In [1]: %autoawait
|
|
131
|
+
Out[1]: {'autoawait': True, 'autoawait_loop': 'asyncio'}
|
|
132
|
+
|
|
133
|
+
In [2]: from rocksky import Client
|
|
134
|
+
|
|
135
|
+
In [3]: rocksky = Client() # base_url defaults to https://api.rocksky.app
|
|
136
|
+
|
|
137
|
+
In [4]: me = await rocksky.actor.get_profile("tsiry-sandratraina.com")
|
|
138
|
+
|
|
139
|
+
In [5]: me.display_name
|
|
140
|
+
Out[5]: 'Tsiry Sandratraina'
|
|
141
|
+
|
|
142
|
+
In [6]: recent = await rocksky.scrobble.list(did=me.did, limit=5)
|
|
143
|
+
|
|
144
|
+
In [7]: [(s.artist, s.title) for s in recent]
|
|
145
|
+
Out[7]: [('Kate Bush', 'Hounds of Love'), …]
|
|
146
|
+
|
|
147
|
+
In [8]: await rocksky.aclose() # tidy up when done
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Jupyter notebooks behave the same — `await` works at the top level of a cell out of
|
|
151
|
+
the box. For other shells (`ptpython`, plain `python -m asyncio`), see your REPL's
|
|
152
|
+
autoawait support.
|
|
153
|
+
|
|
154
|
+
## Resources
|
|
155
|
+
|
|
156
|
+
The client groups endpoints by namespace. Selected highlights:
|
|
157
|
+
|
|
158
|
+
| Namespace | Methods |
|
|
159
|
+
|-----------|---------|
|
|
160
|
+
| `actor` | `get_profile`, `get_albums`, `get_artists`, `get_songs`, `get_scrobbles`, `get_loved_songs`, `get_playlists`, `get_neighbours`, `get_compatibility` |
|
|
161
|
+
| `album` | `get`, `list`, `get_tracks` |
|
|
162
|
+
| `artist` | `get`, `list`, `get_albums`, `get_tracks`, `get_listeners`, `get_recent_listeners` |
|
|
163
|
+
| `song` | `get`, `list`, `match`, `get_recent_listeners`, `create` |
|
|
164
|
+
| `scrobble` | `get`, `list`, `create` |
|
|
165
|
+
| `charts` | `top_tracks`, `top_artists`, `scrobbles_chart` |
|
|
166
|
+
| `feed` | `get`, `search`, `stories`, `recommendations`, `artist_recommendations`, `album_recommendations`, `get_generator`, `list_generators` |
|
|
167
|
+
| `graph` | `follow`, `unfollow`, `get_followers`, `get_follows`, `get_known_followers` |
|
|
168
|
+
| `shout` | `create`, `reply`, `remove`, `report`, `for_profile`, `for_album`, `for_artist`, `for_track`, `replies` |
|
|
169
|
+
| `like` | `like_song`, `dislike_song`, `like_shout`, `dislike_shout` |
|
|
170
|
+
| `playlist` | `get`, `list`, `create`, `remove`, `start`, `insert_files`, `insert_directory` |
|
|
171
|
+
| `player` | `currently_playing`, `queue`, `play`, `pause`, `next`, `previous`, `seek`, `play_file`, `play_directory`, `add_items_to_queue`, `add_directory_to_queue` |
|
|
172
|
+
| `spotify` | `currently_playing`, `play`, `pause`, `next`, `previous`, `seek` |
|
|
173
|
+
| `apikey` | `list`, `create`, `update`, `remove` |
|
|
174
|
+
| `stats` | `get`, `wrapped` |
|
|
175
|
+
| `mirror` | `list_sources`, `put_source` |
|
|
176
|
+
| `dropbox` / `googledrive` | `list_files`, `get_file`, `download_file`, … |
|
|
177
|
+
|
|
178
|
+
For any endpoint that isn't wrapped (or hasn't been added yet), use the generic
|
|
179
|
+
escape hatch:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
raw = await rocksky.call(
|
|
183
|
+
"app.rocksky.feed.describeFeedGenerator", verb="GET"
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Errors
|
|
188
|
+
|
|
189
|
+
All errors derive from `RockskyError`:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from rocksky import (
|
|
193
|
+
APIError,
|
|
194
|
+
AuthenticationError, # 401
|
|
195
|
+
PermissionError, # 403
|
|
196
|
+
NotFoundError, # 404
|
|
197
|
+
RateLimitError, # 429
|
|
198
|
+
ServerError, # 5xx
|
|
199
|
+
TransportError, # network / timeout
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
await rocksky.song.get(uri="at://does-not-exist")
|
|
204
|
+
except NotFoundError as e:
|
|
205
|
+
print(e.status_code, e.error, e.message)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`APIError` exposes `status_code`, `method`, `error`, `message`, and `body`.
|
|
209
|
+
|
|
210
|
+
## Testing your code against the SDK
|
|
211
|
+
|
|
212
|
+
Inject your own `httpx.AsyncClient` so you can mount a mock transport:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import httpx
|
|
216
|
+
from rocksky import Client
|
|
217
|
+
|
|
218
|
+
transport = httpx.MockTransport(lambda req: httpx.Response(200, json={"hits": []}))
|
|
219
|
+
external = httpx.AsyncClient(transport=transport)
|
|
220
|
+
|
|
221
|
+
async with Client(http_client=external) as rocksky:
|
|
222
|
+
await rocksky.feed.search("kate bush")
|
|
223
|
+
|
|
224
|
+
await external.aclose()
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The SDK's own tests use [`respx`](https://lundberg.github.io/respx/) — see the
|
|
228
|
+
`tests/` directory for patterns.
|
|
229
|
+
|
|
230
|
+
## Examples
|
|
231
|
+
|
|
232
|
+
Runnable example scripts live in [`examples/`](examples/):
|
|
233
|
+
|
|
234
|
+
- `examples/quickstart.py` — fetch a profile and recent scrobbles
|
|
235
|
+
- `examples/scrobble.py` — submit a scrobble (requires `ROCKSKY_TOKEN`)
|
|
236
|
+
- `examples/wrapped.py` — print someone's year-in-review summary
|
|
237
|
+
- `examples/search.py` — search and pretty-print hits
|
|
238
|
+
- `examples/follow_feed.py` — page through the follow-graph feed
|
|
239
|
+
- `examples/with_builder.py` — fluent builder with retries + request/response hooks
|
|
240
|
+
|
|
241
|
+
Run them with:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uv run python examples/quickstart.py tsiry-sandratraina.com
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
[MIT](LICENSE) © Tsiry Sandratraina.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Page through a feed generator until exhausted or until a page cap is hit.
|
|
2
|
+
|
|
3
|
+
Run:
|
|
4
|
+
uv run python examples/follow_feed.py at://did:plc:.../app.rocksky.feed.generator/all
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from rocksky import Client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main(feed_uri: str, max_pages: int = 3) -> int:
|
|
16
|
+
cursor: str | None = None
|
|
17
|
+
seen = 0
|
|
18
|
+
async with Client() as rocksky:
|
|
19
|
+
for page in range(max_pages):
|
|
20
|
+
feed = await rocksky.feed.get(feed_uri, limit=25, cursor=cursor)
|
|
21
|
+
for item in feed.feed:
|
|
22
|
+
s = item.scrobble
|
|
23
|
+
if not s:
|
|
24
|
+
continue
|
|
25
|
+
seen += 1
|
|
26
|
+
stamp = s.date.isoformat() if s.date else "—"
|
|
27
|
+
print(f" [{stamp}] {s.user:24s} {s.artist} — {s.title}")
|
|
28
|
+
cursor = feed.cursor
|
|
29
|
+
print(f"--- end of page {page + 1}; cursor={cursor!r} ---")
|
|
30
|
+
if not cursor:
|
|
31
|
+
break
|
|
32
|
+
print(f"\ntotal items: {seen}")
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
if len(sys.argv) < 2:
|
|
38
|
+
print("usage: follow_feed.py <feed-at-uri>")
|
|
39
|
+
sys.exit(2)
|
|
40
|
+
sys.exit(asyncio.run(main(sys.argv[1])))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Fetch a profile and the most recent scrobbles for a handle.
|
|
2
|
+
|
|
3
|
+
Run:
|
|
4
|
+
uv run python examples/quickstart.py alice.bsky.social
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from rocksky import Client, NotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main(handle: str) -> int:
|
|
16
|
+
async with Client() as rocksky:
|
|
17
|
+
try:
|
|
18
|
+
profile = await rocksky.actor.get_profile(handle)
|
|
19
|
+
except NotFoundError:
|
|
20
|
+
print(f"no profile for {handle!r}")
|
|
21
|
+
return 1
|
|
22
|
+
|
|
23
|
+
print(f"{profile.display_name or profile.handle} ({profile.did})")
|
|
24
|
+
print(f" joined: {profile.created_at}")
|
|
25
|
+
print()
|
|
26
|
+
|
|
27
|
+
scrobbles = await rocksky.scrobble.list(did=profile.did, limit=10)
|
|
28
|
+
if not scrobbles:
|
|
29
|
+
print("(no scrobbles yet)")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
print("recent scrobbles:")
|
|
33
|
+
for s in scrobbles:
|
|
34
|
+
stamp = s.date.isoformat() if s.date else "—"
|
|
35
|
+
print(f" [{stamp}] {s.artist} — {s.title}")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
handle = sys.argv[1] if len(sys.argv) > 1 else "tsiry.dev"
|
|
41
|
+
sys.exit(asyncio.run(main(handle)))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Submit a scrobble.
|
|
2
|
+
|
|
3
|
+
Reads the bearer token from $ROCKSKY_TOKEN. Override the API base URL with
|
|
4
|
+
$ROCKSKY_BASE_URL if you're hitting a self-hosted instance.
|
|
5
|
+
|
|
6
|
+
Run:
|
|
7
|
+
ROCKSKY_TOKEN=... uv run python examples/scrobble.py
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from rocksky import APIError, Client
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def main() -> int:
|
|
21
|
+
token = os.environ.get("ROCKSKY_TOKEN")
|
|
22
|
+
if not token:
|
|
23
|
+
print("set $ROCKSKY_TOKEN first")
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
base_url = os.environ.get("ROCKSKY_BASE_URL", "https://api.rocksky.app")
|
|
27
|
+
|
|
28
|
+
async with Client(base_url=base_url, token=token) as rocksky:
|
|
29
|
+
try:
|
|
30
|
+
result = await rocksky.scrobble.create(
|
|
31
|
+
title="Hounds of Love",
|
|
32
|
+
artist="Kate Bush",
|
|
33
|
+
album="Hounds of Love",
|
|
34
|
+
duration=298_000,
|
|
35
|
+
year=1985,
|
|
36
|
+
timestamp=int(time.time()),
|
|
37
|
+
)
|
|
38
|
+
except APIError as exc:
|
|
39
|
+
print(f"scrobble failed: {exc}")
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
print("scrobble accepted:", result)
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(asyncio.run(main()))
|