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:

  1. initially designed specifically for playlists
  2. generalized to support albums and liked collections
  3. 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:

  • tracks table indexes fm.plyr.track records
  • track_likes table indexes fm.plyr.like records
  • track_comments table indexes fm.plyr.comment records
  • playlists table indexes fm.plyr.list records

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:

  1. edit lexicon JSON definitions
  2. run codegen to regenerate Python models
  3. 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:

  1. create the JSON definition in /lexicons/
  2. add the collection to AtprotoSettings in backend/src/backend/config.py
  3. add the OAuth scope in the auth flow
  4. create database migration for local indexing
  5. 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.