+23
.dockerignore
+23
.dockerignore
···
+9
-5
.env.example
+9
-5
.env.example
···
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
frontend/bun.lockb
This is a binary file and will not be displayed.
+2
-1
frontend/package.json
+2
-1
frontend/package.json
···
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
+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
+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
+7
frontend/src/lib/config.ts
+48
frontend/src/lib/theme.ts
+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
+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
+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
+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
+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
-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
-1
justfile
+1
-1
justfile
+1
pyproject.toml
+1
pyproject.toml
+17
-5
src/relay/api/audio.py
+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
+1
-1
src/relay/api/auth.py
+111
-4
src/relay/api/tracks.py
+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
+5
src/relay/atproto/__init__.py
+109
src/relay/atproto/records.py
+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
+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
+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
+1
-1
src/relay/main.py
+2
-1
src/relay/models/__init__.py
+2
-1
src/relay/models/__init__.py
+3
-3
src/relay/models/database.py
+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
+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
+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
+11
-2
src/relay/storage/__init__.py
···
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
+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
+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
+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]]