docs: replace plyr.py script with plyrfm SDK docs (#368)

- remove scripts/plyr.py (now redundant with plyrfm on PyPI)
- add docs/tools/plyrfm.md documenting CLI and SDK usage

users can now `uv tool install plyrfm` instead of running the script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 33153b9a 6a82cf4f

Changed files
+69 -274
docs
tools
scripts
+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
··· 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()