plyr.fm Lexicons#
note: this is living documentation. the lexicon JSON definitions in
/lexicons/are the source of truth.
what are lexicons?#
lexicons 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.
for background, see:
our namespace#
plyr.fm uses the fm.plyr namespace for all custom record types. this is environment-aware:
| environment | namespace |
|---|---|
| production | fm.plyr |
| staging | fm.plyr.stg |
| development | fm.plyr.dev |
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.
current lexicons#
fm.plyr.track#
the core content record - an audio track uploaded by an artist.
key: tid (timestamp-based ID)
required: title, artist, audioUrl, fileType, createdAt
optional: album, duration, features, imageUrl
this 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.
fm.plyr.like#
engagement signal indicating a user liked a track.
key: tid
required: subject (strongRef to track), createdAt
introduced 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.
early implementation mistakenly used app.bsky.feed.like before being corrected to use our own namespace - a lesson in why namespace discipline matters.
fm.plyr.comment#
timed comments anchored to playback positions, similar to SoundCloud.
key: tid
required: subject (strongRef to track), text, timestampMs, createdAt
optional: updatedAt
introduced in november 2025. the timestampMs field captures playback position when the comment was made, enabling "click to seek" functionality.
fm.plyr.list#
generic ordered collection for playlists, albums, and liked track lists.
key: tid
required: items (array of strongRefs), createdAt
optional: name, listType, updatedAt
introduced in december 2025. the listType field uses knownValues (an ATProto pattern for extensible enums) with current values: album, playlist, liked.
this lexicon went through several iterations:
- initially designed specifically for playlists
- generalized to support albums and liked collections
- simplified to just reference any record type via strongRef
fm.plyr.actor.profile#
artist profile metadata specific to plyr.fm.
key: literal:self (singleton - only one per user)
required: createdAt
optional: bio, updatedAt
introduced 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".
ATProto primitives we use#
record keys#
- tid: timestamp-based IDs generated by the client. used for most records where multiple instances per user are expected (tracks, likes, comments, lists).
- literal:self: a fixed key for singleton records. used for profile where only one record per user should exist.
strongRef#
com.atproto.repo.strongRef is ATProto's standard way to reference another record:
{
"uri": "at://did:plc:xyz/fm.plyr.track/abc123",
"cid": "bafyreig..."
}
the 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).
knownValues#
rather 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.
local indexing#
ATProto 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:
trackstable indexesfm.plyr.trackrecordstrack_likestable indexesfm.plyr.likerecordstrack_commentstable indexesfm.plyr.commentrecordsplayliststable indexesfm.plyr.listrecords
the sync pattern: when a user logs in, we fetch their records from their PDS and update our local index. background jobs keep indexes fresh.
future: codegen from lexicon JSON#
currently, our Python models are hand-written to match the lexicon JSON definitions. this is error-prone.
issue #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.
a Rust-based SDK for this purpose is in development. once complete, the workflow will be:
- edit lexicon JSON definitions
- run codegen to regenerate Python models
- use generated models in application code
this removes the manual sync burden and enables type-safe ATProto record handling.
permission sets#
permission sets bundle OAuth permissions under human-readable titles. instead of users seeing "fm.plyr.track, fm.plyr.like, ..." they see "plyr.fm Music Library".
fm.plyr.authFullApp#
full access for the main web app - create/update/delete on all collections.
enabling permission sets#
set ATPROTO_USE_PERMISSION_SETS=true to use include:fm.plyr.authFullApp instead of granular scopes.
requirement: permission set lexicons must be published to com.atproto.lexicon.schema collection on the plyr.fm authority repo (did:plc:vs3hnzq2daqbszxlysywzy54).
see research doc for implementation details.
adding new lexicons#
when adding a new record type:
- create the JSON definition in
/lexicons/ - add the collection to
AtprotoSettingsinbackend/src/backend/config.py - add the OAuth scope in the auth flow
- create database migration for local indexing
- implement API endpoints and sync logic
see existing lexicons as templates. keep records minimal - ATProto schemas can only add optional fields after publication, never remove or change required fields.