music on atproto
plyr.fm
1# plyr.fm Lexicons
2
3> **note**: this is living documentation. the lexicon JSON definitions in `/lexicons/` are the source of truth.
4
5## what are lexicons?
6
7lexicons are ATProto's schema system for defining record types and API methods. each schema uses a **Namespace ID (NSID)** in reverse-DNS format (e.g., `fm.plyr.track`) to uniquely identify it across the network.
8
9for background, see:
10- [ATProto lexicon guide](https://atproto.com/guides/lexicon)
11- [ATProto data model](https://atproto.com/guides/data-repos)
12
13## our namespace
14
15plyr.fm uses the `fm.plyr` namespace for all custom record types. this is environment-aware:
16
17| environment | namespace |
18|-------------|-----------|
19| production | `fm.plyr` |
20| staging | `fm.plyr.stg` |
21| development | `fm.plyr.dev` |
22
23**important**: we never use Bluesky's `app.bsky.*` lexicons. even for concepts like "likes" that Bluesky has, we define our own (`fm.plyr.like`) to maintain namespace isolation and avoid coupling to another app's schema evolution.
24
25## current lexicons
26
27### fm.plyr.track
28
29the core content record - an audio track uploaded by an artist.
30
31```
32key: tid (timestamp-based ID)
33required: title, artist, audioUrl, fileType, createdAt
34optional: album, duration, features, imageUrl
35```
36
37this was the first lexicon, established when the project began. tracks are stored in the user's PDS (Personal Data Server) and indexed by plyr.fm for discovery.
38
39### fm.plyr.like
40
41engagement signal indicating a user liked a track.
42
43```
44key: tid
45required: subject (strongRef to track), createdAt
46```
47
48introduced in november 2025. uses `com.atproto.repo.strongRef` to reference the target track by URI and CID, which is the standard ATProto pattern for cross-record references.
49
50early implementation mistakenly used `app.bsky.feed.like` before being corrected to use our own namespace - a lesson in why namespace discipline matters.
51
52### fm.plyr.comment
53
54timed comments anchored to playback positions, similar to SoundCloud.
55
56```
57key: tid
58required: subject (strongRef to track), text, timestampMs, createdAt
59optional: updatedAt
60```
61
62introduced in november 2025. the `timestampMs` field captures playback position when the comment was made, enabling "click to seek" functionality.
63
64### fm.plyr.list
65
66generic ordered collection for playlists, albums, and liked track lists.
67
68```
69key: tid
70required: items (array of strongRefs), createdAt
71optional: name, listType, updatedAt
72```
73
74introduced in december 2025. the `listType` field uses `knownValues` (an ATProto pattern for extensible enums) with current values: `album`, `playlist`, `liked`.
75
76this lexicon went through several iterations:
771. initially designed specifically for playlists
782. generalized to support albums and liked collections
793. simplified to just reference any record type via strongRef
80
81### fm.plyr.actor.profile
82
83artist profile metadata specific to plyr.fm.
84
85```
86key: literal:self (singleton - only one per user)
87required: createdAt
88optional: bio, updatedAt
89```
90
91introduced in december 2025. uses `literal:self` as the record key, meaning each user can only have one profile record. this is updated via `putRecord` with rkey="self".
92
93## ATProto primitives we use
94
95### record keys
96
97- **tid**: timestamp-based IDs generated by the client. used for most records where multiple instances per user are expected (tracks, likes, comments, lists).
98- **literal:self**: a fixed key for singleton records. used for profile where only one record per user should exist.
99
100### strongRef
101
102`com.atproto.repo.strongRef` is ATProto's standard way to reference another record:
103
104```json
105{
106 "uri": "at://did:plc:xyz/fm.plyr.track/abc123",
107 "cid": "bafyreig..."
108}
109```
110
111the URI identifies the record; the CID is its content hash at a specific version. we use strongRefs in likes (referencing tracks), comments (referencing tracks), and lists (referencing any records).
112
113### knownValues
114
115rather than strict enums, ATProto uses `knownValues` for extensible value sets. our `fm.plyr.list.listType` field declares known values but validators won't reject unknown values - this allows the schema to evolve without breaking existing records.
116
117## local indexing
118
119ATProto records in user PDSes are the source of truth, but querying across PDSes is slow. we maintain local database tables that index records for efficient queries:
120
121- `tracks` table indexes `fm.plyr.track` records
122- `track_likes` table indexes `fm.plyr.like` records
123- `track_comments` table indexes `fm.plyr.comment` records
124- `playlists` table indexes `fm.plyr.list` records
125
126the sync pattern: when a user logs in, we fetch their records from their PDS and update our local index. background jobs keep indexes fresh.
127
128## future: codegen from lexicon JSON
129
130currently, our Python models are hand-written to match the lexicon JSON definitions. this is error-prone.
131
132issue [#494](https://github.com/zzstoatzz/plyr.fm/issues/494) tracks building a portable lexicon-to-Pydantic codegen tool. the goal is to generate models directly from the JSON definitions in `/lexicons/`, ensuring the code always matches the schema.
133
134a Rust-based SDK for this purpose is in development. once complete, the workflow will be:
135
1361. edit lexicon JSON definitions
1372. run codegen to regenerate Python models
1383. use generated models in application code
139
140this removes the manual sync burden and enables type-safe ATProto record handling.
141
142## permission sets
143
144permission sets bundle OAuth permissions under human-readable titles. instead of users seeing "fm.plyr.track, fm.plyr.like, ..." they see "plyr.fm Music Library".
145
146### fm.plyr.authFullApp
147
148full access for the main web app - create/update/delete on all collections.
149
150### enabling permission sets
151
152set `ATPROTO_USE_PERMISSION_SETS=true` to use `include:fm.plyr.authFullApp` instead of granular scopes.
153
154**requirement**: permission set lexicons must be published to `com.atproto.lexicon.schema` collection on the `plyr.fm` authority repo (`did:plc:vs3hnzq2daqbszxlysywzy54`).
155
156see [research doc](../research/2026-01-01-atproto-oauth-permission-sets.md) for implementation details.
157
158## adding new lexicons
159
160when adding a new record type:
161
1621. create the JSON definition in `/lexicons/`
1632. add the collection to `AtprotoSettings` in `backend/src/backend/config.py`
1643. add the OAuth scope in the auth flow
1654. create database migration for local indexing
1665. implement API endpoints and sync logic
167
168see existing lexicons as templates. keep records minimal - ATProto schemas can only add optional fields after publication, never remove or change required fields.