deploy to Fly.io and prepare frontend for Vercel

- backend: deployed to Fly.io at relay-api.fly.dev
- frontend: configured API_URL for environment-based backend URL
- removed Cloudflare-specific configs (wrangler.toml, adapter-cloudflare)
- added Dockerfile with git support for atproto fork
- added fly.toml with secrets and R2 config

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

Co-Authored-By: Claude <noreply@anthropic.com>

+23
.dockerignore
···
··· 1 + .git 2 + .github 3 + .venv 4 + .venv-workers 5 + __pycache__ 6 + *.pyc 7 + *.pyo 8 + *.pyd 9 + .pytest_cache 10 + .coverage 11 + htmlcov 12 + dist 13 + build 14 + *.egg-info 15 + .env 16 + .env.local 17 + data/ 18 + frontend/ 19 + sandbox/ 20 + tests/ 21 + docs/ 22 + *.md 23 + !README.md
+9 -5
.env.example
··· 9 # redis 10 REDIS_URL=redis://localhost:6379/0 11 12 - # cloudflare r2 13 - R2_ACCOUNT_ID= 14 - R2_ACCESS_KEY_ID= 15 - R2_SECRET_ACCESS_KEY= 16 - R2_BUCKET_NAME=relay-audio 17 18 # atproto 19 ATPROTO_PDS_URL=https://bsky.social
··· 9 # redis 10 REDIS_URL=redis://localhost:6379/0 11 12 + # storage 13 + STORAGE_BACKEND=filesystem # or "r2" 14 + 15 + # cloudflare r2 (only needed if STORAGE_BACKEND=r2) 16 + AWS_ACCESS_KEY_ID=your_r2_access_key_id 17 + AWS_SECRET_ACCESS_KEY=your_r2_secret_access_key 18 + R2_BUCKET=relay-audio 19 + R2_ENDPOINT_URL=https://<account_id>.r2.cloudflarestorage.com 20 + R2_PUBLIC_BUCKET_URL=https://audio.relay.example.com 21 22 # atproto 23 ATPROTO_PDS_URL=https://bsky.social
+5 -4
CLAUDE.md
··· 6 7 - **testing**: empirical first - run code and prove it works before writing tests 8 - **auth**: OAuth 2.1 implementation from fork (`git+https://github.com/zzstoatzz/atproto@main`) 9 - - **storage**: filesystem for MVP, will migrate to R2 later 10 - - **database**: delete `data/relay.db` when Track model changes (no migrations yet) 11 - - **frontend**: SvelteKit with **bun** (not npm/pnpm) - reference project in `sandbox/huggingchat-ui` for patterns 12 - - **justfile**: use `just` for dev workflows when needed
··· 6 7 - **testing**: empirical first - run code and prove it works before writing tests 8 - **auth**: OAuth 2.1 implementation from fork (`git+https://github.com/zzstoatzz/atproto@main`) 9 + - **storage**: Cloudflare R2 for audio files 10 + - **database**: Neon PostgreSQL (serverless) 11 + - **frontend**: SvelteKit with **bun** (not npm/pnpm) 12 + - **backend**: FastAPI deployed on Fly.io 13 + - **deployment**: `flyctl deploy` (runs in background per user prefs)
+22
Dockerfile
···
··· 1 + FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim 2 + 3 + # install git for git dependencies 4 + RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* 5 + 6 + WORKDIR /app 7 + 8 + # install dependencies 9 + COPY pyproject.toml uv.lock ./ 10 + RUN uv sync --frozen --no-dev --no-install-project 11 + 12 + # copy application code 13 + COPY . . 14 + 15 + # install the project itself 16 + RUN uv sync --frozen --no-dev 17 + 18 + # expose port 19 + EXPOSE 8000 20 + 21 + # run the application 22 + CMD ["uv", "run", "uvicorn", "relay.main:app", "--host", "0.0.0.0", "--port", "8000"]
+314
docs/README.md
···
··· 1 + # relay ATProto integration documentation 2 + 3 + this directory contains the complete plan and implementation guides for integrating relay with ATProto. 4 + 5 + ## documents 6 + 7 + ### [`atproto-integration-plan.md`](./atproto-integration-plan.md) 8 + 9 + **overview document** - read this first to understand the overall architecture and approach. 10 + 11 + covers: 12 + - hybrid storage model (R2 + ATProto records) 13 + - lexicon design for `app.relay.track` 14 + - implementation phases 15 + - data flow diagrams 16 + - open questions and decisions 17 + 18 + ### [`phase1-r2-implementation.md`](./phase1-r2-implementation.md) 19 + 20 + **R2 storage migration** - practical guide to moving from filesystem to cloudflare R2. 21 + 22 + covers: 23 + - R2 bucket setup and configuration 24 + - implementation of `R2Storage` class 25 + - migration strategies 26 + - testing procedures 27 + - cost estimates (~$0.16/month for 1000 tracks) 28 + 29 + ### [`phase2-atproto-records.md`](./phase2-atproto-records.md) 30 + 31 + **ATProto record creation** - guide to writing track metadata to user's PDS. 32 + 33 + covers: 34 + - database schema updates 35 + - `create_track_record()` implementation 36 + - upload endpoint modifications 37 + - error handling strategies 38 + - frontend integration 39 + 40 + ## quick start 41 + 42 + ### current state (MVP) 43 + 44 + relay is working with: 45 + - ✅ OAuth 2.1 authentication (ATProto) 46 + - ✅ filesystem storage for audio files 47 + - ✅ track upload and playback 48 + - ✅ basic music player 49 + 50 + ### next steps 51 + 52 + #### immediate (phase 1) 53 + 54 + migrate audio storage to R2: 55 + 56 + 1. set up R2 bucket in cloudflare 57 + 2. add credentials to `.env` 58 + 3. implement `R2Storage` class (see phase1 doc) 59 + 4. set `STORAGE_BACKEND=r2` 60 + 5. test upload and playback 61 + 62 + **estimated effort**: 2-3 hours 63 + 64 + #### near-term (phase 2) 65 + 66 + add ATProto record creation: 67 + 68 + 1. update Track model with ATProto fields 69 + 2. create `atproto/records.py` module 70 + 3. modify upload endpoint to create records 71 + 4. test record creation on personal PDS 72 + 5. update frontend to show "published to ATProto" badge 73 + 74 + **estimated effort**: 3-4 hours 75 + 76 + #### future (phase 3) 77 + 78 + implement discovery via firehose: 79 + 80 + 1. set up jetstream consumer 81 + 2. listen for `app.relay.track` commits 82 + 3. index discovered tracks 83 + 4. add discovery feed to frontend 84 + 85 + **estimated effort**: 8-12 hours (deferred) 86 + 87 + ## architecture decisions 88 + 89 + ### why R2 instead of PDS blobs? 90 + 91 + PDS blobs are designed for smaller files like images. audio files are: 92 + - larger (5-50MB per track) 93 + - require streaming 94 + - benefit from CDN distribution 95 + 96 + R2 provides: 97 + - scalable storage 98 + - free egress to cloudflare CDN 99 + - simple HTTP URLs 100 + - cost-effective (~$0.015/GB/month) 101 + 102 + ### why unofficial lexicon? 103 + 104 + relay uses `app.relay.track` as an unofficial lexicon (similar to `app.at-me.visit`) because: 105 + - faster iteration during development 106 + - no formal governance needed for MVP 107 + - can migrate to official lexicon later if needed 108 + 109 + ### why hybrid storage? 110 + 111 + storing metadata on ATProto provides: 112 + - user data sovereignty (users own their catalog) 113 + - decentralization (no single point of failure) 114 + - portability (users can move to another client) 115 + 116 + storing audio on R2 provides: 117 + - performance (fast streaming) 118 + - scalability (handles growth) 119 + - cost efficiency (cheaper than PDS blobs) 120 + 121 + ## testing strategy 122 + 123 + ### phase 1 testing 124 + 125 + ```bash 126 + # 1. upload test file 127 + curl -X POST http://localhost:8001/tracks/ \ 128 + -H "Cookie: session_id=..." \ 129 + -F "file=@test.mp3" \ 130 + -F "title=test" \ 131 + -F "artist=test" 132 + 133 + # 2. verify R2 storage 134 + # check cloudflare dashboard for file 135 + 136 + # 3. test playback 137 + # open frontend and play track 138 + ``` 139 + 140 + ### phase 2 testing 141 + 142 + ```bash 143 + # 1. upload and check record 144 + curl -X POST http://localhost:8001/tracks/ ... 145 + # response should include atproto_record_uri 146 + 147 + # 2. verify on PDS 148 + # use at-me or similar tool to view records 149 + 150 + # 3. check record content 151 + python scripts/check_record.py <record_uri> 152 + ``` 153 + 154 + ## troubleshooting 155 + 156 + ### R2 upload fails 157 + 158 + ``` 159 + error: failed to upload to R2 160 + ``` 161 + 162 + **check**: 163 + - R2 credentials in `.env` 164 + - bucket exists and is accessible 165 + - account ID is correct 166 + 167 + ### ATProto record creation fails 168 + 169 + ``` 170 + error: failed to create atproto record 171 + ``` 172 + 173 + **check**: 174 + - OAuth session is valid (not expired) 175 + - user has write permissions 176 + - PDS is accessible 177 + - record format is valid 178 + 179 + ### audio won't play 180 + 181 + ``` 182 + 404: audio file not found 183 + ``` 184 + 185 + **check**: 186 + - `STORAGE_BACKEND` matches actual storage 187 + - R2 bucket has public read access 188 + - file_id matches database record 189 + 190 + ## monitoring 191 + 192 + ### key metrics to track 193 + 194 + 1. **upload success rate** 195 + - total uploads attempted 196 + - successful R2 uploads 197 + - successful record creations 198 + 199 + 2. **storage costs** 200 + - total R2 storage (GB) 201 + - monthly operations count 202 + - estimated cost 203 + 204 + 3. **playback metrics** 205 + - tracks played 206 + - average stream duration 207 + - errors/failures 208 + 209 + ### logging 210 + 211 + add structured logging for debugging: 212 + 213 + ```python 214 + import structlog 215 + 216 + logger = structlog.get_logger() 217 + 218 + logger.info( 219 + "track_uploaded", 220 + track_id=track.id, 221 + r2_url=r2_url, 222 + atproto_uri=atproto_uri, 223 + ) 224 + ``` 225 + 226 + ## security considerations 227 + 228 + ### audio file access 229 + 230 + **current**: R2 URLs are public (anyone with URL can access) 231 + 232 + **acceptable for MVP** because: 233 + - music is meant to be shared 234 + - no sensitive content 235 + - URL guessing is impractical (content-based hashes) 236 + 237 + **future enhancement**: signed URLs with expiration 238 + 239 + ### record ownership 240 + 241 + **enforced by ATProto**: only user with valid OAuth session can create records in their repo 242 + 243 + **enforced by relay**: tracks are associated with `artist_did` and only owner can delete 244 + 245 + ### rate limiting 246 + 247 + **recommended**: limit uploads to prevent abuse 248 + - 10 uploads per hour per user 249 + - 100MB total per hour per user 250 + 251 + ## cost projections 252 + 253 + ### 1000 tracks (typical small catalog) 254 + 255 + - storage: 10GB @ $0.015/GB = $0.15/month 256 + - uploads: 1000 operations @ $4.50/million = $0.005 257 + - streams: 10k plays @ $0.36/million = $0.004 258 + - **total: ~$0.16/month** 259 + 260 + ### 10,000 tracks (medium platform) 261 + 262 + - storage: 100GB @ $0.015/GB = $1.50/month 263 + - uploads: 10k operations = $0.045 264 + - streams: 100k plays = $0.036 265 + - **total: ~$1.58/month** 266 + 267 + ### 100,000 tracks (large platform) 268 + 269 + - storage: 1TB @ $0.015/GB = $15/month 270 + - uploads: 100k operations = $0.45 271 + - streams: 1M plays = $0.36 272 + - **total: ~$15.81/month** 273 + 274 + **note**: these are R2 costs only. add compute, database, etc. 275 + 276 + ## references 277 + 278 + ### ATProto documentation 279 + 280 + - [repository spec](https://atproto.com/specs/repository) 281 + - [lexicon spec](https://atproto.com/specs/lexicon) 282 + - [data model](https://atproto.com/specs/data-model) 283 + - [OAuth 2.1](https://atproto.com/specs/oauth) 284 + 285 + ### cloudflare documentation 286 + 287 + - [R2 overview](https://developers.cloudflare.com/r2/) 288 + - [R2 pricing](https://developers.cloudflare.com/r2/pricing/) 289 + - [S3 compatibility](https://developers.cloudflare.com/r2/api/s3/) 290 + 291 + ### relay project files 292 + 293 + - current status: `sandbox/status-2025-10-28.md` 294 + - project instructions: `CLAUDE.md` 295 + - atproto fork: `sandbox/atproto-fork/` 296 + - example projects: `sandbox/at-me/`, `sandbox/status/` 297 + 298 + ## contributing 299 + 300 + when implementing these plans: 301 + 302 + 1. **test empirically first** - run code and prove it works 303 + 2. **reference existing docs** - check sandbox directory before researching 304 + 3. **keep it simple** - MVP over perfection 305 + 4. **use lowercase** - respect relay's aesthetic 306 + 5. **no sprawl** - avoid creating multiple versions of files 307 + 308 + ## questions? 309 + 310 + if anything is unclear: 311 + - check the relevant phase document 312 + - review example projects in sandbox 313 + - consult ATProto official docs 314 + - look at your atproto fork implementation
+275
docs/atproto-integration-plan.md
···
··· 1 + # atproto integration plan for relay 2 + 3 + ## architecture summary 4 + 5 + relay uses a **hybrid storage model**: 6 + - **audio files**: stored on cloudflare R2 (not PDS blobs) 7 + - **track metadata**: stored as ATProto records on user's PDS 8 + - **records contain**: R2 URLs pointing to audio files 9 + 10 + this approach provides: 11 + - user data sovereignty (metadata lives in user's PDS) 12 + - scalable storage (R2 for large audio files) 13 + - decentralized identity (ATProto DIDs) 14 + - portability (users own their track metadata) 15 + 16 + ## lexicon design 17 + 18 + ### `app.relay.track` 19 + 20 + **namespace rationale**: using `app.relay.*` because relay is hosted as an application, not under a controlled domain. this is an unofficial, experimental lexicon similar to `app.at-me.visit`. 21 + 22 + **record structure**: 23 + ```json 24 + { 25 + "$type": "app.relay.track", 26 + "title": "song title", 27 + "artist": "artist name", 28 + "album": "optional album name", 29 + "audioUrl": "https://relay-audio.r2.dev/abc123.mp3", 30 + "fileType": "mp3", 31 + "duration": 240, 32 + "createdAt": "2025-10-28T22:30:00Z" 33 + } 34 + ``` 35 + 36 + **field definitions**: 37 + - `$type` (required): `app.relay.track` 38 + - `title` (required): string, max 280 chars 39 + - `artist` (required): string, max 280 chars 40 + - `album` (optional): string, max 280 chars 41 + - `audioUrl` (required): https URL to R2-hosted audio file 42 + - `fileType` (required): string, one of: `mp3`, `wav`, `ogg`, `flac` 43 + - `duration` (optional): integer, seconds 44 + - `createdAt` (required): ISO 8601 timestamp 45 + 46 + **record key**: use TID (Timestamp Identifier) for chronological ordering 47 + 48 + ## implementation phases 49 + 50 + ### phase 1: R2 storage migration (current priority) 51 + 52 + **goal**: migrate from filesystem to cloudflare R2 53 + 54 + **tasks**: 55 + 1. set up cloudflare R2 bucket and credentials 56 + 2. create R2 storage adapter in `src/relay/storage.py` 57 + 3. update `upload_track` endpoint to use R2 58 + 4. update audio serving endpoint to proxy from R2 59 + 5. migrate existing local files to R2 60 + 61 + **configuration** (in `.env`): 62 + ```bash 63 + # cloudflare R2 64 + R2_ACCOUNT_ID=your-account-id 65 + R2_ACCESS_KEY_ID=your-access-key 66 + R2_SECRET_ACCESS_KEY=your-secret-key 67 + R2_BUCKET_NAME=relay-audio 68 + R2_PUBLIC_URL=https://relay-audio.r2.dev 69 + ``` 70 + 71 + ### phase 2: ATProto record creation 72 + 73 + **goal**: write track metadata to user's PDS when uploading 74 + 75 + **tasks**: 76 + 1. create `src/relay/atproto/records.py` module 77 + 2. implement `create_track_record()` function using existing OAuth client 78 + 3. update `upload_track` endpoint to: 79 + - upload audio to R2 (get URL) 80 + - create ATProto record with R2 URL 81 + - store local Track model for fast querying 82 + 4. handle record creation errors gracefully 83 + 84 + **code structure**: 85 + ```python 86 + # src/relay/atproto/records.py 87 + from atproto import Client 88 + from relay.auth import get_oauth_session 89 + 90 + async def create_track_record( 91 + auth_session: Session, 92 + title: str, 93 + artist: str, 94 + audio_url: str, 95 + file_type: str, 96 + album: str | None = None, 97 + duration: int | None = None, 98 + ) -> str: 99 + """create app.relay.track record on user's PDS. 100 + 101 + returns: record URI (at://did:plc:.../app.relay.track/tid) 102 + """ 103 + # get OAuth session with DPoP tokens 104 + oauth_session = await get_oauth_session(auth_session.session_id) 105 + 106 + # create authenticated client 107 + client = Client(pds_url=oauth_session.pds_url) 108 + client.set_access_token(oauth_session.access_token) 109 + 110 + # create record 111 + record = { 112 + "$type": "app.relay.track", 113 + "title": title, 114 + "artist": artist, 115 + "audioUrl": audio_url, 116 + "fileType": file_type, 117 + "createdAt": datetime.utcnow().isoformat() + "Z", 118 + } 119 + if album: 120 + record["album"] = album 121 + if duration: 122 + record["duration"] = duration 123 + 124 + # write to PDS 125 + response = client.com.atproto.repo.create_record({ 126 + "repo": auth_session.did, 127 + "collection": "app.relay.track", 128 + "record": record, 129 + }) 130 + 131 + return response.uri 132 + ``` 133 + 134 + ### phase 3: ATProto record indexing 135 + 136 + **goal**: discover tracks from other users via firehose or appview 137 + 138 + **tasks**: 139 + 1. set up jetstream firehose consumer (similar to status project) 140 + 2. listen for `app.relay.track` records 141 + 3. index discovered tracks in local database 142 + 4. add "discover" page showing tracks from network 143 + 144 + **deferred**: this is lower priority. focus on upload and playback first. 145 + 146 + ## data flow diagrams 147 + 148 + ### upload flow (phase 2) 149 + ``` 150 + user uploads track 151 + 152 + relay backend validates file 153 + 154 + upload audio to R2 155 + ↓ (returns public URL) 156 + create ATProto record on user's PDS 157 + ↓ (includes R2 URL) 158 + save Track model locally (with record URI) 159 + 160 + return success to user 161 + ``` 162 + 163 + ### playback flow (existing + phase 1) 164 + ``` 165 + user clicks track 166 + 167 + frontend requests /audio/{file_id} 168 + 169 + backend proxies from R2 (or redirects) 170 + 171 + audio streams to browser 172 + ``` 173 + 174 + ### discovery flow (phase 3, future) 175 + ``` 176 + relay firehose consumer 177 + 178 + listens for app.relay.track commits 179 + 180 + fetches record from PDS 181 + 182 + verifies R2 URL exists 183 + 184 + indexes track in local database 185 + 186 + track appears in discovery feed 187 + ``` 188 + 189 + ## OAuth scope considerations 190 + 191 + **current scopes**: we only request basic authentication scopes via OAuth 2.1. 192 + 193 + **required for record creation**: the OAuth session gives us write access to the user's PDS under their identity. we can create records in their repo without additional scopes. 194 + 195 + **NOT using `transition:generic`**: this scope is too broad (gives blanket approval). we're using standard OAuth with appropriate permissions for record creation. 196 + 197 + ## privacy and user control 198 + 199 + ### transparency 200 + - users see exactly what record we're creating (show JSON before writing) 201 + - optional: require explicit "publish" confirmation 202 + 203 + ### data sovereignty 204 + - track metadata lives in user's PDS (they control it) 205 + - users can delete records via their PDS management tools 206 + - relay indexes records but doesn't own them 207 + 208 + ### opt-in participation 209 + - uploading a track requires authentication 210 + - users explicitly choose to publish 211 + 212 + ## testing strategy 213 + 214 + ### phase 1 (R2) 215 + 1. upload test file to R2 216 + 2. verify public URL works 217 + 3. test streaming playback 218 + 4. measure latency vs filesystem 219 + 220 + ### phase 2 (ATProto records) 221 + 1. create test record on development PDS 222 + 2. verify record appears in repo 223 + 3. fetch record via AT URI 224 + 4. test error handling (network failures, auth issues) 225 + 226 + ### phase 3 (indexing) 227 + 1. run local jetstream consumer 228 + 2. create test record from different account 229 + 3. verify relay indexes it 230 + 4. test discovery feed 231 + 232 + ## open questions 233 + 234 + 1. **R2 authentication**: should audio URLs be signed (temporary) or public? 235 + - option A: public URLs (simpler, but anyone with URL can access) 236 + - option B: signed URLs with expiration (more secure, but complex) 237 + - **recommendation**: start with public, add signing later if needed 238 + 239 + 2. **record deletion**: when user deletes track, should we: 240 + - delete R2 file immediately? 241 + - delete ATProto record? 242 + - tombstone record but keep R2 file? 243 + - **recommendation**: delete both R2 file and record 244 + 245 + 3. **versioning**: if user updates track metadata, should we: 246 + - update existing record? 247 + - create new record with new rkey? 248 + - **recommendation**: update existing record (keep rkey stable) 249 + 250 + 4. **discovery**: should we: 251 + - run our own appview/indexer? 252 + - use bluesky's firehose? 253 + - start without discovery and add later? 254 + - **recommendation**: defer discovery to phase 3 255 + 256 + ## migration checklist 257 + 258 + - [ ] set up R2 bucket and credentials 259 + - [ ] implement R2 storage adapter 260 + - [ ] migrate existing files to R2 261 + - [ ] update audio serving to use R2 262 + - [ ] test end-to-end upload and playback 263 + - [ ] implement ATProto record creation 264 + - [ ] add record URI to Track model 265 + - [ ] test record creation on personal PDS 266 + - [ ] update frontend to show "published to ATProto" status 267 + - [ ] document record format for other clients 268 + 269 + ## references 270 + 271 + - atproto repository spec: https://atproto.com/specs/repository 272 + - atproto lexicon spec: https://atproto.com/specs/lexicon 273 + - atproto data model: https://atproto.com/specs/data-model 274 + - cloudflare R2 docs: https://developers.cloudflare.com/r2/ 275 + - example unofficial lexicon: `sandbox/at-me/docs/lexicon.md`
+150
docs/cloudflare-deployment.md
···
··· 1 + # cloudflare deployment guide 2 + 3 + ## prerequisites 4 + 5 + 1. cloudflare account 6 + 2. domain configured in cloudflare (optional but recommended) 7 + 3. wrangler CLI installed: `npm install -g wrangler` 8 + 9 + ## setup steps 10 + 11 + ### 1. create r2 bucket 12 + 13 + ```bash 14 + wrangler r2 bucket create relay-audio 15 + ``` 16 + 17 + this creates the bucket for audio storage. 18 + 19 + ### 2. generate r2 access keys 20 + 21 + in cloudflare dashboard: 22 + 1. go to r2 > manage r2 api tokens 23 + 2. create api token with read & write permissions 24 + 3. save access key id and secret access key 25 + 26 + ### 3. set backend secrets 27 + 28 + ```bash 29 + cd /path/to/relay 30 + 31 + # database connection 32 + wrangler secret put DATABASE_URL 33 + # paste your neon postgres connection string 34 + 35 + # r2 credentials 36 + wrangler secret put AWS_ACCESS_KEY_ID 37 + wrangler secret put AWS_SECRET_ACCESS_KEY 38 + 39 + # atproto oauth 40 + wrangler secret put ATPROTO_CLIENT_ID 41 + wrangler secret put ATPROTO_CLIENT_SECRET 42 + ``` 43 + 44 + ### 4. configure environment variables 45 + 46 + update `wrangler.toml` with your values: 47 + - r2 bucket name (should be `relay-audio`) 48 + - pds url (currently `https://pds.zzstoatzz.io`) 49 + - r2 endpoint url: `https://<your-account-id>.r2.cloudflarestorage.com` 50 + - r2 public url: custom domain or `https://pub-<id>.r2.dev` 51 + 52 + ### 5. deploy backend (python workers) 53 + 54 + ```bash 55 + # from project root 56 + wrangler deploy 57 + ``` 58 + 59 + this deploys your fastapi backend to cloudflare workers. 60 + 61 + note: you'll get a url like `https://relay-api.<your-subdomain>.workers.dev` 62 + 63 + ### 6. configure frontend api url 64 + 65 + update `frontend/wrangler.toml`: 66 + ```toml 67 + [env.production.vars] 68 + API_URL = "https://relay-api.<your-subdomain>.workers.dev" 69 + ``` 70 + 71 + ### 7. deploy frontend (pages) 72 + 73 + ```bash 74 + cd frontend 75 + 76 + # build for production 77 + bun run build 78 + 79 + # deploy to pages 80 + wrangler pages deploy .svelte-kit/cloudflare --project-name relay-frontend 81 + ``` 82 + 83 + you'll get a url like `https://relay-frontend.pages.dev` 84 + 85 + ### 8. configure custom domains (optional) 86 + 87 + in cloudflare dashboard: 88 + 1. **frontend**: pages > relay-frontend > custom domains 89 + - add `relay.example.com` 90 + 2. **backend**: workers > relay-api > triggers > custom domains 91 + - add `api.relay.example.com` 92 + 3. **r2 public access**: r2 > relay-audio > settings > public access 93 + - add custom domain `audio.relay.example.com` 94 + 95 + ## cost estimate 96 + 97 + ### free tier (perfect for mvp): 98 + - pages: unlimited static requests, 100k function requests/day 99 + - workers: 100k requests/day 100 + - r2: 10 gb storage, 1m class a ops, 10m class b ops 101 + - **total: $0/month** 102 + 103 + ### paid tier (when you grow): 104 + - workers: $5/month (10m requests) 105 + - r2: ~$0.015/gb-month (100 gb audio = $1.50/month) 106 + - **total: ~$5-10/month for moderate usage** 107 + 108 + ## local development 109 + 110 + keep using current setup: 111 + ```bash 112 + # backend 113 + uv run uvicorn relay.main:app --reload --port 8001 114 + 115 + # frontend 116 + cd frontend && bun run dev 117 + ``` 118 + 119 + ## testing deployment locally 120 + 121 + ```bash 122 + # test backend with wrangler 123 + wrangler dev 124 + 125 + # test frontend 126 + cd frontend && wrangler pages dev .svelte-kit/cloudflare 127 + ``` 128 + 129 + ## troubleshooting 130 + 131 + ### python workers issues 132 + - ensure `compatibility_flags = ["python_workers"]` in wrangler.toml 133 + - python workers are in beta - some packages may not work 134 + - use `pywrangler` for complex dependency bundling 135 + 136 + ### r2 access issues 137 + - verify bucket exists: `wrangler r2 bucket list` 138 + - check secrets are set: `wrangler secret list` 139 + - ensure r2 endpoint url includes account id 140 + 141 + ### cors issues 142 + - configure cors in workers for frontend access 143 + - update frontend api url to match workers deployment 144 + 145 + ## next steps 146 + 147 + 1. set up ci/cd with github actions 148 + 2. configure domain dns in cloudflare 149 + 3. enable cloudflare analytics 150 + 4. set up error tracking (sentry, etc.)
+421
docs/phase1-r2-implementation.md
···
··· 1 + # phase 1: R2 storage implementation 2 + 3 + ## overview 4 + 5 + migrate audio file storage from local filesystem to cloudflare R2 while maintaining the same interface for the rest of the application. 6 + 7 + ## setup 8 + 9 + ### 1. create R2 bucket 10 + 11 + ```bash 12 + # via cloudflare dashboard or wrangler CLI 13 + wrangler r2 bucket create relay-audio 14 + ``` 15 + 16 + ### 2. create API token 17 + 18 + in cloudflare dashboard: 19 + 1. go to R2 → overview 20 + 2. click "manage R2 API tokens" 21 + 3. create token with: 22 + - permissions: read & write 23 + - bucket: relay-audio 24 + 25 + save the credentials: 26 + - access key id 27 + - secret access key 28 + - account id 29 + 30 + ### 3. configure environment 31 + 32 + add to `.env`: 33 + ```bash 34 + # cloudflare R2 configuration 35 + R2_ACCOUNT_ID=your-account-id-here 36 + R2_ACCESS_KEY_ID=your-access-key-id-here 37 + R2_SECRET_ACCESS_KEY=your-secret-access-key-here 38 + R2_BUCKET_NAME=relay-audio 39 + R2_PUBLIC_DOMAIN=relay-audio.your-account.r2.cloudflarestorage.com 40 + ``` 41 + 42 + ### 4. add dependencies 43 + 44 + ```bash 45 + uv add boto3 boto3-stubs[s3] 46 + ``` 47 + 48 + ## implementation 49 + 50 + ### config.py updates 51 + 52 + add R2 configuration to existing config: 53 + 54 + ```python 55 + # src/relay/config.py 56 + from pydantic_settings import BaseSettings 57 + 58 + class Settings(BaseSettings): 59 + # ... existing settings ... 60 + 61 + # r2 storage 62 + r2_account_id: str | None = None 63 + r2_access_key_id: str | None = None 64 + r2_secret_access_key: str | None = None 65 + r2_bucket_name: str = "relay-audio" 66 + r2_public_domain: str | None = None 67 + 68 + # storage backend selection 69 + storage_backend: str = "filesystem" # or "r2" 70 + 71 + settings = Settings() 72 + ``` 73 + 74 + ### R2 storage adapter 75 + 76 + create `src/relay/storage/r2.py`: 77 + 78 + ```python 79 + """cloudflare R2 storage for audio files.""" 80 + 81 + import hashlib 82 + from pathlib import Path 83 + from typing import BinaryIO 84 + 85 + import boto3 86 + from botocore.config import Config 87 + 88 + from relay.config import settings 89 + from relay.models import AudioFormat 90 + 91 + 92 + class R2Storage: 93 + """store audio files on cloudflare R2.""" 94 + 95 + def __init__(self): 96 + """initialize R2 client.""" 97 + if not all([ 98 + settings.r2_account_id, 99 + settings.r2_access_key_id, 100 + settings.r2_secret_access_key, 101 + ]): 102 + raise ValueError("R2 credentials not configured in environment") 103 + 104 + # create boto3 s3 client for R2 105 + self.client = boto3.client( 106 + "s3", 107 + endpoint_url=f"https://{settings.r2_account_id}.r2.cloudflarestorage.com", 108 + aws_access_key_id=settings.r2_access_key_id, 109 + aws_secret_access_key=settings.r2_secret_access_key, 110 + config=Config(signature_version="s3v4"), 111 + ) 112 + self.bucket_name = settings.r2_bucket_name 113 + self.public_domain = settings.r2_public_domain 114 + 115 + def save(self, file: BinaryIO, filename: str) -> str: 116 + """save audio file to R2 and return file id.""" 117 + # read file content 118 + content = file.read() 119 + 120 + # generate file id from content hash 121 + file_id = hashlib.sha256(content).hexdigest()[:16] 122 + 123 + # determine file extension 124 + ext = Path(filename).suffix.lower() 125 + audio_format = AudioFormat.from_extension(ext) 126 + if not audio_format: 127 + raise ValueError( 128 + f"unsupported file type: {ext}. " 129 + f"supported: {AudioFormat.supported_extensions_str()}" 130 + ) 131 + 132 + # construct s3 key 133 + key = f"audio/{file_id}{ext}" 134 + 135 + # upload to R2 136 + self.client.put_object( 137 + Bucket=self.bucket_name, 138 + Key=key, 139 + Body=content, 140 + ContentType=audio_format.media_type, 141 + ) 142 + 143 + return file_id 144 + 145 + def get_url(self, file_id: str) -> str | None: 146 + """get public URL for audio file.""" 147 + # try to find file with any supported extension 148 + for audio_format in AudioFormat: 149 + key = f"audio/{file_id}{audio_format.extension}" 150 + 151 + # check if object exists 152 + try: 153 + self.client.head_object(Bucket=self.bucket_name, Key=key) 154 + # object exists, return public URL 155 + if self.public_domain: 156 + return f"https://{self.public_domain}/{key}" 157 + return f"https://{settings.r2_account_id}.r2.cloudflarestorage.com/{self.bucket_name}/{key}" 158 + except self.client.exceptions.ClientError: 159 + continue 160 + 161 + return None 162 + 163 + def delete(self, file_id: str) -> bool: 164 + """delete audio file from R2.""" 165 + for audio_format in AudioFormat: 166 + key = f"audio/{file_id}{audio_format.extension}" 167 + 168 + try: 169 + self.client.delete_object(Bucket=self.bucket_name, Key=key) 170 + return True 171 + except self.client.exceptions.ClientError: 172 + continue 173 + 174 + return False 175 + ``` 176 + 177 + ### update storage __init__.py 178 + 179 + modify `src/relay/storage/__init__.py` to support both backends: 180 + 181 + ```python 182 + """storage implementations.""" 183 + 184 + from relay.config import settings 185 + 186 + if settings.storage_backend == "r2": 187 + from relay.storage.r2 import R2Storage 188 + storage = R2Storage() 189 + else: 190 + from relay.storage.filesystem import FilesystemStorage 191 + storage = FilesystemStorage() 192 + 193 + __all__ = ["storage"] 194 + ``` 195 + 196 + ### update audio endpoint 197 + 198 + modify `src/relay/api/audio.py` to handle R2 URLs: 199 + 200 + ```python 201 + """audio streaming endpoints.""" 202 + 203 + from fastapi import APIRouter, HTTPException 204 + from fastapi.responses import FileResponse, RedirectResponse 205 + 206 + from relay.config import settings 207 + from relay.models import AudioFormat 208 + from relay.storage import storage 209 + 210 + router = APIRouter(prefix="/audio", tags=["audio"]) 211 + 212 + 213 + @router.get("/{file_id}") 214 + async def stream_audio(file_id: str): 215 + """stream audio file.""" 216 + 217 + if settings.storage_backend == "r2": 218 + # R2: redirect to public URL 219 + from relay.storage.r2 import R2Storage 220 + if isinstance(storage, R2Storage): 221 + url = storage.get_url(file_id) 222 + if not url: 223 + raise HTTPException(status_code=404, detail="audio file not found") 224 + return RedirectResponse(url=url) 225 + 226 + # filesystem: serve file directly 227 + file_path = storage.get_path(file_id) 228 + 229 + if not file_path: 230 + raise HTTPException(status_code=404, detail="audio file not found") 231 + 232 + # determine media type based on extension 233 + audio_format = AudioFormat.from_extension(file_path.suffix) 234 + media_type = audio_format.media_type if audio_format else "audio/mpeg" 235 + 236 + return FileResponse( 237 + path=file_path, 238 + media_type=media_type, 239 + filename=file_path.name, 240 + ) 241 + ``` 242 + 243 + ## migration strategy 244 + 245 + ### option A: immediate switch (recommended for MVP) 246 + 247 + 1. set `STORAGE_BACKEND=r2` in `.env` 248 + 2. restart backend 249 + 3. all new uploads go to R2 250 + 4. old files remain on filesystem (lazy migration) 251 + 252 + ### option B: migrate existing files 253 + 254 + create migration script `scripts/migrate_to_r2.py`: 255 + 256 + ```python 257 + """migrate existing audio files from filesystem to R2.""" 258 + 259 + import sys 260 + from pathlib import Path 261 + 262 + from relay.config import settings 263 + from relay.models import Track, get_db 264 + from relay.storage.filesystem import FilesystemStorage 265 + from relay.storage.r2 import R2Storage 266 + 267 + 268 + def migrate(): 269 + """migrate all audio files from filesystem to R2.""" 270 + fs_storage = FilesystemStorage() 271 + r2_storage = R2Storage() 272 + 273 + db = next(get_db()) 274 + tracks = db.query(Track).all() 275 + 276 + print(f"migrating {len(tracks)} tracks...") 277 + 278 + for track in tracks: 279 + file_id = track.file_id 280 + file_path = fs_storage.get_path(file_id) 281 + 282 + if not file_path: 283 + print(f"warning: file not found for track {track.id}: {file_id}") 284 + continue 285 + 286 + # upload to R2 287 + with open(file_path, "rb") as f: 288 + try: 289 + r2_storage.save(f, file_path.name) 290 + print(f"migrated: {track.title} ({file_id})") 291 + except Exception as e: 292 + print(f"error migrating {file_id}: {e}") 293 + continue 294 + 295 + print("migration complete!") 296 + 297 + 298 + if __name__ == "__main__": 299 + migrate() 300 + ``` 301 + 302 + run migration: 303 + ```bash 304 + uv run python scripts/migrate_to_r2.py 305 + ``` 306 + 307 + ## testing 308 + 309 + ### 1. upload test 310 + 311 + ```bash 312 + # upload a track via portal 313 + curl -X POST http://localhost:8001/tracks/ \ 314 + -H "Cookie: session_id=your-session" \ 315 + -F "file=@test.mp3" \ 316 + -F "title=test track" \ 317 + -F "artist=test artist" 318 + ``` 319 + 320 + verify: 321 + - file appears in R2 bucket (via cloudflare dashboard) 322 + - track plays in frontend 323 + - URL in browser network tab points to R2 324 + 325 + ### 2. streaming test 326 + 327 + ```bash 328 + # get direct URL 329 + curl -I http://localhost:8001/audio/{file_id} 330 + 331 + # should see 307 redirect to R2 URL 332 + # or 200 with file content if using filesystem 333 + ``` 334 + 335 + ### 3. deletion test 336 + 337 + ```bash 338 + # delete via portal or API 339 + curl -X DELETE http://localhost:8001/tracks/{track_id} \ 340 + -H "Cookie: session_id=your-session" 341 + ``` 342 + 343 + verify: 344 + - file removed from R2 bucket 345 + - track removed from database 346 + 347 + ## rollback plan 348 + 349 + if R2 has issues: 350 + 351 + 1. set `STORAGE_BACKEND=filesystem` in `.env` 352 + 2. restart backend 353 + 3. files on filesystem still work 354 + 4. new uploads go to filesystem 355 + 356 + ## performance considerations 357 + 358 + ### latency 359 + - R2 redirect adds ~100ms vs direct file serve 360 + - acceptable for MVP, optimize later if needed 361 + 362 + ### bandwidth 363 + - R2 egress is free to cloudflare CDN 364 + - direct serve uses backend bandwidth 365 + 366 + ### caching 367 + - add `Cache-Control` headers for R2 objects: 368 + ```python 369 + self.client.put_object( 370 + # ... 371 + CacheControl="public, max-age=31536000", # 1 year 372 + ) 373 + ``` 374 + 375 + ## security considerations 376 + 377 + ### public URLs 378 + - R2 bucket needs public read access enabled 379 + - anyone with URL can access file 380 + - acceptable for MVP (music is meant to be shared) 381 + 382 + ### signed URLs (future enhancement) 383 + if you need temporary access: 384 + ```python 385 + def get_signed_url(self, file_id: str, expires_in: int = 3600) -> str: 386 + """generate signed URL with expiration.""" 387 + key = f"audio/{file_id}.mp3" 388 + return self.client.generate_presigned_url( 389 + "get_object", 390 + Params={"Bucket": self.bucket_name, "Key": key}, 391 + ExpiresIn=expires_in, 392 + ) 393 + ``` 394 + 395 + ## cost estimates 396 + 397 + cloudflare R2 pricing (as of 2025): 398 + - storage: $0.015/GB/month 399 + - class A operations (writes): $4.50/million 400 + - class B operations (reads): $0.36/million 401 + - egress: free 402 + 403 + example costs for 1000 tracks: 404 + - storage: ~10GB = $0.15/month 405 + - uploads: 1000 tracks = $0.005 406 + - streams: 10k plays = $0.004 407 + - **total: ~$0.16/month** 408 + 409 + ## next steps 410 + 411 + after R2 migration is working: 412 + 413 + 1. add R2 URL to Track model for phase 2: 414 + ```python 415 + class Track(Base): 416 + # ... existing fields ... 417 + atproto_record_uri: Mapped[str | None] = mapped_column(String, nullable=True) 418 + r2_url: Mapped[str | None] = mapped_column(String, nullable=True) 419 + ``` 420 + 421 + 2. proceed to phase 2: ATProto record creation
+541
docs/phase2-atproto-records.md
···
··· 1 + # phase 2: ATProto record creation 2 + 3 + ## overview 4 + 5 + write track metadata to user's PDS when they upload audio. the record contains R2 URLs and metadata, creating a decentralized, user-owned music catalog. 6 + 7 + ## prerequisites 8 + 9 + - phase 1 complete (R2 storage working) 10 + - OAuth 2.1 authentication working (already done) 11 + - user has valid session with access tokens 12 + 13 + ## implementation 14 + 15 + ### 1. update Track model 16 + 17 + add ATProto record tracking: 18 + 19 + ```python 20 + # src/relay/models/track.py 21 + from datetime import datetime 22 + 23 + from sqlalchemy import DateTime, Integer, String 24 + from sqlalchemy.orm import Mapped, mapped_column 25 + 26 + from relay.models.database import Base 27 + 28 + 29 + class Track(Base): 30 + """track model.""" 31 + 32 + __tablename__ = "tracks" 33 + 34 + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 35 + title: Mapped[str] = mapped_column(String, nullable=False) 36 + artist: Mapped[str] = mapped_column(String, nullable=False) 37 + album: Mapped[str | None] = mapped_column(String, nullable=True) 38 + duration: Mapped[int | None] = mapped_column(Integer, nullable=True) 39 + file_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) 40 + file_type: Mapped[str] = mapped_column(String, nullable=False) 41 + artist_did: Mapped[str] = mapped_column(String, nullable=False, index=True) 42 + artist_handle: Mapped[str] = mapped_column(String, nullable=False) 43 + created_at: Mapped[datetime] = mapped_column( 44 + DateTime, 45 + default=datetime.utcnow, 46 + nullable=False, 47 + ) 48 + 49 + # ATProto integration fields 50 + r2_url: Mapped[str | None] = mapped_column(String, nullable=True) 51 + atproto_record_uri: Mapped[str | None] = mapped_column(String, nullable=True) 52 + atproto_record_cid: Mapped[str | None] = mapped_column(String, nullable=True) 53 + ``` 54 + 55 + ### 2. create ATProto records module 56 + 57 + create `src/relay/atproto/records.py`: 58 + 59 + ```python 60 + """ATProto record creation for relay tracks.""" 61 + 62 + from datetime import datetime, timezone 63 + 64 + from atproto import Client 65 + from atproto.exceptions import AtProtocolError 66 + 67 + from relay.auth import Session as AuthSession 68 + from relay.auth import oauth_client 69 + 70 + 71 + async def create_track_record( 72 + auth_session: AuthSession, 73 + title: str, 74 + artist: str, 75 + audio_url: str, 76 + file_type: str, 77 + album: str | None = None, 78 + duration: int | None = None, 79 + ) -> tuple[str, str]: 80 + """create app.relay.track record on user's PDS. 81 + 82 + args: 83 + auth_session: authenticated user session 84 + title: track title 85 + artist: artist name 86 + audio_url: R2 URL for audio file 87 + file_type: file extension (mp3, wav, etc) 88 + album: optional album name 89 + duration: optional duration in seconds 90 + 91 + returns: 92 + tuple of (record_uri, record_cid) 93 + 94 + raises: 95 + AtProtocolError: if record creation fails 96 + ValueError: if session is invalid 97 + """ 98 + # get OAuth session with tokens 99 + oauth_session = oauth_client.get_session(auth_session.session_id) 100 + if not oauth_session: 101 + raise ValueError("OAuth session not found") 102 + 103 + # create authenticated client 104 + # note: your atproto fork's Client needs PDS URL and access token 105 + client = Client(base_url=oauth_session.pds_url) 106 + 107 + # set access token from OAuth session 108 + # this varies based on your fork's implementation 109 + # check sandbox/atproto-fork for exact API 110 + client.login( 111 + access_jwt=oauth_session.access_token, 112 + refresh_jwt=oauth_session.refresh_token if hasattr(oauth_session, 'refresh_token') else None, 113 + ) 114 + 115 + # construct record 116 + record = { 117 + "$type": "app.relay.track", 118 + "title": title, 119 + "artist": artist, 120 + "audioUrl": audio_url, 121 + "fileType": file_type, 122 + "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), 123 + } 124 + 125 + # add optional fields 126 + if album: 127 + record["album"] = album 128 + if duration: 129 + record["duration"] = duration 130 + 131 + # write to PDS 132 + response = await client.com.atproto.repo.create_record( 133 + repo=auth_session.did, 134 + collection="app.relay.track", 135 + record=record, 136 + ) 137 + 138 + return response.uri, response.cid 139 + ``` 140 + 141 + ### 3. update upload endpoint 142 + 143 + modify `src/relay/api/tracks.py` to create ATProto records: 144 + 145 + ```python 146 + """tracks api endpoints.""" 147 + 148 + from pathlib import Path 149 + from typing import Annotated 150 + 151 + from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile 152 + from sqlalchemy.orm import Session 153 + 154 + from relay.atproto.records import create_track_record 155 + from relay.auth import Session as AuthSession 156 + from relay.auth import require_auth 157 + from relay.config import settings 158 + from relay.models import AudioFormat, Track, get_db 159 + from relay.storage import storage 160 + 161 + router = APIRouter(prefix="/tracks", tags=["tracks"]) 162 + 163 + 164 + @router.post("/") 165 + async def upload_track( 166 + title: Annotated[str, Form()], 167 + artist: Annotated[str, Form()], 168 + album: Annotated[str | None, Form()] = None, 169 + file: UploadFile = File(...), 170 + auth_session: AuthSession = Depends(require_auth), 171 + db: Session = Depends(get_db), 172 + ) -> dict: 173 + """upload a new track (requires authentication).""" 174 + # validate file type 175 + if not file.filename: 176 + raise HTTPException(status_code=400, detail="no filename provided") 177 + 178 + ext = Path(file.filename).suffix.lower() 179 + audio_format = AudioFormat.from_extension(ext) 180 + if not audio_format: 181 + raise HTTPException( 182 + status_code=400, 183 + detail=f"unsupported file type: {ext}. " 184 + f"supported: {AudioFormat.supported_extensions_str()}", 185 + ) 186 + 187 + # save audio file to R2 188 + try: 189 + file_id = storage.save(file.file, file.filename) 190 + except ValueError as e: 191 + raise HTTPException(status_code=400, detail=str(e)) from e 192 + 193 + # get R2 URL 194 + if settings.storage_backend == "r2": 195 + from relay.storage.r2 import R2Storage 196 + if isinstance(storage, R2Storage): 197 + r2_url = storage.get_url(file_id) 198 + else: 199 + r2_url = None 200 + else: 201 + # filesystem: construct relay backend URL 202 + r2_url = f"http://localhost:8001/audio/{file_id}" 203 + 204 + # create ATProto record (if R2 URL available) 205 + atproto_uri = None 206 + atproto_cid = None 207 + if r2_url and settings.storage_backend == "r2": 208 + try: 209 + atproto_uri, atproto_cid = await create_track_record( 210 + auth_session=auth_session, 211 + title=title, 212 + artist=artist, 213 + audio_url=r2_url, 214 + file_type=ext[1:], # remove dot 215 + album=album, 216 + duration=None, # TODO: extract from audio file 217 + ) 218 + except Exception as e: 219 + # log but don't fail upload if record creation fails 220 + print(f"warning: failed to create ATProto record: {e}") 221 + 222 + # create track record in local database 223 + track = Track( 224 + title=title, 225 + artist=artist, 226 + album=album, 227 + file_id=file_id, 228 + file_type=ext[1:], 229 + artist_did=auth_session.did, 230 + artist_handle=auth_session.handle, 231 + r2_url=r2_url, 232 + atproto_record_uri=atproto_uri, 233 + atproto_record_cid=atproto_cid, 234 + ) 235 + 236 + db.add(track) 237 + db.commit() 238 + db.refresh(track) 239 + 240 + return { 241 + "id": track.id, 242 + "title": track.title, 243 + "artist": track.artist, 244 + "album": track.album, 245 + "file_id": track.file_id, 246 + "file_type": track.file_type, 247 + "artist_did": track.artist_did, 248 + "artist_handle": track.artist_handle, 249 + "r2_url": track.r2_url, 250 + "atproto_record_uri": track.atproto_record_uri, 251 + "created_at": track.created_at.isoformat(), 252 + } 253 + ``` 254 + 255 + ## checking your atproto fork API 256 + 257 + since you're using a custom fork, check the actual client API: 258 + 259 + ```bash 260 + # look at client implementation 261 + cat sandbox/atproto-fork/packages/atproto_client/atproto_client/client.py | grep -A 20 "class Client" 262 + 263 + # or check examples 264 + cat sandbox/atproto-fork/examples/*/main.py 265 + ``` 266 + 267 + key questions to answer: 268 + 1. how to initialize authenticated client with OAuth tokens? 269 + 2. does it use `client.login()` or set tokens directly? 270 + 3. what's the method signature for `create_record()`? 271 + 272 + ## database migration 273 + 274 + create migration for new columns: 275 + 276 + ```bash 277 + # if using alembic 278 + alembic revision -m "add atproto fields to tracks" 279 + ``` 280 + 281 + or manual SQL: 282 + ```sql 283 + ALTER TABLE tracks ADD COLUMN r2_url VARCHAR; 284 + ALTER TABLE tracks ADD COLUMN atproto_record_uri VARCHAR; 285 + ALTER TABLE tracks ADD COLUMN atproto_record_cid VARCHAR; 286 + ``` 287 + 288 + for MVP, just delete the database: 289 + ```bash 290 + rm data/relay.db 291 + # restart backend (will recreate tables) 292 + ``` 293 + 294 + ## testing 295 + 296 + ### 1. upload with record creation 297 + 298 + ```bash 299 + # upload via authenticated session 300 + curl -X POST http://localhost:8001/tracks/ \ 301 + -H "Cookie: session_id=your-session" \ 302 + -F "file=@test.mp3" \ 303 + -F "title=test track" \ 304 + -F "artist=test artist" 305 + 306 + # response should include: 307 + { 308 + "atproto_record_uri": "at://did:plc:abc123/app.relay.track/3k2j4h5g6h", 309 + "r2_url": "https://relay-audio.r2.cloudflarestorage.com/audio/abc123.mp3", 310 + ... 311 + } 312 + ``` 313 + 314 + ### 2. verify record on PDS 315 + 316 + use ATProto tools to check record exists: 317 + 318 + ```python 319 + from atproto import Client 320 + 321 + client = Client() 322 + client.login("your-handle", "your-app-password") 323 + 324 + # get record 325 + record = client.com.atproto.repo.get_record( 326 + repo="your-did", 327 + collection="app.relay.track", 328 + rkey="3k2j4h5g6h", 329 + ) 330 + 331 + print(record) 332 + # should show: title, artist, audioUrl, etc. 333 + ``` 334 + 335 + ### 3. check via at-me or similar tool 336 + 337 + visit your at-me visualization: 338 + - should see `app.relay.track` collection 339 + - records should list all uploaded tracks 340 + - each record should have R2 URL in `audioUrl` field 341 + 342 + ## error handling 343 + 344 + ### OAuth token expired 345 + 346 + ```python 347 + try: 348 + atproto_uri, atproto_cid = await create_track_record(...) 349 + except AtProtocolError as e: 350 + if "token" in str(e).lower(): 351 + # token expired, refresh and retry 352 + await oauth_client.refresh_session(auth_session.session_id) 353 + atproto_uri, atproto_cid = await create_track_record(...) 354 + else: 355 + raise 356 + ``` 357 + 358 + ### network failure 359 + 360 + ```python 361 + import asyncio 362 + 363 + try: 364 + atproto_uri, atproto_cid = await asyncio.wait_for( 365 + create_track_record(...), 366 + timeout=10.0, # 10 second timeout 367 + ) 368 + except asyncio.TimeoutError: 369 + print("atproto record creation timed out") 370 + atproto_uri = None 371 + atproto_cid = None 372 + ``` 373 + 374 + ### PDS unavailable 375 + 376 + gracefully degrade: 377 + - save track locally without record 378 + - add background job to retry record creation later 379 + - show user "publishing to atproto..." status 380 + 381 + ## frontend updates 382 + 383 + update portal page to show ATProto status: 384 + 385 + ```svelte 386 + <!-- frontend/src/routes/portal/+page.svelte --> 387 + 388 + {#each tracks as track} 389 + <div class="track-item"> 390 + <div class="track-info"> 391 + <div class="track-title">{track.title}</div> 392 + <div class="track-meta"> 393 + {track.artist} 394 + {#if track.atproto_record_uri} 395 + <span class="atproto-badge" title={track.atproto_record_uri}> 396 + ✓ published to atproto 397 + </span> 398 + {/if} 399 + </div> 400 + </div> 401 + </div> 402 + {/each} 403 + 404 + <style> 405 + .atproto-badge { 406 + color: #5ce87b; 407 + font-size: 0.85rem; 408 + margin-left: 0.5rem; 409 + } 410 + </style> 411 + ``` 412 + 413 + ## next steps 414 + 415 + ### track discovery (phase 3) 416 + 417 + once records are being created: 418 + 419 + 1. set up jetstream consumer (like status project) 420 + 2. listen for `app.relay.track` commits 421 + 3. index tracks from other users 422 + 4. add discovery feed to frontend 423 + 424 + ### metadata extraction 425 + 426 + add duration extraction: 427 + 428 + ```bash 429 + uv add mutagen 430 + ``` 431 + 432 + ```python 433 + from mutagen import File as MutagenFile 434 + 435 + def extract_audio_metadata(file_path: Path) -> dict: 436 + """extract metadata from audio file.""" 437 + audio = MutagenFile(file_path) 438 + return { 439 + "duration": int(audio.info.length) if audio.info else None, 440 + # add more metadata as needed 441 + } 442 + ``` 443 + 444 + ### record updates 445 + 446 + allow users to update track metadata: 447 + 448 + ```python 449 + async def update_track_record( 450 + auth_session: AuthSession, 451 + record_uri: str, 452 + **updates, 453 + ) -> str: 454 + """update existing record.""" 455 + # parse record URI to get rkey 456 + rkey = record_uri.split("/")[-1] 457 + 458 + # get current record 459 + record = await client.com.atproto.repo.get_record( 460 + repo=auth_session.did, 461 + collection="app.relay.track", 462 + rkey=rkey, 463 + ) 464 + 465 + # update fields 466 + record.update(updates) 467 + 468 + # write back 469 + response = await client.com.atproto.repo.put_record( 470 + repo=auth_session.did, 471 + collection="app.relay.track", 472 + rkey=rkey, 473 + record=record, 474 + ) 475 + 476 + return response.cid 477 + ``` 478 + 479 + ## security considerations 480 + 481 + ### validate R2 URLs 482 + 483 + ensure URLs only point to your R2 bucket: 484 + 485 + ```python 486 + def validate_r2_url(url: str) -> bool: 487 + """ensure URL is from our R2 bucket.""" 488 + from urllib.parse import urlparse 489 + 490 + parsed = urlparse(url) 491 + allowed_domains = [ 492 + settings.r2_public_domain, 493 + f"{settings.r2_account_id}.r2.cloudflarestorage.com", 494 + ] 495 + return parsed.hostname in allowed_domains 496 + ``` 497 + 498 + ### rate limiting 499 + 500 + prevent spam record creation: 501 + 502 + ```python 503 + from fastapi_limiter import FastAPILimiter 504 + from fastapi_limiter.depends import RateLimiter 505 + 506 + @router.post("/", dependencies=[Depends(RateLimiter(times=10, seconds=3600))]) 507 + async def upload_track(...): 508 + """upload track (max 10/hour).""" 509 + ... 510 + ``` 511 + 512 + ## monitoring 513 + 514 + ### track record creation success rate 515 + 516 + ```python 517 + import logging 518 + 519 + logger = logging.getLogger(__name__) 520 + 521 + try: 522 + atproto_uri, atproto_cid = await create_track_record(...) 523 + logger.info(f"created atproto record: {atproto_uri}") 524 + except Exception as e: 525 + logger.error(f"failed to create atproto record: {e}", exc_info=True) 526 + ``` 527 + 528 + ### dashboard metrics 529 + 530 + add to admin panel: 531 + - total tracks uploaded 532 + - tracks with ATProto records 533 + - record creation success rate 534 + - average record creation latency 535 + 536 + ## references 537 + 538 + - atproto repo spec: https://atproto.com/specs/repository 539 + - atproto lexicon spec: https://atproto.com/specs/lexicon 540 + - your atproto fork: `sandbox/atproto-fork/` 541 + - example record creation: `sandbox/at-me/` and `sandbox/status/`
+143
docs/theming.md
···
··· 1 + # theming guide 2 + 3 + ## color system 4 + 5 + all colors are centralized in `frontend/src/lib/theme.ts` for easy customization. 6 + 7 + ### current color palette 8 + 9 + #### backgrounds 10 + - **primary**: `#0a0a0a` - main app background 11 + - **secondary**: `#141414` - card backgrounds 12 + - **tertiary**: `#1a1a1a` - hover states 13 + - **hover**: `#1f1f1f` - active hover 14 + 15 + #### borders 16 + - **subtle**: `#282828` - default borders 17 + - **default**: `#333333` - emphasized borders 18 + - **emphasis**: `#444444` - strong borders 19 + 20 + #### text 21 + - **primary**: `#e8e8e8` - main text, titles 22 + - **secondary**: `#b0b0b0` - subtitles, labels 23 + - **tertiary**: `#808080` - meta info 24 + - **muted**: `#666666` - disabled/very subtle 25 + 26 + #### accents 27 + - **primary**: `#6a9fff` - links, buttons, highlights 28 + - **hover**: `#8ab3ff` - accent hover state 29 + - **muted**: `#4a7ddd` - subtle accent 30 + 31 + ## changing colors 32 + 33 + ### option 1: edit theme.ts (recommended) 34 + 35 + update `frontend/src/lib/theme.ts`: 36 + 37 + ```typescript 38 + export const theme = { 39 + colors: { 40 + accent: { 41 + primary: '#your-color', // change this 42 + hover: '#your-hover', 43 + muted: '#your-muted' 44 + } 45 + } 46 + }; 47 + ``` 48 + 49 + ### option 2: global find/replace 50 + 51 + for quick experiments, find/replace these key colors: 52 + 53 + - **accent blue**: `#6a9fff` → your color 54 + - **bright text**: `#e8e8e8` → your color 55 + - **mid text**: `#b0b0b0` → your color 56 + - **muted text**: `#808080` → your color 57 + 58 + ### option 3: user-configurable themes (future) 59 + 60 + the centralized theme.ts makes it easy to: 61 + 1. create multiple theme objects (light, dark, custom) 62 + 2. store user preference in localStorage 63 + 3. apply theme via context or global state 64 + 4. hot-swap at runtime 65 + 66 + example future implementation: 67 + 68 + ```typescript 69 + // themes.ts 70 + export const themes = { 71 + dark: { colors: { ... } }, 72 + light: { colors: { ... } }, 73 + custom: { colors: { ... } } 74 + }; 75 + 76 + // app context 77 + let selectedTheme = $state('dark'); 78 + let currentTheme = $derived(themes[selectedTheme]); 79 + ``` 80 + 81 + ## design principles 82 + 83 + ### contrast ratios 84 + - **titles** (`#e8e8e8` on `#0a0a0a`): 15:1 ratio 85 + - **body text** (`#b0b0b0` on `#0a0a0a`): 11:1 ratio 86 + - **meta text** (`#808080` on `#0a0a0a`): 7:1 ratio 87 + 88 + all exceed wcag aa standards for accessibility. 89 + 90 + ### visual hierarchy 91 + 1. **primary** - track titles, headings (`#e8e8e8`) 92 + 2. **secondary** - artist names, navigation (`#b0b0b0`) 93 + 3. **tertiary** - metadata, handles (`#808080`) 94 + 4. **muted** - timestamps, separators (`#666666`) 95 + 96 + ### accent usage 97 + - **primary action** - login button, playing indicator 98 + - **hover states** - interactive feedback 99 + - **links** - atproto record links 100 + - **borders** - active/playing track highlight 101 + 102 + ## component-specific colors 103 + 104 + ### trackitem 105 + - title: `#e8e8e8` (bright, pops) 106 + - artist: `#b0b0b0` (readable) 107 + - album: `#909090` (subtle) 108 + - handle: `#808080` (meta) 109 + - record link: `#6a9fff` (accent) 110 + 111 + ### header 112 + - brand: `#e8e8e8` → `#6a9fff` on hover 113 + - tagline: `#909090` 114 + - nav links: `#b0b0b0` → `#e8e8e8` on hover 115 + - buttons: `#6a9fff` border 116 + 117 + ### player 118 + - track title: `#e8e8e8` 119 + - artist: `#b0b0b0` 120 + - controls: inherit from parent 121 + - progress bar: accent colors 122 + 123 + ## testing colors 124 + 125 + ```bash 126 + # start dev server 127 + cd frontend && bun run dev 128 + 129 + # navigate to http://localhost:5173 130 + # check: 131 + # - track titles are clearly readable 132 + # - hover states provide feedback 133 + # - playing state is obvious 134 + # - links stand out but don't overpower 135 + ``` 136 + 137 + ## future enhancements 138 + 139 + 1. **css custom properties**: migrate to css variables for runtime theming 140 + 2. **theme switcher**: ui control for theme selection 141 + 3. **user themes**: allow custom color uploads 142 + 4. **accessibility**: add high contrast mode 143 + 5. **presets**: curated theme collections
+37
fly.toml
···
··· 1 + app = 'relay-api' 2 + primary_region = 'iad' 3 + 4 + [build] 5 + 6 + [http_service] 7 + internal_port = 8000 8 + force_https = true 9 + auto_stop_machines = 'stop' 10 + auto_start_machines = true 11 + min_machines_running = 0 12 + processes = ['app'] 13 + 14 + [http_service.concurrency] 15 + type = 'requests' 16 + hard_limit = 250 17 + soft_limit = 200 18 + 19 + [[vm]] 20 + memory = '1gb' 21 + cpu_kind = 'shared' 22 + cpus = 1 23 + 24 + [env] 25 + PORT = '8000' 26 + STORAGE_BACKEND = 'r2' 27 + R2_BUCKET = 'relay' 28 + R2_ENDPOINT_URL = 'https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com' 29 + R2_PUBLIC_BUCKET_URL = 'https://pub-841ec0f5a7854eaab01292d44aca4820.r2.dev' 30 + ATPROTO_PDS_URL = 'https://pds.zzstoatzz.io' 31 + 32 + # secrets to set via: fly secrets set KEY=value 33 + # - DATABASE_URL 34 + # - AWS_ACCESS_KEY_ID 35 + # - AWS_SECRET_ACCESS_KEY 36 + # - ATPROTO_CLIENT_ID (will be https://relay-api.fly.dev/client-metadata.json after deployment) 37 + # - ATPROTO_REDIRECT_URI (will be https://relay-api.fly.dev/auth/callback after deployment)
frontend/bun.lockb

This is a binary file and will not be displayed.

+2 -1
frontend/package.json
··· 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 13 }, 14 "devDependencies": { 15 - "@sveltejs/adapter-auto": "^6.1.0", 16 "@sveltejs/kit": "^2.43.2", 17 "@sveltejs/vite-plugin-svelte": "^6.2.0", 18 "svelte": "^5.39.5",
··· 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 13 }, 14 "devDependencies": { 15 + "@sveltejs/adapter-auto": "^7.0.0", 16 + "@sveltejs/adapter-vercel": "^6.1.1", 17 "@sveltejs/kit": "^2.43.2", 18 "@sveltejs/vite-plugin-svelte": "^6.2.0", 19 "svelte": "^5.39.5",
+127
frontend/src/lib/components/Header.svelte
···
··· 1 + <script lang="ts"> 2 + import type { User } from '$lib/types'; 3 + 4 + interface Props { 5 + user: User | null; 6 + onLogout: () => Promise<void>; 7 + } 8 + 9 + let { user, onLogout }: Props = $props(); 10 + 11 + let isAuthenticated = $derived(user !== null); 12 + </script> 13 + 14 + <header> 15 + <div class="header-content"> 16 + <a href="/" class="brand"> 17 + <h1>relay</h1> 18 + <p>music on atproto</p> 19 + </a> 20 + 21 + <nav> 22 + {#if isAuthenticated} 23 + <a href="/portal" class="nav-link">portal</a> 24 + <span class="user-info">@{user.handle}</span> 25 + <button onclick={onLogout} class="btn-secondary">logout</button> 26 + {:else} 27 + <a href="/login" class="btn-primary">login</a> 28 + {/if} 29 + </nav> 30 + </div> 31 + </header> 32 + 33 + <style> 34 + header { 35 + border-bottom: 1px solid #333; 36 + margin-bottom: 2rem; 37 + } 38 + 39 + .header-content { 40 + max-width: 800px; 41 + margin: 0 auto; 42 + padding: 1.5rem 1rem; 43 + display: flex; 44 + justify-content: space-between; 45 + align-items: center; 46 + } 47 + 48 + .brand { 49 + text-decoration: none; 50 + color: inherit; 51 + display: flex; 52 + flex-direction: column; 53 + gap: 0.25rem; 54 + } 55 + 56 + .brand:hover h1 { 57 + color: #6a9fff; 58 + } 59 + 60 + h1 { 61 + font-size: 1.5rem; 62 + margin: 0; 63 + color: #e8e8e8; 64 + transition: color 0.2s; 65 + } 66 + 67 + .brand p { 68 + margin: 0; 69 + font-size: 0.85rem; 70 + color: #909090; 71 + } 72 + 73 + nav { 74 + display: flex; 75 + align-items: center; 76 + gap: 1rem; 77 + } 78 + 79 + .nav-link { 80 + color: #b0b0b0; 81 + text-decoration: none; 82 + font-size: 0.9rem; 83 + transition: color 0.2s; 84 + } 85 + 86 + .nav-link:hover { 87 + color: #e8e8e8; 88 + } 89 + 90 + .user-info { 91 + color: #909090; 92 + font-size: 0.9rem; 93 + } 94 + 95 + .btn-primary { 96 + background: transparent; 97 + border: 1px solid #6a9fff; 98 + color: #6a9fff; 99 + padding: 0.5rem 1rem; 100 + border-radius: 4px; 101 + font-size: 0.9rem; 102 + text-decoration: none; 103 + transition: all 0.2s; 104 + cursor: pointer; 105 + } 106 + 107 + .btn-primary:hover { 108 + background: #6a9fff; 109 + color: #0a0a0a; 110 + } 111 + 112 + .btn-secondary { 113 + background: transparent; 114 + border: 1px solid #444; 115 + color: #b0b0b0; 116 + padding: 0.5rem 1rem; 117 + border-radius: 4px; 118 + font-size: 0.9rem; 119 + cursor: pointer; 120 + transition: all 0.2s; 121 + } 122 + 123 + .btn-secondary:hover { 124 + border-color: #666; 125 + color: #e8e8e8; 126 + } 127 + </style>
+113
frontend/src/lib/components/TrackItem.svelte
···
··· 1 + <script lang="ts"> 2 + import type { Track } from '$lib/types'; 3 + 4 + interface Props { 5 + track: Track; 6 + isPlaying?: boolean; 7 + onPlay: (track: Track) => void; 8 + } 9 + 10 + let { track, isPlaying = false, onPlay }: Props = $props(); 11 + </script> 12 + 13 + <button 14 + class="track" 15 + class:playing={isPlaying} 16 + onclick={() => onPlay(track)} 17 + > 18 + <div class="track-info"> 19 + <div class="track-title">{track.title}</div> 20 + <div class="track-artist"> 21 + {track.artist} 22 + {#if track.album} 23 + <span class="album">- {track.album}</span> 24 + {/if} 25 + </div> 26 + <div class="track-meta"> 27 + <span>@{track.artist_handle}</span> 28 + {#if track.atproto_record_uri} 29 + {@const parts = track.atproto_record_uri.split('/')} 30 + {@const did = parts[2]} 31 + {@const collection = parts[3]} 32 + {@const rkey = parts[4]} 33 + <span class="separator">•</span> 34 + <a 35 + href={`https://pds.zzstoatzz.io/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`} 36 + target="_blank" 37 + rel="noopener" 38 + class="atproto-link" 39 + onclick={(e) => e.stopPropagation()} 40 + > 41 + view record 42 + </a> 43 + {/if} 44 + </div> 45 + </div> 46 + </button> 47 + 48 + <style> 49 + .track { 50 + background: #141414; 51 + border: 1px solid #282828; 52 + border-left: 3px solid transparent; 53 + padding: 1rem; 54 + cursor: pointer; 55 + text-align: left; 56 + transition: all 0.15s ease-in-out; 57 + width: 100%; 58 + } 59 + 60 + .track:hover { 61 + background: #1a1a1a; 62 + border-left-color: #6a9fff; 63 + border-color: #333; 64 + transform: translateX(2px); 65 + } 66 + 67 + .track.playing { 68 + background: #1a2330; 69 + border-left-color: #6a9fff; 70 + border-color: #2a3a4a; 71 + } 72 + 73 + .track-title { 74 + font-weight: 600; 75 + font-size: 1.1rem; 76 + margin-bottom: 0.25rem; 77 + color: #e8e8e8; 78 + } 79 + 80 + .track-artist { 81 + color: #b0b0b0; 82 + margin-bottom: 0.25rem; 83 + } 84 + 85 + .album { 86 + color: #909090; 87 + } 88 + 89 + .track-meta { 90 + font-size: 0.85rem; 91 + color: #808080; 92 + display: flex; 93 + align-items: center; 94 + gap: 0.5rem; 95 + } 96 + 97 + .track-meta .separator { 98 + color: #555; 99 + } 100 + 101 + .atproto-link { 102 + color: #6a9fff; 103 + text-decoration: none; 104 + font-size: 0.8rem; 105 + transition: all 0.2s; 106 + border-bottom: 1px solid transparent; 107 + } 108 + 109 + .atproto-link:hover { 110 + color: #8ab3ff; 111 + border-bottom-color: #8ab3ff; 112 + } 113 + </style>
+7
frontend/src/lib/config.ts
···
··· 1 + import { browser } from '$app/environment'; 2 + 3 + export const API_URL = browser 4 + ? (window.location.hostname === 'localhost' 5 + ? 'http://localhost:8001' 6 + : 'https://relay-api.fly.dev') 7 + : 'https://relay-api.fly.dev';
+48
frontend/src/lib/theme.ts
···
··· 1 + export const theme = { 2 + colors: { 3 + // backgrounds 4 + bg: { 5 + primary: '#0a0a0a', 6 + secondary: '#141414', 7 + tertiary: '#1a1a1a', 8 + hover: '#1f1f1f' 9 + }, 10 + // borders 11 + border: { 12 + subtle: '#282828', 13 + default: '#333333', 14 + emphasis: '#444444' 15 + }, 16 + // text 17 + text: { 18 + primary: '#e8e8e8', 19 + secondary: '#b0b0b0', 20 + tertiary: '#808080', 21 + muted: '#666666' 22 + }, 23 + // accents 24 + accent: { 25 + primary: '#6a9fff', 26 + hover: '#8ab3ff', 27 + muted: '#4a7ddd' 28 + }, 29 + // semantic 30 + success: '#4ade80', 31 + warning: '#fbbf24', 32 + error: '#ef4444' 33 + }, 34 + spacing: { 35 + xs: '0.25rem', 36 + sm: '0.5rem', 37 + md: '1rem', 38 + lg: '1.5rem', 39 + xl: '2rem' 40 + }, 41 + radius: { 42 + sm: '4px', 43 + md: '6px', 44 + lg: '8px' 45 + } 46 + }; 47 + 48 + export type Theme = typeof theme;
+16
frontend/src/lib/types.ts
···
··· 1 + export interface Track { 2 + id: number; 3 + title: string; 4 + artist: string; 5 + album?: string; 6 + file_id: string; 7 + file_type: string; 8 + artist_handle: string; 9 + r2_url?: string; 10 + atproto_record_uri?: string; 11 + } 12 + 13 + export interface User { 14 + did: string; 15 + handle: string; 16 + }
+265 -168
frontend/src/routes/+page.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 4 - interface Track { 5 - id: number; 6 - title: string; 7 - artist: string; 8 - album?: string; 9 - file_id: string; 10 - file_type: string; 11 - artist_handle: string; 12 - } 13 14 - interface User { 15 - did: string; 16 - handle: string; 17 - } 18 19 - let tracks: Track[] = []; 20 - let currentTrack: Track | null = null; 21 - let audioElement: HTMLAudioElement; 22 - let user: User | null = null; 23 24 onMount(async () => { 25 // check authentication 26 try { 27 - const authResponse = await fetch('http://localhost:8000/auth/me', { 28 credentials: 'include' 29 }); 30 if (authResponse.ok) { ··· 35 } 36 37 // load tracks 38 - const response = await fetch('http://localhost:8000/tracks/'); 39 const data = await response.json(); 40 tracks = data.tracks; 41 }); 42 43 function playTrack(track: Track) { 44 - currentTrack = track; 45 - if (audioElement) { 46 - audioElement.src = `http://localhost:8000/audio/${track.file_id}`; 47 - audioElement.play(); 48 } 49 } 50 51 async function logout() { 52 - await fetch('http://localhost:8000/auth/logout', { 53 method: 'POST', 54 credentials: 'include' 55 }); ··· 57 } 58 </script> 59 60 <main> 61 - <header> 62 - <div class="header-top"> 63 - <div> 64 - <h1>relay</h1> 65 - <p>decentralized music on ATProto</p> 66 - </div> 67 - <div class="auth-section"> 68 - {#if user} 69 - <span class="user-info">@{user.handle}</span> 70 - <button onclick={logout} class="logout-btn">logout</button> 71 - {:else} 72 - <a href="/login" class="login-link">login</a> 73 - {/if} 74 - </div> 75 - </div> 76 - {#if user} 77 - <a href="/portal">artist portal →</a> 78 - {/if} 79 - </header> 80 81 <section class="tracks"> 82 <h2>latest tracks</h2> 83 - {#if tracks.length === 0} 84 <p class="empty">no tracks yet</p> 85 {:else} 86 <div class="track-list"> 87 {#each tracks as track} 88 - <button 89 - class="track" 90 - class:playing={currentTrack?.id === track.id} 91 - onclick={() => playTrack(track)} 92 - > 93 - <div class="track-info"> 94 - <div class="track-title">{track.title}</div> 95 - <div class="track-artist"> 96 - {track.artist} 97 - {#if track.album} 98 - <span class="album">- {track.album}</span> 99 - {/if} 100 - </div> 101 - <div class="track-meta">@{track.artist_handle}</div> 102 - </div> 103 - </button> 104 {/each} 105 </div> 106 {/if} ··· 108 109 {#if currentTrack} 110 <div class="player"> 111 - <div class="now-playing"> 112 - <strong>{currentTrack.title}</strong> by {currentTrack.artist} 113 </div> 114 - <audio bind:this={audioElement} controls></audio> 115 </div> 116 {/if} 117 </main> ··· 120 :global(body) { 121 margin: 0; 122 padding: 0; 123 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 124 background: #0a0a0a; 125 - color: #fff; 126 } 127 128 main { 129 max-width: 800px; 130 margin: 0 auto; 131 - padding: 2rem 1rem 120px; 132 } 133 134 - header { 135 - margin-bottom: 3rem; 136 } 137 138 - .header-top { 139 - display: flex; 140 - justify-content: space-between; 141 - align-items: flex-start; 142 - margin-bottom: 1rem; 143 } 144 145 - h1 { 146 - font-size: 2.5rem; 147 - margin: 0 0 0.5rem; 148 } 149 150 - header p { 151 - color: #888; 152 - margin: 0 0 1rem; 153 } 154 155 - .auth-section { 156 display: flex; 157 align-items: center; 158 - gap: 1rem; 159 } 160 161 - .user-info { 162 - color: #aaa; 163 - font-size: 0.9rem; 164 } 165 166 - .logout-btn { 167 - background: transparent; 168 - border: 1px solid #444; 169 - color: #aaa; 170 - padding: 0.4rem 0.8rem; 171 - border-radius: 4px; 172 - cursor: pointer; 173 - font-size: 0.9rem; 174 - transition: all 0.2s; 175 } 176 177 - .logout-btn:hover { 178 - border-color: #666; 179 - color: #fff; 180 } 181 182 - .login-link { 183 - color: #3a7dff; 184 - text-decoration: none; 185 - font-size: 0.9rem; 186 - padding: 0.4rem 0.8rem; 187 - border: 1px solid #3a7dff; 188 - border-radius: 4px; 189 - transition: all 0.2s; 190 } 191 192 - .login-link:hover { 193 - background: #3a7dff; 194 - color: white; 195 } 196 197 - header > a { 198 color: #3a7dff; 199 - text-decoration: none; 200 - font-size: 0.9rem; 201 } 202 203 - header > a:hover { 204 - text-decoration: underline; 205 } 206 207 - .tracks h2 { 208 - font-size: 1.5rem; 209 - margin-bottom: 1.5rem; 210 } 211 212 - .empty { 213 - color: #666; 214 - padding: 2rem; 215 - text-align: center; 216 - } 217 - 218 - .track-list { 219 - display: flex; 220 - flex-direction: column; 221 - gap: 0.5rem; 222 } 223 224 - .track { 225 - background: #1a1a1a; 226 - border: 1px solid #2a2a2a; 227 - border-left: 3px solid transparent; 228 - padding: 1rem; 229 cursor: pointer; 230 - text-align: left; 231 transition: all 0.2s; 232 - width: 100%; 233 } 234 235 - .track:hover { 236 - background: #222; 237 - border-left-color: #3a7dff; 238 } 239 240 - .track.playing { 241 - background: #1a2332; 242 - border-left-color: #3a7dff; 243 } 244 245 - .track-title { 246 - font-weight: 600; 247 - font-size: 1.1rem; 248 - margin-bottom: 0.25rem; 249 } 250 251 - .track-artist { 252 - color: #aaa; 253 - margin-bottom: 0.25rem; 254 } 255 256 - .album { 257 color: #888; 258 } 259 260 - .track-meta { 261 - font-size: 0.85rem; 262 - color: #666; 263 } 264 265 - .player { 266 - position: fixed; 267 - bottom: 0; 268 - left: 0; 269 - right: 0; 270 - background: #1a1a1a; 271 - border-top: 1px solid #2a2a2a; 272 - padding: 1rem; 273 - display: flex; 274 - align-items: center; 275 - gap: 1rem; 276 } 277 278 - .now-playing { 279 - flex: 1; 280 - min-width: 0; 281 } 282 283 - .now-playing strong { 284 - color: #fff; 285 } 286 287 - audio { 288 - flex: 1; 289 - max-width: 400px; 290 } 291 </style>
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 + import TrackItem from '$lib/components/TrackItem.svelte'; 4 + import Header from '$lib/components/Header.svelte'; 5 + import type { Track, User } from '$lib/types'; 6 + import { API_URL } from '$lib/config'; 7 8 + let tracks = $state<Track[]>([]); 9 + let currentTrack = $state<Track | null>(null); 10 + let audioElement = $state<HTMLAudioElement | undefined>(undefined); 11 + let user = $state<User | null>(null); 12 13 + // player state - using Svelte's built-in bindings 14 + let paused = $state(true); 15 + let currentTime = $state(0); 16 + let duration = $state(0); 17 + let volume = $state(0.7); 18 19 + // derived values 20 + let hasTracks = $derived(tracks.length > 0); 21 + let isAuthenticated = $derived(user !== null); 22 + let formattedCurrentTime = $derived(formatTime(currentTime)); 23 + let formattedDuration = $derived(formatTime(duration)); 24 25 onMount(async () => { 26 // check authentication 27 try { 28 + const authResponse = await fetch('`${API_URL}`/auth/me', { 29 credentials: 'include' 30 }); 31 if (authResponse.ok) { ··· 36 } 37 38 // load tracks 39 + const response = await fetch('`${API_URL}`/tracks/'); 40 const data = await response.json(); 41 tracks = data.tracks; 42 }); 43 44 + // Use $effect to reactively handle track changes only 45 + let previousTrackId: number | null = null; 46 + $effect(() => { 47 + if (!currentTrack || !audioElement) return; 48 + 49 + // Only load new track if it actually changed 50 + if (currentTrack.id !== previousTrackId) { 51 + previousTrackId = currentTrack.id; 52 + audioElement.src = `${API_URL}/audio/${currentTrack.file_id}`; 53 + audioElement.load(); 54 + 55 + if (!paused) { 56 + audioElement.play().catch(err => { 57 + console.error('playback failed:', err); 58 + paused = true; 59 + }); 60 + } 61 + } 62 + }); 63 + 64 function playTrack(track: Track) { 65 + if (currentTrack?.id === track.id) { 66 + // toggle play/pause on same track 67 + paused = !paused; 68 + } else { 69 + // switch tracks 70 + currentTrack = track; 71 + paused = false; 72 } 73 } 74 75 + function formatTime(seconds: number): string { 76 + if (!seconds || isNaN(seconds)) return '0:00'; 77 + const mins = Math.floor(seconds / 60); 78 + const secs = Math.floor(seconds % 60); 79 + return `${mins}:${secs.toString().padStart(2, '0')}`; 80 + } 81 + 82 async function logout() { 83 + await fetch('`${API_URL}`/auth/logout', { 84 method: 'POST', 85 credentials: 'include' 86 }); ··· 88 } 89 </script> 90 91 + <Header {user} onLogout={logout} /> 92 + 93 <main> 94 95 <section class="tracks"> 96 <h2>latest tracks</h2> 97 + {#if !hasTracks} 98 <p class="empty">no tracks yet</p> 99 {:else} 100 <div class="track-list"> 101 {#each tracks as track} 102 + <TrackItem 103 + {track} 104 + isPlaying={currentTrack?.id === track.id} 105 + onPlay={playTrack} 106 + /> 107 {/each} 108 </div> 109 {/if} ··· 111 112 {#if currentTrack} 113 <div class="player"> 114 + <audio 115 + bind:this={audioElement} 116 + bind:paused 117 + bind:currentTime 118 + bind:duration 119 + bind:volume 120 + onended={() => { 121 + currentTime = 0; 122 + paused = true; 123 + }} 124 + ></audio> 125 + 126 + <div class="player-content"> 127 + <div class="player-info"> 128 + <div class="player-title">{currentTrack.title}</div> 129 + <div class="player-artist">{currentTrack.artist}</div> 130 + </div> 131 + 132 + <div class="player-controls"> 133 + <button class="control-btn" onclick={() => paused = !paused} title={paused ? 'Play' : 'Pause'}> 134 + {#if !paused} 135 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 136 + <rect x="6" y="4" width="4" height="16" rx="1"></rect> 137 + <rect x="14" y="4" width="4" height="16" rx="1"></rect> 138 + </svg> 139 + {:else} 140 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 141 + <path d="M8 5v14l11-7z"></path> 142 + </svg> 143 + {/if} 144 + </button> 145 + 146 + <div class="time-control"> 147 + <span class="time">{formattedCurrentTime}</span> 148 + <input 149 + type="range" 150 + class="seek-bar" 151 + min="0" 152 + max={duration || 0} 153 + bind:value={currentTime} 154 + /> 155 + <span class="time">{formattedDuration}</span> 156 + </div> 157 + 158 + <div class="volume-control"> 159 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 160 + <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon> 161 + <path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path> 162 + </svg> 163 + <input 164 + type="range" 165 + class="volume-bar" 166 + min="0" 167 + max="1" 168 + step="0.01" 169 + bind:value={volume} 170 + /> 171 + </div> 172 + </div> 173 </div> 174 </div> 175 {/if} 176 </main> ··· 179 :global(body) { 180 margin: 0; 181 padding: 0; 182 + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 183 background: #0a0a0a; 184 + color: #e0e0e0; 185 + -webkit-font-smoothing: antialiased; 186 } 187 188 main { 189 max-width: 800px; 190 margin: 0 auto; 191 + padding: 0 1rem 120px; 192 } 193 194 + .tracks h2 { 195 + font-size: 1.5rem; 196 + margin-bottom: 1.5rem; 197 + color: #e8e8e8; 198 } 199 200 + .empty { 201 + color: #808080; 202 + padding: 2rem; 203 + text-align: center; 204 } 205 206 + .track-list { 207 + display: flex; 208 + flex-direction: column; 209 + gap: 0.5rem; 210 } 211 212 + .player { 213 + position: fixed; 214 + bottom: 0; 215 + left: 0; 216 + right: 0; 217 + background: #1a1a1a; 218 + border-top: 1px solid #2a2a2a; 219 + padding: 1rem; 220 + z-index: 100; 221 } 222 223 + .player-content { 224 + max-width: 1200px; 225 + margin: 0 auto; 226 display: flex; 227 align-items: center; 228 + gap: 2rem; 229 } 230 231 + .player-info { 232 + flex: 0 0 200px; 233 + min-width: 0; 234 } 235 236 + .player-title { 237 + font-weight: 600; 238 + font-size: 0.95rem; 239 + margin-bottom: 0.25rem; 240 + color: #e8e8e8; 241 + white-space: nowrap; 242 + overflow: hidden; 243 + text-overflow: ellipsis; 244 } 245 246 + .player-artist { 247 + color: #b0b0b0; 248 + font-size: 0.85rem; 249 + white-space: nowrap; 250 + overflow: hidden; 251 + text-overflow: ellipsis; 252 } 253 254 + .player-controls { 255 + flex: 1; 256 + display: flex; 257 + align-items: center; 258 + gap: 1.5rem; 259 } 260 261 + .control-btn { 262 + background: transparent; 263 + border: none; 264 + color: #fff; 265 + cursor: pointer; 266 + padding: 0.5rem; 267 + display: flex; 268 + align-items: center; 269 + justify-content: center; 270 + transition: all 0.2s; 271 + border-radius: 50%; 272 } 273 274 + .control-btn:hover { 275 + background: rgba(58, 125, 255, 0.1); 276 color: #3a7dff; 277 } 278 279 + .time-control { 280 + flex: 1; 281 + display: flex; 282 + align-items: center; 283 + gap: 0.75rem; 284 } 285 286 + .time { 287 + font-size: 0.8rem; 288 + color: #888; 289 + font-variant-numeric: tabular-nums; 290 + min-width: 40px; 291 } 292 293 + .seek-bar { 294 + flex: 1; 295 + height: 4px; 296 + -webkit-appearance: none; 297 + appearance: none; 298 + background: #2a2a2a; 299 + border-radius: 2px; 300 + outline: none; 301 + cursor: pointer; 302 } 303 304 + .seek-bar::-webkit-slider-thumb { 305 + -webkit-appearance: none; 306 + appearance: none; 307 + width: 12px; 308 + height: 12px; 309 + background: #3a7dff; 310 + border-radius: 50%; 311 cursor: pointer; 312 transition: all 0.2s; 313 } 314 315 + .seek-bar::-webkit-slider-thumb:hover { 316 + background: #5a8fff; 317 + transform: scale(1.2); 318 } 319 320 + .seek-bar::-moz-range-thumb { 321 + width: 12px; 322 + height: 12px; 323 + background: #3a7dff; 324 + border-radius: 50%; 325 + border: none; 326 + cursor: pointer; 327 + transition: all 0.2s; 328 } 329 330 + .seek-bar::-moz-range-thumb:hover { 331 + background: #5a8fff; 332 + transform: scale(1.2); 333 } 334 335 + .volume-control { 336 + display: flex; 337 + align-items: center; 338 + gap: 0.5rem; 339 + flex: 0 0 120px; 340 } 341 342 + .volume-control svg { 343 + flex-shrink: 0; 344 color: #888; 345 } 346 347 + .volume-bar { 348 + flex: 1; 349 + height: 4px; 350 + -webkit-appearance: none; 351 + appearance: none; 352 + background: #2a2a2a; 353 + border-radius: 2px; 354 + outline: none; 355 + cursor: pointer; 356 } 357 358 + .volume-bar::-webkit-slider-thumb { 359 + -webkit-appearance: none; 360 + appearance: none; 361 + width: 10px; 362 + height: 10px; 363 + background: #888; 364 + border-radius: 50%; 365 + cursor: pointer; 366 + transition: all 0.2s; 367 } 368 369 + .volume-bar::-webkit-slider-thumb:hover { 370 + background: #aaa; 371 + transform: scale(1.2); 372 } 373 374 + .volume-bar::-moz-range-thumb { 375 + width: 10px; 376 + height: 10px; 377 + background: #888; 378 + border-radius: 50%; 379 + border: none; 380 + cursor: pointer; 381 + transition: all 0.2s; 382 } 383 384 + .volume-bar::-moz-range-thumb:hover { 385 + background: #aaa; 386 + transform: scale(1.2); 387 } 388 </style>
+34 -22
frontend/src/routes/login/+page.svelte
··· 1 <script lang="ts"> 2 let handle = ''; 3 let loading = false; 4 5 - function startOAuth() { 6 if (!handle.trim()) return; 7 loading = true; 8 // redirect to backend OAuth start endpoint 9 - window.location.href = `http://localhost:8000/auth/start?handle=${encodeURIComponent(handle)}`; 10 } 11 </script> 12 ··· 15 <h1>relay</h1> 16 <p>decentralized music streaming</p> 17 18 - <form on:submit|preventDefault={startOAuth}> 19 <div class="input-group"> 20 - <label for="handle">bluesky handle</label> 21 <input 22 id="handle" 23 type="text" ··· 29 </div> 30 31 <button type="submit" disabled={loading || !handle.trim()}> 32 - {loading ? 'redirecting...' : 'login with bluesky'} 33 </button> 34 </form> 35 </div> 36 </div> 37 38 <style> 39 .container { 40 min-height: 100vh; 41 display: flex; 42 align-items: center; 43 justify-content: center; 44 - background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); 45 padding: 1rem; 46 } 47 48 .login-card { 49 - background: #0f3460; 50 - border-radius: 12px; 51 padding: 3rem; 52 max-width: 400px; 53 width: 100%; 54 - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 55 } 56 57 h1 { 58 font-size: 2.5rem; 59 margin: 0 0 0.5rem 0; 60 - color: #e94560; 61 text-align: center; 62 } 63 64 p { 65 - color: rgba(255, 255, 255, 0.7); 66 text-align: center; 67 margin: 0 0 2rem 0; 68 } 69 70 .input-group { ··· 73 74 label { 75 display: block; 76 - color: rgba(255, 255, 255, 0.9); 77 margin-bottom: 0.5rem; 78 font-size: 0.9rem; 79 } ··· 81 input { 82 width: 100%; 83 padding: 0.75rem; 84 - background: rgba(255, 255, 255, 0.05); 85 - border: 1px solid rgba(255, 255, 255, 0.1); 86 - border-radius: 6px; 87 color: white; 88 font-size: 1rem; 89 transition: all 0.2s; 90 } 91 92 input:focus { 93 outline: none; 94 - border-color: #e94560; 95 - background: rgba(255, 255, 255, 0.08); 96 } 97 98 input:disabled { ··· 101 } 102 103 input::placeholder { 104 - color: rgba(255, 255, 255, 0.3); 105 } 106 107 button { 108 width: 100%; 109 padding: 0.75rem; 110 - background: #e94560; 111 color: white; 112 border: none; 113 - border-radius: 6px; 114 font-size: 1rem; 115 font-weight: 600; 116 cursor: pointer; ··· 118 } 119 120 button:hover:not(:disabled) { 121 - background: #d63651; 122 transform: translateY(-1px); 123 - box-shadow: 0 4px 12px rgba(233, 69, 96, 0.3); 124 } 125 126 button:disabled {
··· 1 <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + 4 let handle = ''; 5 let loading = false; 6 7 + function startOAuth(e: SubmitEvent) { 8 + e.preventDefault(); 9 if (!handle.trim()) return; 10 loading = true; 11 // redirect to backend OAuth start endpoint 12 + window.location.href = `${API_URL}/auth/start?handle=${encodeURIComponent(handle)}`; 13 } 14 </script> 15 ··· 18 <h1>relay</h1> 19 <p>decentralized music streaming</p> 20 21 + <form onsubmit={startOAuth}> 22 <div class="input-group"> 23 + <label for="handle">atproto handle</label> 24 <input 25 id="handle" 26 type="text" ··· 32 </div> 33 34 <button type="submit" disabled={loading || !handle.trim()}> 35 + {loading ? 'redirecting...' : 'sign in with atproto'} 36 </button> 37 </form> 38 </div> 39 </div> 40 41 <style> 42 + :global(body) { 43 + margin: 0; 44 + padding: 0; 45 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 46 + background: #0a0a0a; 47 + color: #fff; 48 + } 49 + 50 .container { 51 min-height: 100vh; 52 display: flex; 53 align-items: center; 54 justify-content: center; 55 + background: #0a0a0a; 56 padding: 1rem; 57 } 58 59 .login-card { 60 + background: #1a1a1a; 61 + border: 1px solid #2a2a2a; 62 + border-radius: 8px; 63 padding: 3rem; 64 max-width: 400px; 65 width: 100%; 66 } 67 68 h1 { 69 font-size: 2.5rem; 70 margin: 0 0 0.5rem 0; 71 + color: #fff; 72 text-align: center; 73 } 74 75 p { 76 + color: #888; 77 text-align: center; 78 margin: 0 0 2rem 0; 79 + font-size: 0.95rem; 80 } 81 82 .input-group { ··· 85 86 label { 87 display: block; 88 + color: #aaa; 89 margin-bottom: 0.5rem; 90 font-size: 0.9rem; 91 } ··· 93 input { 94 width: 100%; 95 padding: 0.75rem; 96 + background: #0a0a0a; 97 + border: 1px solid #333; 98 + border-radius: 4px; 99 color: white; 100 font-size: 1rem; 101 transition: all 0.2s; 102 + box-sizing: border-box; 103 } 104 105 input:focus { 106 outline: none; 107 + border-color: #3a7dff; 108 } 109 110 input:disabled { ··· 113 } 114 115 input::placeholder { 116 + color: #666; 117 } 118 119 button { 120 width: 100%; 121 padding: 0.75rem; 122 + background: #3a7dff; 123 color: white; 124 border: none; 125 + border-radius: 4px; 126 font-size: 1rem; 127 font-weight: 600; 128 cursor: pointer; ··· 130 } 131 132 button:hover:not(:disabled) { 133 + background: #2868e6; 134 transform: translateY(-1px); 135 + box-shadow: 0 4px 12px rgba(58, 125, 255, 0.3); 136 } 137 138 button:disabled {
+200 -57
frontend/src/routes/portal/+page.svelte
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 - 4 - interface User { 5 - did: string; 6 - handle: string; 7 - } 8 9 let user: User | null = null; 10 let loading = true; 11 12 // form state 13 let uploading = false; ··· 22 23 onMount(async () => { 24 try { 25 - const response = await fetch('http://localhost:8000/auth/me', { 26 credentials: 'include' 27 }); 28 if (response.ok) { 29 user = await response.json(); 30 } else { 31 // not authenticated, redirect to login 32 window.location.href = '/login'; ··· 38 } 39 }); 40 41 - async function handleUpload(e: Event) { 42 e.preventDefault(); 43 if (!file) return; 44 ··· 53 if (album) formData.append('album', album); 54 55 try { 56 - const response = await fetch('http://localhost:8000/tracks/', { 57 method: 'POST', 58 body: formData, 59 credentials: 'include' ··· 68 file = null; 69 // @ts-ignore 70 document.getElementById('file-input').value = ''; 71 } else { 72 const error = await response.json(); 73 uploadError = error.detail || `upload failed (${response.status} ${response.statusText})`; ··· 79 } 80 } 81 82 function handleFileChange(e: Event) { 83 const target = e.target as HTMLInputElement; 84 if (target.files && target.files[0]) { 85 file = target.files[0]; 86 } 87 } 88 </script> 89 90 {#if loading} 91 <div class="loading">loading...</div> 92 {:else if user} 93 <main> 94 - <header> 95 - <div class="header-content"> 96 - <div> 97 - <h1>artist portal</h1> 98 - <p class="user-info">logged in as @{user.handle}</p> 99 - </div> 100 - <a href="/" class="back-link">← back to tracks</a> 101 - </div> 102 - </header> 103 104 <section class="upload-section"> 105 <h2>upload track</h2> ··· 112 <div class="message error">{uploadError}</div> 113 {/if} 114 115 - <form on:submit={handleUpload}> 116 <div class="form-group"> 117 <label for="title">track title</label> 118 <input ··· 154 id="file-input" 155 type="file" 156 accept="audio/*" 157 - on:change={handleFileChange} 158 required 159 disabled={uploading} 160 /> ··· 168 </button> 169 </form> 170 </section> 171 </main> 172 {/if} 173 174 <style> 175 - :global(body) { 176 - margin: 0; 177 - padding: 0; 178 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 179 - background: #0a0a0a; 180 - color: #fff; 181 - } 182 - 183 .loading { 184 display: flex; 185 align-items: center; ··· 191 main { 192 max-width: 800px; 193 margin: 0 auto; 194 - padding: 2rem 1rem; 195 } 196 197 - header { 198 - margin-bottom: 3rem; 199 - } 200 - 201 - .header-content { 202 - display: flex; 203 - justify-content: space-between; 204 - align-items: flex-start; 205 - } 206 - 207 - h1 { 208 - font-size: 2.5rem; 209 - margin: 0 0 0.5rem; 210 } 211 212 - .user-info { 213 - color: #888; 214 margin: 0; 215 } 216 217 - .back-link { 218 - color: #3a7dff; 219 - text-decoration: none; 220 - font-size: 0.9rem; 221 - padding: 0.5rem 1rem; 222 - border: 1px solid #3a7dff; 223 - border-radius: 4px; 224 - transition: all 0.2s; 225 - } 226 - 227 - .back-link:hover { 228 - background: #3a7dff; 229 - color: white; 230 - } 231 232 .upload-section h2 { 233 font-size: 1.5rem; ··· 340 341 button:active:not(:disabled) { 342 transform: translateY(0); 343 } 344 </style>
··· 1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 + import Header from '$lib/components/Header.svelte'; 4 + import type { User, Track} from '$lib/types'; 5 + import { API_URL } from '$lib/config'; 6 7 let user: User | null = null; 8 let loading = true; 9 + let tracks: Track[] = []; 10 + let loadingTracks = false; 11 12 // form state 13 let uploading = false; ··· 22 23 onMount(async () => { 24 try { 25 + const response = await fetch('`${API_URL}`/auth/me', { 26 credentials: 'include' 27 }); 28 if (response.ok) { 29 user = await response.json(); 30 + await loadMyTracks(); 31 } else { 32 // not authenticated, redirect to login 33 window.location.href = '/login'; ··· 39 } 40 }); 41 42 + async function loadMyTracks() { 43 + loadingTracks = true; 44 + try { 45 + const response = await fetch('`${API_URL}`/tracks/me', { 46 + credentials: 'include' 47 + }); 48 + if (response.ok) { 49 + const data = await response.json(); 50 + tracks = data.tracks; 51 + } 52 + } catch (e) { 53 + console.error('failed to load tracks:', e); 54 + } finally { 55 + loadingTracks = false; 56 + } 57 + } 58 + 59 + async function handleUpload(e: SubmitEvent) { 60 e.preventDefault(); 61 if (!file) return; 62 ··· 71 if (album) formData.append('album', album); 72 73 try { 74 + const response = await fetch('`${API_URL}`/tracks/', { 75 method: 'POST', 76 body: formData, 77 credentials: 'include' ··· 86 file = null; 87 // @ts-ignore 88 document.getElementById('file-input').value = ''; 89 + // reload tracks 90 + await loadMyTracks(); 91 } else { 92 const error = await response.json(); 93 uploadError = error.detail || `upload failed (${response.status} ${response.statusText})`; ··· 99 } 100 } 101 102 + async function deleteTrack(trackId: number, trackTitle: string) { 103 + if (!confirm(`delete "${trackTitle}"?`)) return; 104 + 105 + try { 106 + const response = await fetch(`${API_URL}/tracks/${trackId}`, { 107 + method: 'DELETE', 108 + credentials: 'include' 109 + }); 110 + 111 + if (response.ok) { 112 + await loadMyTracks(); 113 + } else { 114 + const error = await response.json(); 115 + alert(error.detail || 'failed to delete track'); 116 + } 117 + } catch (e) { 118 + alert(`network error: ${e instanceof Error ? e.message : 'unknown error'}`); 119 + } 120 + } 121 + 122 function handleFileChange(e: Event) { 123 const target = e.target as HTMLInputElement; 124 if (target.files && target.files[0]) { 125 file = target.files[0]; 126 } 127 } 128 + 129 + async function logout() { 130 + await fetch('`${API_URL}`/auth/logout', { 131 + method: 'POST', 132 + credentials: 'include' 133 + }); 134 + window.location.href = '/'; 135 + } 136 </script> 137 138 {#if loading} 139 <div class="loading">loading...</div> 140 {:else if user} 141 + <Header {user} onLogout={logout} /> 142 <main> 143 + <div class="portal-header"> 144 + <h2>artist portal</h2> 145 + </div> 146 147 <section class="upload-section"> 148 <h2>upload track</h2> ··· 155 <div class="message error">{uploadError}</div> 156 {/if} 157 158 + <form onsubmit={handleUpload}> 159 <div class="form-group"> 160 <label for="title">track title</label> 161 <input ··· 197 id="file-input" 198 type="file" 199 accept="audio/*" 200 + onchange={handleFileChange} 201 required 202 disabled={uploading} 203 /> ··· 211 </button> 212 </form> 213 </section> 214 + 215 + <section class="tracks-section"> 216 + <h2>your tracks</h2> 217 + 218 + {#if loadingTracks} 219 + <p class="empty">loading tracks...</p> 220 + {:else if tracks.length === 0} 221 + <p class="empty">no tracks uploaded yet</p> 222 + {:else} 223 + <div class="tracks-list"> 224 + {#each tracks as track} 225 + <div class="track-item"> 226 + <div class="track-info"> 227 + <div class="track-title">{track.title}</div> 228 + <div class="track-meta"> 229 + {track.artist} 230 + {#if track.album} 231 + <span class="separator">•</span> 232 + <span class="album">{track.album}</span> 233 + {/if} 234 + </div> 235 + <div class="track-date"> 236 + {new Date(track.created_at).toLocaleDateString()} 237 + </div> 238 + </div> 239 + <button 240 + class="delete-btn" 241 + onclick={() => deleteTrack(track.id, track.title)} 242 + title="delete track" 243 + > 244 + × 245 + </button> 246 + </div> 247 + {/each} 248 + </div> 249 + {/if} 250 + </section> 251 </main> 252 {/if} 253 254 <style> 255 .loading { 256 display: flex; 257 align-items: center; ··· 263 main { 264 max-width: 800px; 265 margin: 0 auto; 266 + padding: 0 1rem 2rem; 267 } 268 269 + .portal-header { 270 + margin-bottom: 2rem; 271 } 272 273 + .portal-header h2 { 274 + font-size: 1.5rem; 275 margin: 0; 276 } 277 278 279 .upload-section h2 { 280 font-size: 1.5rem; ··· 387 388 button:active:not(:disabled) { 389 transform: translateY(0); 390 + } 391 + 392 + .tracks-section { 393 + margin-top: 3rem; 394 + } 395 + 396 + .tracks-section h2 { 397 + font-size: 1.5rem; 398 + margin-bottom: 1.5rem; 399 + } 400 + 401 + .empty { 402 + color: #666; 403 + padding: 2rem; 404 + text-align: center; 405 + background: #1a1a1a; 406 + border-radius: 8px; 407 + border: 1px solid #2a2a2a; 408 + } 409 + 410 + .tracks-list { 411 + display: flex; 412 + flex-direction: column; 413 + gap: 0.75rem; 414 + } 415 + 416 + .track-item { 417 + display: flex; 418 + align-items: center; 419 + justify-content: space-between; 420 + background: #1a1a1a; 421 + border: 1px solid #2a2a2a; 422 + border-radius: 6px; 423 + padding: 1rem; 424 + transition: all 0.2s; 425 + } 426 + 427 + .track-item:hover { 428 + background: #222; 429 + border-color: #333; 430 + } 431 + 432 + .track-info { 433 + flex: 1; 434 + min-width: 0; 435 + } 436 + 437 + .track-title { 438 + font-weight: 600; 439 + font-size: 1rem; 440 + margin-bottom: 0.25rem; 441 + color: #fff; 442 + } 443 + 444 + .track-meta { 445 + font-size: 0.9rem; 446 + color: #aaa; 447 + margin-bottom: 0.25rem; 448 + } 449 + 450 + .separator { 451 + margin: 0 0.5rem; 452 + color: #666; 453 + } 454 + 455 + .album { 456 + color: #888; 457 + } 458 + 459 + .track-date { 460 + font-size: 0.85rem; 461 + color: #666; 462 + } 463 + 464 + .delete-btn { 465 + flex-shrink: 0; 466 + width: 36px; 467 + height: 36px; 468 + padding: 0; 469 + background: transparent; 470 + border: 1px solid #444; 471 + border-radius: 4px; 472 + color: #888; 473 + font-size: 1.5rem; 474 + line-height: 1; 475 + cursor: pointer; 476 + transition: all 0.2s; 477 + margin-left: 1rem; 478 + } 479 + 480 + .delete-btn:hover { 481 + background: rgba(233, 69, 96, 0.1); 482 + border-color: rgba(233, 69, 96, 0.5); 483 + color: #ff6b6b; 484 + transform: none; 485 + box-shadow: none; 486 } 487 </style>
+1 -6
frontend/svelte.config.js
··· 1 - import adapter from '@sveltejs/adapter-auto'; 2 import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 4 /** @type {import('@sveltejs/kit').Config} */ 5 const config = { 6 - // Consult https://svelte.dev/docs/kit/integrations 7 - // for more information about preprocessors 8 preprocess: vitePreprocess(), 9 10 kit: { 11 - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 - // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 adapter: adapter() 15 } 16 };
··· 1 + import adapter from '@sveltejs/adapter-vercel'; 2 import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 4 /** @type {import('@sveltejs/kit').Config} */ 5 const config = { 6 preprocess: vitePreprocess(), 7 8 kit: { 9 adapter: adapter() 10 } 11 };
+1 -1
justfile
··· 6 7 # run backend server 8 serve: 9 - uv run uvicorn relay.main:app --reload --host 0.0.0.0 --port 8000 10 11 # run frontend dev server 12 dev:
··· 6 7 # run backend server 8 serve: 9 + uv run uvicorn relay.main:app --reload --host 0.0.0.0 --port ${PORT:-8001} 10 11 # run frontend dev server 12 dev:
+1
pyproject.toml
··· 19 "python-multipart>=0.0.20", 20 "python-jose[cryptography]>=3.3.0", 21 "passlib[bcrypt]>=1.7.4", 22 ] 23 24 requires-python = ">=3.11"
··· 19 "python-multipart>=0.0.20", 20 "python-jose[cryptography]>=3.3.0", 21 "passlib[bcrypt]>=1.7.4", 22 + "psycopg[binary]>=3.2.12", 23 ] 24 25 requires-python = ">=3.11"
+17 -5
src/relay/api/audio.py
··· 1 """audio streaming endpoints.""" 2 3 from fastapi import APIRouter, HTTPException 4 - from fastapi.responses import FileResponse 5 6 from relay.models import AudioFormat 7 from relay.storage import storage 8 ··· 10 11 12 @router.get("/{file_id}") 13 - async def stream_audio(file_id: str) -> FileResponse: 14 """stream audio file.""" 15 file_path = storage.get_path(file_id) 16 - 17 if not file_path: 18 raise HTTPException(status_code=404, detail="audio file not found") 19 - 20 # determine media type based on extension 21 audio_format = AudioFormat.from_extension(file_path.suffix) 22 media_type = audio_format.media_type if audio_format else "audio/mpeg" 23 - 24 return FileResponse( 25 path=file_path, 26 media_type=media_type,
··· 1 """audio streaming endpoints.""" 2 3 from fastapi import APIRouter, HTTPException 4 + from fastapi.responses import FileResponse, RedirectResponse 5 6 + from relay.config import settings 7 from relay.models import AudioFormat 8 from relay.storage import storage 9 ··· 11 12 13 @router.get("/{file_id}") 14 + async def stream_audio(file_id: str): 15 """stream audio file.""" 16 + 17 + if settings.storage_backend == "r2": 18 + # R2: redirect to public URL 19 + from relay.storage.r2 import R2Storage 20 + if isinstance(storage, R2Storage): 21 + url = storage.get_url(file_id) 22 + if not url: 23 + raise HTTPException(status_code=404, detail="audio file not found") 24 + return RedirectResponse(url=url) 25 + 26 + # filesystem: serve file directly 27 file_path = storage.get_path(file_id) 28 + 29 if not file_path: 30 raise HTTPException(status_code=404, detail="audio file not found") 31 + 32 # determine media type based on extension 33 audio_format = AudioFormat.from_extension(file_path.suffix) 34 media_type = audio_format.media_type if audio_format else "audio/mpeg" 35 + 36 return FileResponse( 37 path=file_path, 38 media_type=media_type,
+1 -1
src/relay/api/auth.py
··· 36 37 # redirect to localhost endpoint to set cookie properly for localhost domain 38 response = RedirectResponse( 39 - url=f"http://localhost:8000/auth/session?session_id={session_id}", 40 status_code=303 41 ) 42 return response
··· 36 37 # redirect to localhost endpoint to set cookie properly for localhost domain 38 response = RedirectResponse( 39 + url=f"http://localhost:8001/auth/session?session_id={session_id}", 40 status_code=303 41 ) 42 return response
+111 -4
src/relay/api/tracks.py
··· 6 from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile 7 from sqlalchemy.orm import Session 8 9 from relay.auth import Session as AuthSession 10 from relay.auth import require_auth 11 from relay.models import AudioFormat, Track, get_db 12 from relay.storage import storage 13 ··· 43 except ValueError as e: 44 raise HTTPException(status_code=400, detail=str(e)) from e 45 46 # create track record with artist identity 47 track = Track( 48 title=title, 49 artist=artist, 50 - album=album, 51 file_id=file_id, 52 file_type=ext[1:], # remove the dot 53 artist_did=auth_session.did, 54 artist_handle=auth_session.handle, 55 ) 56 57 db.add(track) ··· 67 "file_type": track.file_type, 68 "artist_did": track.artist_did, 69 "artist_handle": track.artist_handle, 70 "created_at": track.created_at.isoformat(), 71 } 72 ··· 75 async def list_tracks(db: Session = Depends(get_db)) -> dict: 76 """list all tracks.""" 77 tracks = db.query(Track).order_by(Track.created_at.desc()).all() 78 - 79 return { 80 "tracks": [ 81 { ··· 85 "album": track.album, 86 "file_id": track.file_id, 87 "file_type": track.file_type, 88 "created_at": track.created_at.isoformat(), 89 } 90 for track in tracks ··· 92 } 93 94 95 @router.get("/{track_id}") 96 async def get_track(track_id: int, db: Session = Depends(get_db)) -> dict: 97 """get a specific track.""" 98 track = db.query(Track).filter(Track.id == track_id).first() 99 - 100 if not track: 101 raise HTTPException(status_code=404, detail="track not found") 102 - 103 return { 104 "id": track.id, 105 "title": track.title, ··· 107 "album": track.album, 108 "file_id": track.file_id, 109 "file_type": track.file_type, 110 "created_at": track.created_at.isoformat(), 111 }
··· 6 from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile 7 from sqlalchemy.orm import Session 8 9 + from relay.atproto import create_track_record 10 from relay.auth import Session as AuthSession 11 from relay.auth import require_auth 12 + from relay.config import settings 13 from relay.models import AudioFormat, Track, get_db 14 from relay.storage import storage 15 ··· 45 except ValueError as e: 46 raise HTTPException(status_code=400, detail=str(e)) from e 47 48 + # get R2 URL 49 + r2_url = None 50 + if settings.storage_backend == "r2": 51 + from relay.storage.r2 import R2Storage 52 + if isinstance(storage, R2Storage): 53 + r2_url = storage.get_url(file_id) 54 + 55 + # create ATProto record (if R2 URL available) 56 + atproto_uri = None 57 + atproto_cid = None 58 + if r2_url: 59 + try: 60 + atproto_uri, atproto_cid = await create_track_record( 61 + auth_session=auth_session, 62 + title=title, 63 + artist=artist, 64 + audio_url=r2_url, 65 + file_type=ext[1:], # remove dot 66 + album=album, 67 + duration=None, # TODO: extract from audio file 68 + ) 69 + except Exception as e: 70 + # log but don't fail upload if record creation fails 71 + print(f"warning: failed to create ATProto record: {e}") 72 + 73 # create track record with artist identity 74 + extra = {} 75 + if album: 76 + extra["album"] = album 77 + 78 track = Track( 79 title=title, 80 artist=artist, 81 file_id=file_id, 82 file_type=ext[1:], # remove the dot 83 artist_did=auth_session.did, 84 artist_handle=auth_session.handle, 85 + extra=extra, 86 + r2_url=r2_url, 87 + atproto_record_uri=atproto_uri, 88 + atproto_record_cid=atproto_cid, 89 ) 90 91 db.add(track) ··· 101 "file_type": track.file_type, 102 "artist_did": track.artist_did, 103 "artist_handle": track.artist_handle, 104 + "r2_url": track.r2_url, 105 + "atproto_record_uri": track.atproto_record_uri, 106 "created_at": track.created_at.isoformat(), 107 } 108 ··· 111 async def list_tracks(db: Session = Depends(get_db)) -> dict: 112 """list all tracks.""" 113 tracks = db.query(Track).order_by(Track.created_at.desc()).all() 114 + 115 + return { 116 + "tracks": [ 117 + { 118 + "id": track.id, 119 + "title": track.title, 120 + "artist": track.artist, 121 + "album": track.album, 122 + "file_id": track.file_id, 123 + "file_type": track.file_type, 124 + "artist_handle": track.artist_handle, 125 + "r2_url": track.r2_url, 126 + "atproto_record_uri": track.atproto_record_uri, 127 + "created_at": track.created_at.isoformat(), 128 + } 129 + for track in tracks 130 + ] 131 + } 132 + 133 + 134 + @router.get("/me") 135 + async def list_my_tracks( 136 + auth_session: AuthSession = Depends(require_auth), 137 + db: Session = Depends(get_db), 138 + ) -> dict: 139 + """list tracks uploaded by authenticated user.""" 140 + tracks = ( 141 + db.query(Track) 142 + .filter(Track.artist_did == auth_session.did) 143 + .order_by(Track.created_at.desc()) 144 + .all() 145 + ) 146 + 147 return { 148 "tracks": [ 149 { ··· 153 "album": track.album, 154 "file_id": track.file_id, 155 "file_type": track.file_type, 156 + "artist_handle": track.artist_handle, 157 + "r2_url": track.r2_url, 158 + "atproto_record_uri": track.atproto_record_uri, 159 "created_at": track.created_at.isoformat(), 160 } 161 for track in tracks ··· 163 } 164 165 166 + @router.delete("/{track_id}") 167 + async def delete_track( 168 + track_id: int, 169 + auth_session: AuthSession = Depends(require_auth), 170 + db: Session = Depends(get_db), 171 + ) -> dict: 172 + """delete a track (only by owner).""" 173 + track = db.query(Track).filter(Track.id == track_id).first() 174 + 175 + if not track: 176 + raise HTTPException(status_code=404, detail="track not found") 177 + 178 + # verify ownership 179 + if track.artist_did != auth_session.did: 180 + raise HTTPException( 181 + status_code=403, 182 + detail="you can only delete your own tracks", 183 + ) 184 + 185 + # delete audio file from storage 186 + try: 187 + storage.delete(track.file_id) 188 + except Exception as e: 189 + # log but don't fail - maybe file was already deleted 190 + print(f"warning: failed to delete file {track.file_id}: {e}") 191 + 192 + # delete track record 193 + db.delete(track) 194 + db.commit() 195 + 196 + return {"message": "track deleted successfully"} 197 + 198 + 199 @router.get("/{track_id}") 200 async def get_track(track_id: int, db: Session = Depends(get_db)) -> dict: 201 """get a specific track.""" 202 track = db.query(Track).filter(Track.id == track_id).first() 203 + 204 if not track: 205 raise HTTPException(status_code=404, detail="track not found") 206 + 207 return { 208 "id": track.id, 209 "title": track.title, ··· 211 "album": track.album, 212 "file_id": track.file_id, 213 "file_type": track.file_type, 214 + "artist_handle": track.artist_handle, 215 + "r2_url": track.r2_url, 216 + "atproto_record_uri": track.atproto_record_uri, 217 "created_at": track.created_at.isoformat(), 218 }
+5
src/relay/atproto/__init__.py
···
··· 1 + """ATProto integration for relay.""" 2 + 3 + from relay.atproto.records import create_track_record 4 + 5 + __all__ = ["create_track_record"]
+109
src/relay/atproto/records.py
···
··· 1 + """ATProto record creation for relay tracks.""" 2 + 3 + import json 4 + from datetime import datetime, timezone 5 + 6 + from atproto_oauth.models import OAuthSession 7 + 8 + from relay.auth import Session as AuthSession 9 + from relay.auth import oauth_client 10 + 11 + 12 + async def create_track_record( 13 + auth_session: AuthSession, 14 + title: str, 15 + artist: str, 16 + audio_url: str, 17 + file_type: str, 18 + album: str | None = None, 19 + duration: int | None = None, 20 + ) -> tuple[str, str]: 21 + """create app.relay.track record on user's PDS. 22 + 23 + args: 24 + auth_session: authenticated user session 25 + title: track title 26 + artist: artist name 27 + audio_url: R2 URL for audio file 28 + file_type: file extension (mp3, wav, etc) 29 + album: optional album name 30 + duration: optional duration in seconds 31 + 32 + returns: 33 + tuple of (record_uri, record_cid) 34 + 35 + raises: 36 + ValueError: if session is invalid 37 + Exception: if record creation fails 38 + """ 39 + # get OAuth session data from database 40 + oauth_data = auth_session.oauth_session 41 + if not oauth_data or "access_token" not in oauth_data: 42 + raise ValueError(f"OAuth session data missing or invalid for {auth_session.did}") 43 + 44 + # reconstruct OAuthSession from database 45 + from cryptography.hazmat.backends import default_backend 46 + from cryptography.hazmat.primitives import serialization 47 + 48 + # deserialize DPoP private key 49 + dpop_key_pem = oauth_data.get("dpop_private_key_pem") 50 + if not dpop_key_pem: 51 + raise ValueError("DPoP private key not found in session - please log out and log back in") 52 + 53 + dpop_private_key = serialization.load_pem_private_key( 54 + dpop_key_pem.encode("utf-8"), 55 + password=None, 56 + backend=default_backend(), 57 + ) 58 + 59 + oauth_session = OAuthSession( 60 + did=oauth_data["did"], 61 + handle=oauth_data["handle"], 62 + pds_url=oauth_data["pds_url"], 63 + authserver_iss=oauth_data["authserver_iss"], 64 + access_token=oauth_data["access_token"], 65 + refresh_token=oauth_data["refresh_token"], 66 + dpop_private_key=dpop_private_key, 67 + dpop_authserver_nonce=oauth_data.get("dpop_authserver_nonce", ""), 68 + dpop_pds_nonce=oauth_data.get("dpop_pds_nonce", ""), 69 + scope=oauth_data["scope"], 70 + ) 71 + 72 + # construct record 73 + record = { 74 + "$type": "app.relay.track", 75 + "title": title, 76 + "artist": artist, 77 + "audioUrl": audio_url, 78 + "fileType": file_type, 79 + "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), 80 + } 81 + 82 + # add optional fields 83 + if album: 84 + record["album"] = album 85 + if duration: 86 + record["duration"] = duration 87 + 88 + # make authenticated request to create record 89 + url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.createRecord" 90 + payload = { 91 + "repo": auth_session.did, 92 + "collection": "app.relay.track", 93 + "record": record, 94 + } 95 + 96 + response = await oauth_client.make_authenticated_request( 97 + session=oauth_session, 98 + method="POST", 99 + url=url, 100 + json=payload, 101 + ) 102 + 103 + if response.status_code not in (200, 201): 104 + raise Exception( 105 + f"Failed to create ATProto record: {response.status_code} {response.text}" 106 + ) 107 + 108 + result = response.json() 109 + return result["uri"], result["cid"]
+64 -12
src/relay/auth.py
··· 1 """OAuth 2.1 authentication and session management.""" 2 3 import secrets 4 from dataclasses import dataclass 5 from typing import Annotated ··· 9 from fastapi import Cookie, HTTPException 10 11 from relay.config import settings 12 13 14 @dataclass ··· 20 handle: str 21 oauth_session: dict # store OAuth session data 22 23 24 - # in-memory stores (MVP - replace with redis/db later) 25 - _sessions: dict[str, Session] = {} 26 _state_store = MemoryStateStore() 27 _session_store = MemorySessionStore() 28 ··· 30 oauth_client = OAuthClient( 31 client_id=settings.atproto_client_id, 32 redirect_uri=settings.atproto_redirect_uri, 33 - scope="atproto", 34 state_store=_state_store, 35 session_store=_session_store, 36 ) ··· 39 def create_session(did: str, handle: str, oauth_session: dict) -> str: 40 """create a new session for authenticated user.""" 41 session_id = secrets.token_urlsafe(32) 42 - _sessions[session_id] = Session( 43 - session_id=session_id, 44 - did=did, 45 - handle=handle, 46 - oauth_session=oauth_session, 47 - ) 48 return session_id 49 50 51 def get_session(session_id: str) -> Session | None: 52 """retrieve session by id.""" 53 - return _sessions.get(session_id) 54 55 56 def delete_session(session_id: str) -> None: 57 """delete a session.""" 58 - _sessions.pop(session_id, None) 59 60 61 async def start_oauth_flow(handle: str) -> tuple[str, str]: ··· 78 state=state, 79 iss=iss, 80 ) 81 - # OAuth session is already stored in session_store, just extract key info 82 session_data = { 83 "did": oauth_session.did, 84 "handle": oauth_session.handle, 85 "pds_url": oauth_session.pds_url, 86 "authserver_iss": oauth_session.authserver_iss, 87 "scope": oauth_session.scope, 88 } 89 return oauth_session.did, oauth_session.handle, session_data 90 except Exception as e:
··· 1 """OAuth 2.1 authentication and session management.""" 2 3 + import json 4 import secrets 5 from dataclasses import dataclass 6 from typing import Annotated ··· 10 from fastapi import Cookie, HTTPException 11 12 from relay.config import settings 13 + from relay.models import UserSession, get_db 14 15 16 @dataclass ··· 22 handle: str 23 oauth_session: dict # store OAuth session data 24 25 + def get_oauth_session_id(self) -> str: 26 + """extract OAuth session ID for retrieving from session store.""" 27 + return self.oauth_session.get("session_id", self.did) 28 29 + 30 + # OAuth stores (state store still in-memory for now) 31 _state_store = MemoryStateStore() 32 _session_store = MemorySessionStore() 33 ··· 35 oauth_client = OAuthClient( 36 client_id=settings.atproto_client_id, 37 redirect_uri=settings.atproto_redirect_uri, 38 + scope="atproto repo:app.relay.track", 39 state_store=_state_store, 40 session_store=_session_store, 41 ) ··· 44 def create_session(did: str, handle: str, oauth_session: dict) -> str: 45 """create a new session for authenticated user.""" 46 session_id = secrets.token_urlsafe(32) 47 + 48 + # store in database 49 + db = next(get_db()) 50 + try: 51 + db_session = UserSession( 52 + session_id=session_id, 53 + did=did, 54 + handle=handle, 55 + oauth_session_data=json.dumps(oauth_session), 56 + ) 57 + db.add(db_session) 58 + db.commit() 59 + finally: 60 + db.close() 61 + 62 return session_id 63 64 65 def get_session(session_id: str) -> Session | None: 66 """retrieve session by id.""" 67 + db = next(get_db()) 68 + try: 69 + db_session = db.query(UserSession).filter( 70 + UserSession.session_id == session_id 71 + ).first() 72 + 73 + if not db_session: 74 + return None 75 + 76 + return Session( 77 + session_id=db_session.session_id, 78 + did=db_session.did, 79 + handle=db_session.handle, 80 + oauth_session=json.loads(db_session.oauth_session_data), 81 + ) 82 + finally: 83 + db.close() 84 85 86 def delete_session(session_id: str) -> None: 87 """delete a session.""" 88 + db = next(get_db()) 89 + try: 90 + db.query(UserSession).filter( 91 + UserSession.session_id == session_id 92 + ).delete() 93 + db.commit() 94 + finally: 95 + db.close() 96 97 98 async def start_oauth_flow(handle: str) -> tuple[str, str]: ··· 115 state=state, 116 iss=iss, 117 ) 118 + 119 + # serialize DPoP private key for storage 120 + from cryptography.hazmat.primitives import serialization 121 + 122 + dpop_key_pem = oauth_session.dpop_private_key.private_bytes( 123 + encoding=serialization.Encoding.PEM, 124 + format=serialization.PrivateFormat.PKCS8, 125 + encryption_algorithm=serialization.NoEncryption(), 126 + ).decode("utf-8") 127 + 128 + # store full OAuth session with tokens in database 129 session_data = { 130 "did": oauth_session.did, 131 "handle": oauth_session.handle, 132 "pds_url": oauth_session.pds_url, 133 "authserver_iss": oauth_session.authserver_iss, 134 "scope": oauth_session.scope, 135 + "access_token": oauth_session.access_token, 136 + "refresh_token": oauth_session.refresh_token, 137 + "dpop_private_key_pem": dpop_key_pem, 138 + "dpop_authserver_nonce": oauth_session.dpop_authserver_nonce, 139 + "dpop_pds_nonce": oauth_session.dpop_pds_nonce or "", 140 } 141 return oauth_session.did, oauth_session.handle, session_data 142 except Exception as e:
+9 -15
src/relay/config.py
··· 17 # app settings 18 app_name: str = "relay" 19 debug: bool = False 20 21 # database 22 database_url: str = Field( ··· 30 description="Redis connection string", 31 ) 32 33 # cloudflare r2 34 - r2_account_id: str = Field(default="", description="Cloudflare R2 account ID") 35 - r2_access_key_id: str = Field(default="", description="R2 access key ID") 36 - r2_secret_access_key: str = Field(default="", description="R2 secret access key") 37 - r2_bucket_name: str = Field(default="relay-audio", description="R2 bucket name") 38 - r2_endpoint_url: str = Field( 39 - default="", 40 - description="R2 endpoint URL (computed from account_id if not provided)", 41 - ) 42 43 # atproto 44 atproto_pds_url: str = Field( ··· 51 default="http://localhost:8000/auth/callback", 52 description="OAuth redirect URI", 53 ) 54 - 55 - @property 56 - def r2_endpoint(self) -> str: 57 - """get r2 endpoint url.""" 58 - if self.r2_endpoint_url: 59 - return self.r2_endpoint_url 60 - return f"https://{self.r2_account_id}.r2.cloudflarestorage.com" 61 62 63 settings = Settings()
··· 17 # app settings 18 app_name: str = "relay" 19 debug: bool = False 20 + port: int = Field(default=8001, description="Server port") 21 22 # database 23 database_url: str = Field( ··· 31 description="Redis connection string", 32 ) 33 34 + # storage 35 + storage_backend: str = Field(default="filesystem", description="Storage backend (filesystem or r2)") 36 + 37 # cloudflare r2 38 + aws_access_key_id: str = Field(default="", description="AWS access key ID") 39 + aws_secret_access_key: str = Field(default="", description="AWS secret access key") 40 + r2_bucket: str = Field(default="", description="R2 bucket name") 41 + r2_endpoint_url: str = Field(default="", description="R2 endpoint URL") 42 + r2_public_bucket_url: str = Field(default="", description="R2 public bucket URL") 43 44 # atproto 45 atproto_pds_url: str = Field( ··· 52 default="http://localhost:8000/auth/callback", 53 description="OAuth redirect URI", 54 ) 55 56 57 settings = Settings()
+1 -1
src/relay/main.py
··· 58 "client_name": "relay", 59 "client_uri": client_uri, 60 "redirect_uris": [settings.atproto_redirect_uri], 61 - "scope": "atproto", 62 "grant_types": ["authorization_code", "refresh_token"], 63 "response_types": ["code"], 64 "token_endpoint_auth_method": "none",
··· 58 "client_name": "relay", 59 "client_uri": client_uri, 60 "redirect_uris": [settings.atproto_redirect_uri], 61 + "scope": "atproto repo:app.relay.track", 62 "grant_types": ["authorization_code", "refresh_token"], 63 "response_types": ["code"], 64 "token_endpoint_auth_method": "none",
+2 -1
src/relay/models/__init__.py
··· 2 3 from relay.models.audio import AudioFormat 4 from relay.models.database import Base, get_db, init_db 5 from relay.models.track import Track 6 7 - __all__ = ["AudioFormat", "Base", "Track", "get_db", "init_db"]
··· 2 3 from relay.models.audio import AudioFormat 4 from relay.models.database import Base, get_db, init_db 5 + from relay.models.session import UserSession 6 from relay.models.track import Track 7 8 + __all__ = ["AudioFormat", "Base", "Track", "UserSession", "get_db", "init_db"]
+3 -3
src/relay/models/database.py
··· 3 from sqlalchemy import create_engine 4 from sqlalchemy.orm import DeclarativeBase, sessionmaker 5 6 - # sqlite database in local data directory 7 - DATABASE_URL = "sqlite:///./data/relay.db" 8 9 - engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) 10 SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 11 12
··· 3 from sqlalchemy import create_engine 4 from sqlalchemy.orm import DeclarativeBase, sessionmaker 5 6 + from relay.config import settings 7 8 + # use postgres from settings (neon or local) 9 + engine = create_engine(settings.database_url) 10 SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 11 12
+27
src/relay/models/session.py
···
··· 1 + """session model for storing user sessions.""" 2 + 3 + from datetime import datetime 4 + 5 + from sqlalchemy import DateTime, String, Text 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from relay.models.database import Base 9 + 10 + 11 + class UserSession(Base): 12 + """user session model.""" 13 + 14 + __tablename__ = "user_sessions" 15 + 16 + session_id: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) 17 + did: Mapped[str] = mapped_column(String, nullable=False, index=True) 18 + handle: Mapped[str] = mapped_column(String, nullable=False) 19 + oauth_session_data: Mapped[str] = mapped_column( 20 + Text, nullable=False 21 + ) # JSON stored as text 22 + created_at: Mapped[datetime] = mapped_column( 23 + DateTime, 24 + default=datetime.utcnow, 25 + nullable=False, 26 + ) 27 + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+26 -4
src/relay/models/track.py
··· 3 from datetime import datetime 4 5 from sqlalchemy import DateTime, Integer, String 6 from sqlalchemy.orm import Mapped, mapped_column 7 8 from relay.models.database import Base 9 10 11 class Track(Base): 12 - """track model.""" 13 14 __tablename__ = "tracks" 15 16 id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 17 title: Mapped[str] = mapped_column(String, nullable=False) 18 artist: Mapped[str] = mapped_column(String, nullable=False) 19 - album: Mapped[str | None] = mapped_column(String, nullable=True) 20 - duration: Mapped[int | None] = mapped_column(Integer, nullable=True) # seconds 21 file_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) 22 - file_type: Mapped[str] = mapped_column(String, nullable=False) # mp3 or wav 23 artist_did: Mapped[str] = mapped_column(String, nullable=False, index=True) 24 artist_handle: Mapped[str] = mapped_column(String, nullable=False) 25 created_at: Mapped[datetime] = mapped_column( ··· 27 default=datetime.utcnow, 28 nullable=False, 29 )
··· 3 from datetime import datetime 4 5 from sqlalchemy import DateTime, Integer, String 6 + from sqlalchemy.dialects.postgresql import JSONB 7 from sqlalchemy.orm import Mapped, mapped_column 8 9 from relay.models.database import Base 10 11 12 class Track(Base): 13 + """track model. 14 + 15 + only essential fields are explicit columns. 16 + use metadata JSONB for flexible fields that may evolve. 17 + """ 18 19 __tablename__ = "tracks" 20 21 + # essential fields 22 id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 23 title: Mapped[str] = mapped_column(String, nullable=False) 24 artist: Mapped[str] = mapped_column(String, nullable=False) 25 file_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) 26 + file_type: Mapped[str] = mapped_column(String, nullable=False) 27 artist_did: Mapped[str] = mapped_column(String, nullable=False, index=True) 28 artist_handle: Mapped[str] = mapped_column(String, nullable=False) 29 created_at: Mapped[datetime] = mapped_column( ··· 31 default=datetime.utcnow, 32 nullable=False, 33 ) 34 + 35 + # flexible extra fields (album, duration, genre, etc.) 36 + extra: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") 37 + 38 + # ATProto integration fields 39 + r2_url: Mapped[str | None] = mapped_column(String, nullable=True) 40 + atproto_record_uri: Mapped[str | None] = mapped_column(String, nullable=True) 41 + atproto_record_cid: Mapped[str | None] = mapped_column(String, nullable=True) 42 + 43 + @property 44 + def album(self) -> str | None: 45 + """get album from extra.""" 46 + return self.extra.get("album") 47 + 48 + @property 49 + def duration(self) -> int | None: 50 + """get duration from extra (in seconds).""" 51 + return self.extra.get("duration")
+11 -2
src/relay/storage/__init__.py
··· 1 """storage implementations.""" 2 3 - from relay.storage.filesystem import FilesystemStorage, storage 4 5 - __all__ = ["FilesystemStorage", "storage"]
··· 1 """storage implementations.""" 2 3 + from relay.config import settings 4 + 5 + if settings.storage_backend == "r2": 6 + from relay.storage.r2 import R2Storage 7 + 8 + storage = R2Storage() 9 + else: 10 + from relay.storage.filesystem import FilesystemStorage 11 12 + storage = FilesystemStorage() 13 + 14 + __all__ = ["storage"]
+97
src/relay/storage/r2.py
···
··· 1 + """cloudflare R2 storage for audio files.""" 2 + 3 + import hashlib 4 + from pathlib import Path 5 + from typing import BinaryIO 6 + 7 + import boto3 8 + from botocore.config import Config 9 + 10 + from relay.config import settings 11 + from relay.models import AudioFormat 12 + 13 + 14 + class R2Storage: 15 + """store audio files on cloudflare R2.""" 16 + 17 + def __init__(self): 18 + """initialize R2 client.""" 19 + if not all([ 20 + settings.r2_bucket, 21 + settings.r2_endpoint_url, 22 + settings.aws_access_key_id, 23 + settings.aws_secret_access_key, 24 + ]): 25 + raise ValueError("R2 credentials not configured in environment") 26 + 27 + self.bucket_name = settings.r2_bucket 28 + self.public_bucket_url = settings.r2_public_bucket_url 29 + self.client = boto3.client( 30 + "s3", 31 + endpoint_url=settings.r2_endpoint_url, 32 + aws_access_key_id=settings.aws_access_key_id, 33 + aws_secret_access_key=settings.aws_secret_access_key, 34 + config=Config( 35 + request_checksum_calculation="WHEN_REQUIRED", 36 + response_checksum_validation="WHEN_REQUIRED", 37 + ), 38 + ) 39 + 40 + def save(self, file: BinaryIO, filename: str) -> str: 41 + """save audio file to R2 and return file id.""" 42 + # read file content 43 + content = file.read() 44 + 45 + # generate file id from content hash 46 + file_id = hashlib.sha256(content).hexdigest()[:16] 47 + 48 + # determine file extension 49 + ext = Path(filename).suffix.lower() 50 + audio_format = AudioFormat.from_extension(ext) 51 + if not audio_format: 52 + raise ValueError( 53 + f"unsupported file type: {ext}. " 54 + f"supported: {AudioFormat.supported_extensions_str()}" 55 + ) 56 + 57 + # construct s3 key 58 + key = f"audio/{file_id}{ext}" 59 + 60 + # upload to R2 61 + self.client.put_object( 62 + Bucket=self.bucket_name, 63 + Key=key, 64 + Body=content, 65 + ContentType=audio_format.media_type, 66 + ) 67 + 68 + return file_id 69 + 70 + def get_url(self, file_id: str) -> str | None: 71 + """get public URL for audio file.""" 72 + # try to find file with any supported extension 73 + for audio_format in AudioFormat: 74 + key = f"audio/{file_id}{audio_format.extension}" 75 + 76 + # check if object exists 77 + try: 78 + self.client.head_object(Bucket=self.bucket_name, Key=key) 79 + # object exists, return public URL 80 + return f"{self.public_bucket_url}/{key}" 81 + except self.client.exceptions.ClientError: 82 + continue 83 + 84 + return None 85 + 86 + def delete(self, file_id: str) -> bool: 87 + """delete audio file from R2.""" 88 + for audio_format in AudioFormat: 89 + key = f"audio/{file_id}{audio_format.extension}" 90 + 91 + try: 92 + self.client.delete_object(Bucket=self.bucket_name, Key=key) 93 + return True 94 + except self.client.exceptions.ClientError: 95 + continue 96 + 97 + return False
+51
tests/test_r2_upload.py
···
··· 1 + """test R2 upload functionality.""" 2 + 3 + import shutil 4 + from pathlib import Path 5 + 6 + from relay.storage import storage 7 + from relay.storage.r2 import R2Storage 8 + 9 + 10 + def test_r2_upload(): 11 + """test uploading a file to R2 and retrieving its URL.""" 12 + # copy existing test file 13 + source = Path("data/audio/f6197e825152d2b5.m4a") 14 + test_dir = Path("tests/fixtures") 15 + test_dir.mkdir(parents=True, exist_ok=True) 16 + test_file = test_dir / "test_audio.m4a" 17 + shutil.copy(source, test_file) 18 + 19 + print(f"test file created: {test_file}") 20 + print(f"file size: {test_file.stat().st_size} bytes") 21 + 22 + # verify we're using R2 storage 23 + assert isinstance(storage, R2Storage), f"expected R2Storage, got {type(storage)}" 24 + print(f"✓ using R2 storage") 25 + 26 + # upload file 27 + with open(test_file, "rb") as f: 28 + file_id = storage.save(f, "test_audio.m4a") 29 + 30 + print(f"✓ uploaded file with id: {file_id}") 31 + 32 + # get URL 33 + url = storage.get_url(file_id) 34 + print(f"✓ got public URL: {url}") 35 + 36 + # verify URL format 37 + assert url is not None 38 + assert url.startswith("https://") 39 + assert "r2.dev" in url 40 + assert file_id in url 41 + 42 + print("\n✓ all tests passed!") 43 + print(f"\nfile is accessible at: {url}") 44 + 45 + # cleanup 46 + test_file.unlink() 47 + test_dir.rmdir() 48 + 49 + 50 + if __name__ == "__main__": 51 + test_r2_upload()
+79 -2
uv.lock
··· 1 version = 1 2 revision = 3 3 requires-python = ">=3.11" 4 5 [[package]] 6 name = "alembic" ··· 100 101 [[package]] 102 name = "atproto" 103 - version = "0.0.1.dev460" 104 - source = { git = "https://github.com/zzstoatzz/atproto?rev=main#efe6f7407ae7edb7f35b410a1eb8dba7a5c63e7f" } 105 dependencies = [ 106 { name = "click" }, 107 { name = "cryptography" }, ··· 1105 ] 1106 1107 [[package]] 1108 name = "ptyprocess" 1109 version = "0.7.0" 1110 source = { registry = "https://pypi.org/simple" } ··· 1501 { name = "fastapi" }, 1502 { name = "httpx" }, 1503 { name = "passlib", extra = ["bcrypt"] }, 1504 { name = "pydantic" }, 1505 { name = "pydantic-settings" }, 1506 { name = "python-dotenv" }, ··· 1536 { name = "fastapi", specifier = ">=0.115.0" }, 1537 { name = "httpx", specifier = ">=0.28.0" }, 1538 { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, 1539 { name = "pydantic", specifier = ">=2.11.0" }, 1540 { name = "pydantic-settings", specifier = ">=2.7.0" }, 1541 { name = "python-dotenv", specifier = ">=1.1.0" }, ··· 1771 sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } 1772 wheels = [ 1773 { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, 1774 ] 1775 1776 [[package]]
··· 1 version = 1 2 revision = 3 3 requires-python = ">=3.11" 4 + resolution-markers = [ 5 + "python_full_version >= '3.13'", 6 + "python_full_version == '3.12.*'", 7 + "python_full_version < '3.12'", 8 + ] 9 10 [[package]] 11 name = "alembic" ··· 105 106 [[package]] 107 name = "atproto" 108 + version = "0.0.1.dev461" 109 + source = { git = "https://github.com/zzstoatzz/atproto?rev=main#57bb99a28c916a5b5c960ade00e4f82695860af1" } 110 dependencies = [ 111 { name = "click" }, 112 { name = "cryptography" }, ··· 1110 ] 1111 1112 [[package]] 1113 + name = "psycopg" 1114 + version = "3.2.12" 1115 + source = { registry = "https://pypi.org/simple" } 1116 + dependencies = [ 1117 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 1118 + { name = "tzdata", marker = "sys_platform == 'win32'" }, 1119 + ] 1120 + sdist = { url = "https://files.pythonhosted.org/packages/a8/77/c72d10262b872617e509a0c60445afcc4ce2cd5cd6bc1c97700246d69c85/psycopg-3.2.12.tar.gz", hash = "sha256:85c08d6f6e2a897b16280e0ff6406bef29b1327c045db06d21f364d7cd5da90b", size = 160642, upload-time = "2025-10-26T00:46:03.045Z" } 1121 + wheels = [ 1122 + { url = "https://files.pythonhosted.org/packages/c8/28/8c4f90e415411dc9c78d6ba10b549baa324659907c13f64bfe3779d4066c/psycopg-3.2.12-py3-none-any.whl", hash = "sha256:8a1611a2d4c16ae37eada46438be9029a35bb959bb50b3d0e1e93c0f3d54c9ee", size = 206765, upload-time = "2025-10-26T00:10:42.173Z" }, 1123 + ] 1124 + 1125 + [package.optional-dependencies] 1126 + binary = [ 1127 + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, 1128 + ] 1129 + 1130 + [[package]] 1131 + name = "psycopg-binary" 1132 + version = "3.2.12" 1133 + source = { registry = "https://pypi.org/simple" } 1134 + wheels = [ 1135 + { url = "https://files.pythonhosted.org/packages/60/4d/980fdd0f75914c8b1f229a6e5a9c422b53e809166b96a7d0e1287b369796/psycopg_binary-3.2.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16db2549a31ccd4887bef05570d95036813ce25fd9810b523ba1c16b0f6cfd90", size = 4037686, upload-time = "2025-10-26T00:16:22.041Z" }, 1136 + { url = "https://files.pythonhosted.org/packages/51/76/6b6ccd3fd31c67bec8608225407322f26a2a633c05b35c56b7c0638dcc67/psycopg_binary-3.2.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b9a99ded7d19b24d3b6fa632b58e52bbdecde7e1f866c3b23d0c27b092af4e3", size = 4098526, upload-time = "2025-10-26T00:16:58.395Z" }, 1137 + { url = "https://files.pythonhosted.org/packages/91/d8/be5242efed4f57f74a27eb47cb3a01bebb04e43ca57e903fcbda23361e72/psycopg_binary-3.2.12-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:385c7b5cfffac115f413b8e32c941c85ea0960e0b94a6ef43bb260f774c54893", size = 4646680, upload-time = "2025-10-26T00:17:36.967Z" }, 1138 + { url = "https://files.pythonhosted.org/packages/20/c1/96e42d39c0e75c4059f80e8fc9b286e2b6d9652f30b42698101d4be201cf/psycopg_binary-3.2.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9c674887d1e0d4384c06c822bc7fcfede4952742e232ec1e76b5a6ae39a3ddd4", size = 4749345, upload-time = "2025-10-26T00:18:16.61Z" }, 1139 + { url = "https://files.pythonhosted.org/packages/78/00/0ee41e18bdb05b43a27ebf8a952343319554cd9bde7931f633343b5abbad/psycopg_binary-3.2.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72fd979e410ba7805462817ef8ed6f37dd75f9f4ae109bdb8503e013ccecb80b", size = 4432535, upload-time = "2025-10-26T00:18:53.823Z" }, 1140 + { url = "https://files.pythonhosted.org/packages/c5/77/580cc455ba909d9e3082b80bb1952f67c5b9692a56ecaf71816ce0e9aa69/psycopg_binary-3.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82fa5134517af44e28a30c38f34384773a0422ffd545fd298433ea9f2cc5a9", size = 3888888, upload-time = "2025-10-26T00:19:26.768Z" }, 1141 + { url = "https://files.pythonhosted.org/packages/cb/29/0d0d2aa4238fd57ddbd2f517c58cefb26d408d3e989cbca9ad43f4c48433/psycopg_binary-3.2.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:100fdfee763d701f6da694bde711e264aca4c2bc84fb81e1669fb491ce11d219", size = 3571385, upload-time = "2025-10-26T00:19:56.844Z" }, 1142 + { url = "https://files.pythonhosted.org/packages/b1/7d/eb11cd86339122c19c1039cb5ee5f87f88d6015dff564b1ed23d0c4a90e7/psycopg_binary-3.2.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:802bd01fb18a0acb0dea491f69a9a2da6034f33329a62876ab5b558a1fb66b45", size = 3614219, upload-time = "2025-10-26T00:20:27.135Z" }, 1143 + { url = "https://files.pythonhosted.org/packages/65/02/dff51dc1f88d9854405013e2cabbf7060c2b3993cb82d6e8ad21396081af/psycopg_binary-3.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:f33c9e12ed05e579b7fb3c8fdb10a165f41459394b8eb113e7c377b2bd027f61", size = 2919778, upload-time = "2025-10-26T00:20:51.974Z" }, 1144 + { url = "https://files.pythonhosted.org/packages/db/4a/b2779f74fdb0d661febe802fb3b770546a99f0a513ef108e8f9ed36b87cb/psycopg_binary-3.2.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ea9751310b840186379c949ede5a5129b31439acdb929f3003a8685372117ed8", size = 4019926, upload-time = "2025-10-26T00:21:25.599Z" }, 1145 + { url = "https://files.pythonhosted.org/packages/d5/af/df6c2beb44de456c4f025a61dfe611cf5b3eb3d3fa671144ce19ac7f1139/psycopg_binary-3.2.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9fdf3a0c24822401c60c93640da69b3dfd4d9f29c3a8d797244fe22bfe592823", size = 4092107, upload-time = "2025-10-26T00:22:00.043Z" }, 1146 + { url = "https://files.pythonhosted.org/packages/f6/3b/b16260c93a0a435000fd175f1abb8d12af5542bd9d35d17dd2b7f347dbd5/psycopg_binary-3.2.12-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49582c3b6d578bdaab2932b59f70b1bd93351ed4d594b2c97cea1611633c9de1", size = 4626849, upload-time = "2025-10-26T00:22:38.606Z" }, 1147 + { url = "https://files.pythonhosted.org/packages/cb/52/2c8d1c534777176e3e4832105f0b2f70c0ff3d63def0f1fda46833cc2dc1/psycopg_binary-3.2.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5b6e505618cb376a7a7d6af86833a8f289833fe4cc97541d7100745081dc31bd", size = 4719811, upload-time = "2025-10-26T00:23:18.23Z" }, 1148 + { url = "https://files.pythonhosted.org/packages/34/44/005ab6a42698489310f52f287b78c26560aeedb091ba12f034acdff4549b/psycopg_binary-3.2.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6a898717ab560db393355c6ecf39b8c534f252afc3131480db1251e061090d3a", size = 4410950, upload-time = "2025-10-26T00:23:55.532Z" }, 1149 + { url = "https://files.pythonhosted.org/packages/a7/ba/c59303ed65659cd62da2b3f4ad2b8635ae10eb85e7645d063025277c953d/psycopg_binary-3.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd632f7038c76b0921f6d5621f5ba9ecabfad3042fa40e5875db11771d2a5de", size = 3861578, upload-time = "2025-10-26T00:24:28.482Z" }, 1150 + { url = "https://files.pythonhosted.org/packages/29/ce/d36f03b11959978b2c2522c87369fa8d75c1fa9b311805b39ce7678455ae/psycopg_binary-3.2.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e9c9e64fb7cda688e9488402611c0be2c81083664117edcc709d15f37faa30f", size = 3534948, upload-time = "2025-10-26T00:24:58.657Z" }, 1151 + { url = "https://files.pythonhosted.org/packages/5a/cc/e0e5fc0d5f2d2650f85540cebd0d047e14b0933b99f713749b2ebc031047/psycopg_binary-3.2.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c1e38b1eda54910628f68448598139a9818973755abf77950057372c1fe89a6", size = 3583525, upload-time = "2025-10-26T00:25:28.731Z" }, 1152 + { url = "https://files.pythonhosted.org/packages/13/27/e2b1afb9819835f85f1575f07fdfc871dd8b4ea7ed8244bfe86a2f6d6566/psycopg_binary-3.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:77690f0bf08356ca00fc357f50a5980c7a25f076c2c1f37d9d775a278234fefd", size = 2910254, upload-time = "2025-10-26T00:25:53.335Z" }, 1153 + { url = "https://files.pythonhosted.org/packages/b2/0b/9d480aba4a4864832c29e6fc94ddd34d9927c276448eb3b56ffe24ed064c/psycopg_binary-3.2.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:442f20153415f374ae5753ca618637611a41a3c58c56d16ce55f845d76a3cf7b", size = 4017829, upload-time = "2025-10-26T00:26:27.031Z" }, 1154 + { url = "https://files.pythonhosted.org/packages/a4/f3/0d294b30349bde24a46741a1f27a10e8ab81e9f4118d27c2fe592acfb42a/psycopg_binary-3.2.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:79de3cc5adbf51677009a8fda35ac9e9e3686d5595ab4b0c43ec7099ece6aeb5", size = 4089835, upload-time = "2025-10-26T00:27:01.392Z" }, 1155 + { url = "https://files.pythonhosted.org/packages/82/d4/ff82e318e5a55d6951b278d3af7b4c7c1b19344e3a3722b6613f156a38ea/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:095ccda59042a1239ac2fefe693a336cb5cecf8944a8d9e98b07f07e94e2b78d", size = 4625474, upload-time = "2025-10-26T00:27:40.34Z" }, 1156 + { url = "https://files.pythonhosted.org/packages/b1/e8/2c9df6475a5ab6d614d516f4497c568d84f7d6c21d0e11444468c9786c9f/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:efab679a2c7d1bf7d0ec0e1ecb47fe764945eff75bb4321f2e699b30a12db9b3", size = 4720350, upload-time = "2025-10-26T00:28:20.104Z" }, 1157 + { url = "https://files.pythonhosted.org/packages/74/f5/7aec81b0c41985dc006e2d5822486ad4b7c2a1a97a5a05e37dc2adaf1512/psycopg_binary-3.2.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d369e79ad9647fc8217cbb51bbbf11f9a1ffca450be31d005340157ffe8e91b3", size = 4411621, upload-time = "2025-10-26T00:28:59.104Z" }, 1158 + { url = "https://files.pythonhosted.org/packages/fc/15/d3cb41b8fa9d5f14320ab250545fbb66f9ddb481e448e618902672a806c0/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eedc410f82007038030650aa58f620f9fe0009b9d6b04c3dc71cbd3bae5b2675", size = 3863081, upload-time = "2025-10-26T00:29:31.235Z" }, 1159 + { url = "https://files.pythonhosted.org/packages/69/8a/72837664e63e3cd3aa145cedcf29e5c21257579739aba78ab7eb668f7d9c/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bae4be7f6781bf6c9576eedcd5e1bb74468126fa6de991e47cdb1a8ea3a42a", size = 3537428, upload-time = "2025-10-26T00:30:01.465Z" }, 1160 + { url = "https://files.pythonhosted.org/packages/cc/7e/1b78ae38e7d69e6d7fb1e2dcce101493f5fa429480bac3a68b876c9b1635/psycopg_binary-3.2.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8ffe75fe6be902dadd439adf4228c98138a992088e073ede6dd34e7235f4e03e", size = 3585981, upload-time = "2025-10-26T00:30:31.635Z" }, 1161 + { url = "https://files.pythonhosted.org/packages/a3/f8/245b4868b2dac46c3fb6383b425754ae55df1910c826d305ed414da03777/psycopg_binary-3.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:2598d0e4f2f258da13df0560187b3f1dfc9b8688c46b9d90176360ae5212c3fc", size = 2912929, upload-time = "2025-10-26T00:30:56.413Z" }, 1162 + { url = "https://files.pythonhosted.org/packages/5c/5b/76fbb40b981b73b285a00dccafc38cf67b7a9b3f7d4f2025dda7b896e7ef/psycopg_binary-3.2.12-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dc68094e00a5a7e8c20de1d3a0d5e404a27f522e18f8eb62bbbc9f865c3c81ef", size = 4016868, upload-time = "2025-10-26T00:31:29.974Z" }, 1163 + { url = "https://files.pythonhosted.org/packages/0e/08/8841ae3e2d1a3228e79eaaf5b7f991d15f0a231bb5031a114305b19724b1/psycopg_binary-3.2.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2d55009eeddbef54c711093c986daaf361d2c4210aaa1ee905075a3b97a62441", size = 4090508, upload-time = "2025-10-26T00:32:04.192Z" }, 1164 + { url = "https://files.pythonhosted.org/packages/05/de/a41f62230cf4095ae4547eceada218cf28c17e7f94376913c1c8dde9546f/psycopg_binary-3.2.12-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66a031f22e4418016990446d3e38143826f03ad811b9f78f58e2afbc1d343f7a", size = 4629788, upload-time = "2025-10-26T00:32:43.28Z" }, 1165 + { url = "https://files.pythonhosted.org/packages/45/19/529d92134eae44475f781a86d58cdf3edd0953e17c69762abf387a9f2636/psycopg_binary-3.2.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:58ed30d33c25d7dc8d2f06285e88493147c2a660cc94713e4b563a99efb80a1f", size = 4724124, upload-time = "2025-10-26T00:33:22.594Z" }, 1166 + { url = "https://files.pythonhosted.org/packages/5c/f5/97344e87065f7c9713ce213a2cff7732936ec3af6622e4b2a88715a953f2/psycopg_binary-3.2.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e0b5ccd03ca4749b8f66f38608ccbcb415cbd130d02de5eda80d042b83bee90e", size = 4411340, upload-time = "2025-10-26T00:34:00.759Z" }, 1167 + { url = "https://files.pythonhosted.org/packages/b1/c2/34bce068f6bfb4c2e7bb1187bb64a3f3be254702b158c4ad05eacc0055cf/psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:909de94de7dd4d6086098a5755562207114c9638ec42c52d84c8a440c45fe084", size = 3867815, upload-time = "2025-10-26T00:34:33.181Z" }, 1168 + { url = "https://files.pythonhosted.org/packages/d1/a1/c647e01ab162e6bfa52380e23e486215e9d28ffd31e9cf3cb1e9ca59008b/psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7130effd0517881f3a852eff98729d51034128f0737f64f0d1c7ea8343d77bd7", size = 3541756, upload-time = "2025-10-26T00:35:08.622Z" }, 1169 + { url = "https://files.pythonhosted.org/packages/6b/d0/795bdaa8c946a7b7126bf7ca8d4371eaaa613093e3ec341a0e50f52cbee2/psycopg_binary-3.2.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89b3c5201ca616d69ca0c3c0003ca18f7170a679c445c7e386ebfb4f29aa738e", size = 3587950, upload-time = "2025-10-26T00:35:41.183Z" }, 1170 + { url = "https://files.pythonhosted.org/packages/53/cf/10c3e95827a3ca8af332dfc471befec86e15a14dc83cee893c49a4910dad/psycopg_binary-3.2.12-cp314-cp314-win_amd64.whl", hash = "sha256:48a8e29f3e38fcf8d393b8fe460d83e39c107ad7e5e61cd3858a7569e0554a39", size = 3005787, upload-time = "2025-10-26T00:36:06.783Z" }, 1171 + ] 1172 + 1173 + [[package]] 1174 name = "ptyprocess" 1175 version = "0.7.0" 1176 source = { registry = "https://pypi.org/simple" } ··· 1567 { name = "fastapi" }, 1568 { name = "httpx" }, 1569 { name = "passlib", extra = ["bcrypt"] }, 1570 + { name = "psycopg", extra = ["binary"] }, 1571 { name = "pydantic" }, 1572 { name = "pydantic-settings" }, 1573 { name = "python-dotenv" }, ··· 1603 { name = "fastapi", specifier = ">=0.115.0" }, 1604 { name = "httpx", specifier = ">=0.28.0" }, 1605 { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, 1606 + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" }, 1607 { name = "pydantic", specifier = ">=2.11.0" }, 1608 { name = "pydantic-settings", specifier = ">=2.7.0" }, 1609 { name = "python-dotenv", specifier = ">=1.1.0" }, ··· 1839 sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } 1840 wheels = [ 1841 { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, 1842 + ] 1843 + 1844 + [[package]] 1845 + name = "tzdata" 1846 + version = "2025.2" 1847 + source = { registry = "https://pypi.org/simple" } 1848 + sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } 1849 + wheels = [ 1850 + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, 1851 ] 1852 1853 [[package]]