music on atproto
plyr.fm
1# configuration
2
3plyr.fm uses nested pydantic settings for configuration management, following a pattern similar to prefect.
4
5## settings structure
6
7settings are organized into logical sections:
8
9```python
10from backend.config import settings
11
12# application settings
13settings.app.name # "plyr"
14settings.app.port # 8001 (from PORT)
15settings.app.debug # false
16settings.app.broadcast_channel_prefix # "plyr"
17settings.app.canonical_host # "plyr.fm"
18settings.app.canonical_url # computed: https://plyr.fm
19
20# frontend settings
21settings.frontend.url # from FRONTEND_URL
22settings.frontend.cors_origin_regex # from FRONTEND_CORS_ORIGIN_REGEX (optional)
23settings.frontend.resolved_cors_origin_regex # computed: defaults to relay-4i6.pages.dev pattern
24
25# database settings
26settings.database.url # from DATABASE_URL
27
28# storage settings (cloudflare r2)
29settings.storage.backend # from STORAGE_BACKEND
30settings.storage.r2_bucket # from R2_BUCKET (audio files)
31settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files)
32settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL
33settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files)
34settings.storage.r2_public_image_bucket_url # from R2_PUBLIC_IMAGE_BUCKET_URL (image files)
35settings.storage.aws_access_key_id # from AWS_ACCESS_KEY_ID
36settings.storage.aws_secret_access_key # from AWS_SECRET_ACCESS_KEY
37
38# atproto settings
39settings.atproto.pds_url # from ATPROTO_PDS_URL
40settings.atproto.client_id # from ATPROTO_CLIENT_ID
41settings.atproto.client_secret # from ATPROTO_CLIENT_SECRET
42settings.atproto.redirect_uri # from ATPROTO_REDIRECT_URI
43settings.atproto.app_namespace # from ATPROTO_APP_NAMESPACE
44settings.atproto.old_app_namespace # from ATPROTO_OLD_APP_NAMESPACE (optional)
45settings.atproto.oauth_encryption_key # from OAUTH_ENCRYPTION_KEY
46settings.atproto.track_collection # computed: "{namespace}.track"
47settings.atproto.old_track_collection # computed: "{old_namespace}.track" (if set)
48settings.atproto.resolved_scope # computed: "atproto repo:{collections}"
49
50# observability settings (pydantic logfire)
51settings.observability.enabled # from LOGFIRE_ENABLED
52settings.observability.write_token # from LOGFIRE_WRITE_TOKEN
53settings.observability.environment # from LOGFIRE_ENVIRONMENT
54
55# notification settings
56settings.notify.enabled # from NOTIFY_ENABLED
57settings.notify.recipient_handle # from NOTIFY_RECIPIENT_HANDLE
58settings.notify.bot.handle # from NOTIFY_BOT_HANDLE
59settings.notify.bot.password # from NOTIFY_BOT_PASSWORD
60```
61
62## environment variables
63
64all settings can be configured via environment variables. the variable names match the flat structure used in `.env`:
65
66### required
67
68```bash
69# database
70DATABASE_URL=postgresql+psycopg://user:pass@host/db
71
72# oauth (uses client metadata discovery - no registration required)
73ATPROTO_CLIENT_ID=https://your-domain.com/oauth-client-metadata.json
74ATPROTO_CLIENT_SECRET=<optional-client-secret>
75ATPROTO_REDIRECT_URI=https://your-domain.com/auth/callback
76OAUTH_ENCRYPTION_KEY=<base64-encoded-32-byte-key>
77
78# storage
79STORAGE_BACKEND=r2 # or "filesystem"
80R2_BUCKET=your-audio-bucket
81R2_IMAGE_BUCKET=your-image-bucket
82R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com
83R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
84R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-xxx.r2.dev # for image files
85AWS_ACCESS_KEY_ID=your-r2-access-key
86AWS_SECRET_ACCESS_KEY=your-r2-secret
87```
88
89### optional
90
91```bash
92# app
93PORT=8001
94FRONTEND_URL=http://localhost:5173
95
96# observability
97LOGFIRE_ENABLED=true
98LOGFIRE_WRITE_TOKEN=pylf_xxx
99
100# notifications (bluesky DMs)
101NOTIFY_ENABLED=true
102NOTIFY_RECIPIENT_HANDLE=your.handle
103NOTIFY_BOT_HANDLE=bot.handle
104NOTIFY_BOT_PASSWORD=app-password
105```
106
107## computed fields
108
109some settings are computed from other values:
110
111### `settings.app.canonical_url`
112
113automatically determines the protocol based on host:
114- `localhost` or `127.0.0.1` → `http://`
115- anything else → `https://`
116
117can be overridden with `canonical_url_override` if needed.
118
119### `settings.frontend.resolved_cors_origin_regex`
120
121constructs the CORS origin regex pattern:
122```python
123# default: allows localhost + relay-4i6.pages.dev (including preview deployments)
124r"^(https://([a-z0-9]+\.)?relay-4i6\.pages\.dev|http://localhost:5173)$"
125```
126
127can be overridden with `FRONTEND_CORS_ORIGIN_REGEX` if needed.
128
129### `settings.atproto.track_collection`
130
131constructs the atproto collection name from the namespace:
132```python
133f"{settings.atproto.app_namespace}.track"
134# default: "fm.plyr.track"
135```
136
137### `settings.atproto.resolved_scope`
138
139constructs the oauth scope from the collection(s):
140```python
141# base scopes: our track collection + our like collection
142scopes = [
143 f"repo:{settings.atproto.track_collection}",
144 f"repo:{settings.atproto.app_namespace}.like",
145]
146
147# if we have an old namespace, add old track collection too
148if settings.atproto.old_app_namespace:
149 scopes.append(f"repo:{settings.atproto.old_track_collection}")
150
151return f"atproto {' '.join(scopes)}"
152# default: "atproto repo:fm.plyr.track repo:fm.plyr.like"
153```
154
155can be overridden with `ATPROTO_SCOPE_OVERRIDE` if needed.
156
157## atproto namespace
158
159plyr.fm uses `fm.plyr` as the ATProto namespace:
160
161```bash
162ATPROTO_APP_NAMESPACE=fm.plyr # default
163```
164
165this defines the collections:
166- `track_collection` → `"fm.plyr.track"`
167- `like_collection` → `"fm.plyr.like"` (implicit)
168- `resolved_scope` → `"atproto repo:fm.plyr.track repo:fm.plyr.like"`
169
170### environment-specific namespaces
171
172each environment uses a separate namespace to prevent test data from polluting production collections:
173
174**development (local):**
175```bash
176ATPROTO_APP_NAMESPACE=fm.plyr.dev
177```
178- `track_collection` → `"fm.plyr.dev.track"`
179- `like_collection` → `"fm.plyr.dev.like"`
180- records written to dev-specific collections on user's PDS
181
182**staging:**
183```bash
184ATPROTO_APP_NAMESPACE=fm.plyr.stg
185```
186- `track_collection` → `"fm.plyr.stg.track"`
187- `like_collection` → `"fm.plyr.stg.like"`
188- records written to staging-specific collections on user's PDS
189
190**production:**
191```bash
192ATPROTO_APP_NAMESPACE=fm.plyr
193```
194- `track_collection` → `"fm.plyr.track"`
195- `like_collection` → `"fm.plyr.like"`
196- records written to production collections on user's PDS
197
198this separation ensures that:
199- test tracks/likes created in dev/staging don't pollute production collections
200- OAuth scopes are environment-specific
201- database and ATProto records stay aligned within each environment
202
203see `docs/deployment/environments.md` for more details on environment configuration.
204
205### namespace migration
206
207optionally supports migration from an old namespace:
208
209```bash
210ATPROTO_OLD_APP_NAMESPACE=app.relay # optional, for migration
211```
212
213when set, OAuth scopes will include both old and new namespaces:
214- `old_track_collection` → `"app.relay.track"`
215- `resolved_scope` → `"atproto repo:fm.plyr.track repo:fm.plyr.like repo:app.relay.track"`
216
217## usage in code
218
219```python
220from backend.config import settings
221
222# access nested settings
223database_url = settings.database.url
224r2_bucket = settings.storage.r2_bucket
225track_collection = settings.atproto.track_collection
226
227# computed properties work seamlessly
228canonical_url = settings.app.canonical_url
229oauth_scope = settings.atproto.resolved_scope
230```
231
232## testing
233
234tests automatically override settings with local defaults via `tests/__init__.py`:
235
236```python
237os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://localhost/relay")
238os.environ.setdefault("LOGFIRE_ENABLED", "false")
239os.environ.setdefault("NOTIFY_ENABLED", "false")
240```
241
242individual tests can override settings using pytest fixtures:
243
244```python
245from backend.config import Settings
246
247def test_something(monkeypatch):
248 monkeypatch.setenv("PORT", "9100")
249 monkeypatch.setenv("ATPROTO_APP_NAMESPACE", "com.example.test")
250
251 settings = Settings() # reload with new env vars
252 assert settings.app.port == 9100
253 assert settings.atproto.track_collection == "com.example.test.track"
254```
255
256## migration from flat settings
257
258the refactor maintains backward compatibility with all existing environment variables:
259
260| old (flat) | new (nested) | env var |
261|-------------------------|---------------------------------|-------------------|
262| `settings.port` | `settings.app.port` | `PORT` |
263| `settings.database_url` | `settings.database.url` | `DATABASE_URL` |
264| `settings.r2_bucket` | `settings.storage.r2_bucket` | `R2_BUCKET` |
265| `settings.atproto_scope`| `settings.atproto.resolved_scope`| (computed) |
266
267all code has been updated to use the nested structure.
268
269## design
270
271the settings design follows the prefect pattern:
272- each section extends `BaseSettings` subclass
273- sections are composed in the root `Settings` class
274- environment variables map directly to field names
275- computed fields derive values from other settings
276- type hints ensure correct types at runtime
277
278see `src/backend/config.py` for implementation details.