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