+69
docs/tools/plyrfm.md
+69
docs/tools/plyrfm.md
···
1
+
# plyrfm
2
+
3
+
python SDK and CLI for plyr.fm - available on [PyPI](https://pypi.org/project/plyrfm/) and [GitHub](https://github.com/zzstoatzz/plyr-python-client).
4
+
5
+
## installation
6
+
7
+
```bash
8
+
# run directly
9
+
uvx plyrfm --help
10
+
11
+
# or install as a tool
12
+
uv tool install plyrfm
13
+
14
+
# or as a dependency (SDK + CLI)
15
+
uv add plyrfm
16
+
```
17
+
18
+
## authentication
19
+
20
+
some operations work without auth (listing public tracks, getting a track by ID).
21
+
22
+
for authenticated operations:
23
+
24
+
1. go to [plyr.fm/portal](https://plyr.fm/portal) -> "your data" -> "developer tokens"
25
+
2. create a token
26
+
3. `export PLYR_TOKEN="your_token"`
27
+
28
+
## CLI
29
+
30
+
```bash
31
+
# public (no auth)
32
+
plyrfm list # list all tracks
33
+
34
+
# authenticated
35
+
plyrfm my-tracks # list your tracks
36
+
plyrfm upload track.mp3 "My Song" # upload
37
+
plyrfm download 42 -o song.mp3 # download
38
+
plyrfm delete 42 -y # delete
39
+
plyrfm me # check auth
40
+
```
41
+
42
+
use staging API:
43
+
```bash
44
+
PLYR_API_URL=https://api-stg.plyr.fm plyrfm list
45
+
```
46
+
47
+
## SDK
48
+
49
+
```python
50
+
from plyrfm import PlyrClient, AsyncPlyrClient
51
+
52
+
# public operations (no auth)
53
+
client = PlyrClient()
54
+
tracks = client.list_tracks()
55
+
track = client.get_track(42)
56
+
57
+
# authenticated operations
58
+
client = PlyrClient(token="your_token") # or set PLYR_TOKEN
59
+
my_tracks = client.my_tracks()
60
+
result = client.upload("song.mp3", "My Song")
61
+
client.delete(result.track_id)
62
+
```
63
+
64
+
async:
65
+
```python
66
+
async with AsyncPlyrClient(token="your_token") as client:
67
+
tracks = await client.list_tracks()
68
+
await client.upload("song.mp3", "My Song")
69
+
```
-274
scripts/plyr.py
-274
scripts/plyr.py
···
1
-
#!/usr/bin/env -S uv run --script
2
-
# /// script
3
-
# requires-python = ">=3.11"
4
-
# dependencies = [
5
-
# "cyclopts>=3.0",
6
-
# "httpx>=0.27",
7
-
# "pydantic-settings>=2.0",
8
-
# "rich>=13.0",
9
-
# ]
10
-
# ///
11
-
"""
12
-
plyr.fm CLI - upload and download tracks programmatically.
13
-
14
-
setup:
15
-
1. create a developer token at plyr.fm/portal -> "your data" -> "developer tokens"
16
-
2. export PLYR_TOKEN="your_token_here"
17
-
18
-
usage:
19
-
uv run scripts/plyr.py list
20
-
uv run scripts/plyr.py upload track.mp3 "My Track" --album "My Album"
21
-
uv run scripts/plyr.py download 42 -o my-track.mp3
22
-
uv run scripts/plyr.py delete 42
23
-
24
-
environments (defaults to localhost:8001):
25
-
PLYR_API_URL=https://api-stg.plyr.fm uv run scripts/plyr.py list # staging
26
-
PLYR_API_URL=https://api.plyr.fm uv run scripts/plyr.py list # production
27
-
"""
28
-
29
-
import json
30
-
import sys
31
-
from pathlib import Path
32
-
from typing import Annotated
33
-
34
-
import httpx
35
-
from cyclopts import App, Parameter
36
-
from pydantic import Field
37
-
from pydantic_settings import BaseSettings, SettingsConfigDict
38
-
from rich.console import Console
39
-
from rich.table import Table
40
-
41
-
console = Console()
42
-
43
-
44
-
class Settings(BaseSettings):
45
-
"""plyr.fm CLI configuration.
46
-
47
-
override api_url for different environments:
48
-
PLYR_API_URL=http://localhost:8001 # local dev (default)
49
-
PLYR_API_URL=https://api-stg.plyr.fm # staging
50
-
PLYR_API_URL=https://api.plyr.fm # production
51
-
"""
52
-
53
-
model_config = SettingsConfigDict(
54
-
env_prefix="PLYR_", env_file=".env", extra="ignore"
55
-
)
56
-
57
-
token: str | None = Field(default=None, description="API token")
58
-
api_url: str = Field(default="http://localhost:8001", description="API base URL")
59
-
60
-
@property
61
-
def headers(self) -> dict[str, str]:
62
-
if not self.token:
63
-
console.print("[red]error:[/] PLYR_TOKEN not set")
64
-
console.print("create a token at plyr.fm/portal -> 'developer tokens'")
65
-
sys.exit(1)
66
-
return {"Authorization": f"Bearer {self.token}"}
67
-
68
-
69
-
settings = Settings()
70
-
app = App(help="plyr.fm CLI - upload and download tracks")
71
-
72
-
73
-
@app.command
74
-
def upload(
75
-
file: Annotated[Path, Parameter(help="audio file to upload")],
76
-
title: Annotated[str, Parameter(help="track title")],
77
-
album: Annotated[str | None, Parameter(help="album name")] = None,
78
-
) -> None:
79
-
"""upload a track to plyr.fm."""
80
-
if not file.exists():
81
-
console.print(f"[red]error:[/] file not found: {file}")
82
-
sys.exit(1)
83
-
84
-
with console.status("uploading..."):
85
-
with open(file, "rb") as f:
86
-
files = {"file": (file.name, f)}
87
-
data = {"title": title}
88
-
if album:
89
-
data["album"] = album
90
-
91
-
response = httpx.post(
92
-
f"{settings.api_url}/tracks/",
93
-
headers=settings.headers,
94
-
files=files,
95
-
data=data,
96
-
timeout=120.0,
97
-
)
98
-
99
-
if response.status_code == 401:
100
-
console.print("[red]error:[/] invalid or expired token")
101
-
sys.exit(1)
102
-
103
-
if response.status_code == 403:
104
-
detail = response.json().get("detail", "")
105
-
if "artist_profile_required" in detail:
106
-
console.print("[red]error:[/] create an artist profile first at plyr.fm")
107
-
elif "scope_upgrade_required" in detail:
108
-
console.print("[red]error:[/] log out and back in, then create a new token")
109
-
else:
110
-
console.print(f"[red]error:[/] forbidden - {detail}")
111
-
sys.exit(1)
112
-
113
-
response.raise_for_status()
114
-
upload_data = response.json()
115
-
upload_id = upload_data.get("upload_id")
116
-
117
-
if not upload_id:
118
-
console.print(f"[green]done:[/] {response.json()}")
119
-
return
120
-
121
-
# poll for completion
122
-
console.print(f"processing: {upload_id}")
123
-
with httpx.stream(
124
-
"GET",
125
-
f"{settings.api_url}/tracks/uploads/{upload_id}/progress",
126
-
headers=settings.headers,
127
-
timeout=300.0,
128
-
) as sse:
129
-
for line in sse.iter_lines():
130
-
if line.startswith("data: "):
131
-
data = json.loads(line[6:])
132
-
status = data.get("status")
133
-
134
-
if status == "completed":
135
-
track_id = data.get("track_id")
136
-
console.print(f"[green]uploaded:[/] track {track_id}")
137
-
return
138
-
elif status == "failed":
139
-
error = data.get("error", "unknown error")
140
-
console.print(f"[red]failed:[/] {error}")
141
-
sys.exit(1)
142
-
143
-
144
-
@app.command
145
-
def download(
146
-
track_id: Annotated[int, Parameter(help="track ID to download")],
147
-
output: Annotated[
148
-
Path | None, Parameter(name=["--output", "-o"], help="output file")
149
-
] = None,
150
-
) -> None:
151
-
"""download a track from plyr.fm."""
152
-
# get track info first
153
-
with console.status("fetching track info..."):
154
-
info_response = httpx.get(
155
-
f"{settings.api_url}/tracks/{track_id}",
156
-
headers=settings.headers,
157
-
timeout=30.0,
158
-
)
159
-
160
-
if info_response.status_code == 404:
161
-
console.print(f"[red]error:[/] track {track_id} not found")
162
-
sys.exit(1)
163
-
164
-
info_response.raise_for_status()
165
-
track = info_response.json()
166
-
167
-
# determine output filename
168
-
if output is None:
169
-
# use track title + extension from file_type
170
-
ext = track.get("file_type", "mp3")
171
-
safe_title = "".join(
172
-
c if c.isalnum() or c in " -_" else "" for c in track["title"]
173
-
)
174
-
output = Path(f"{safe_title}.{ext}")
175
-
176
-
# download audio
177
-
with console.status(f"downloading {track['title']}..."):
178
-
audio_response = httpx.get(
179
-
f"{settings.api_url}/audio/{track['file_id']}",
180
-
headers=settings.headers,
181
-
follow_redirects=True,
182
-
timeout=300.0,
183
-
)
184
-
185
-
audio_response.raise_for_status()
186
-
187
-
output.write_bytes(audio_response.content)
188
-
size_mb = len(audio_response.content) / 1024 / 1024
189
-
console.print(f"[green]saved:[/] {output} ({size_mb:.1f} MB)")
190
-
191
-
192
-
@app.command(name="list")
193
-
def list_tracks(
194
-
limit: Annotated[int, Parameter(help="max tracks to show")] = 20,
195
-
) -> None:
196
-
"""list your tracks."""
197
-
with console.status("fetching tracks..."):
198
-
response = httpx.get(
199
-
f"{settings.api_url}/tracks/",
200
-
headers=settings.headers,
201
-
timeout=30.0,
202
-
)
203
-
204
-
response.raise_for_status()
205
-
tracks = response.json().get("tracks", [])
206
-
207
-
if not tracks:
208
-
console.print("no tracks found")
209
-
return
210
-
211
-
table = Table(title="your tracks")
212
-
table.add_column("ID", style="cyan")
213
-
table.add_column("title")
214
-
table.add_column("album")
215
-
table.add_column("plays", justify="right")
216
-
217
-
for track in tracks[:limit]:
218
-
album = track.get("album")
219
-
album_name = album.get("title") if isinstance(album, dict) else (album or "-")
220
-
table.add_row(
221
-
str(track["id"]),
222
-
track["title"],
223
-
album_name,
224
-
str(track.get("play_count", 0)),
225
-
)
226
-
227
-
console.print(table)
228
-
229
-
230
-
@app.command
231
-
def delete(
232
-
track_id: Annotated[int, Parameter(help="track ID to delete")],
233
-
yes: Annotated[
234
-
bool, Parameter(name=["--yes", "-y"], help="skip confirmation")
235
-
] = False,
236
-
) -> None:
237
-
"""delete a track."""
238
-
# get track info first
239
-
with console.status("fetching track info..."):
240
-
info_response = httpx.get(
241
-
f"{settings.api_url}/tracks/{track_id}",
242
-
headers=settings.headers,
243
-
timeout=30.0,
244
-
)
245
-
246
-
if info_response.status_code == 404:
247
-
console.print(f"[red]error:[/] track {track_id} not found")
248
-
sys.exit(1)
249
-
250
-
info_response.raise_for_status()
251
-
track = info_response.json()
252
-
253
-
if not yes:
254
-
console.print(f"delete '{track['title']}'? [y/N] ", end="")
255
-
if input().lower() != "y":
256
-
console.print("cancelled")
257
-
return
258
-
259
-
response = httpx.delete(
260
-
f"{settings.api_url}/tracks/{track_id}",
261
-
headers=settings.headers,
262
-
timeout=30.0,
263
-
)
264
-
265
-
if response.status_code == 404:
266
-
console.print(f"[red]error:[/] track {track_id} not found")
267
-
sys.exit(1)
268
-
269
-
response.raise_for_status()
270
-
console.print(f"[green]deleted:[/] {track['title']}")
271
-
272
-
273
-
if __name__ == "__main__":
274
-
app()