music on atproto
plyr.fm
1# unified search
2
3global search across tracks, artists, albums, and tags with fuzzy matching.
4
5## usage
6
7**keyboard shortcut**: `Cmd+K` (mac) or `Ctrl+K` (windows/linux)
8
9the search modal opens as an overlay with:
10- instant fuzzy matching as you type
11- results grouped by type with relevance scores
12- keyboard navigation (arrow keys, enter, esc)
13- artwork/avatars displayed when available
14
15## architecture
16
17### frontend
18
19**state management**: `frontend/src/lib/search.svelte.ts`
20
21```typescript
22import { search } from '$lib/search.svelte';
23
24// open/close
25search.open();
26search.close();
27search.toggle();
28
29// reactive state
30search.isOpen // boolean
31search.query // string
32search.results // SearchResult[]
33search.loading // boolean
34search.error // string | null
35```
36
37**component**: `frontend/src/lib/components/SearchModal.svelte`
38
39renders the search overlay with:
40- debounced input (150ms)
41- keyboard navigation
42- lazy-loaded images with fallback
43- platform-aware shortcut hints
44
45**keyboard handler**: `frontend/src/routes/+layout.svelte`
46
47```typescript
48// Cmd/Ctrl+K toggles search from anywhere
49if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
50 event.preventDefault();
51 search.toggle();
52}
53```
54
55### backend
56
57**endpoint**: `GET /search/`
58
59```
60GET /search/?q=query&type=tracks,artists&limit=10
61```
62
63**parameters**:
64- `q` (required): search query, 2-100 characters
65- `type` (optional): filter by type(s), comma-separated: `tracks`, `artists`, `albums`, `tags`
66- `limit` (optional): max results per type, 1-50, default 20
67
68**response**:
69
70```json
71{
72 "results": [
73 {
74 "type": "track",
75 "id": 123,
76 "title": "song name",
77 "artist_handle": "artist.bsky.social",
78 "artist_display_name": "artist name",
79 "image_url": "https://...",
80 "relevance": 0.85
81 },
82 {
83 "type": "artist",
84 "did": "did:plc:...",
85 "handle": "artist.bsky.social",
86 "display_name": "artist name",
87 "avatar_url": "https://...",
88 "relevance": 0.72
89 }
90 ],
91 "counts": {
92 "tracks": 5,
93 "artists": 2,
94 "albums": 1,
95 "tags": 0
96 }
97}
98```
99
100**implementation**: `backend/src/backend/api/search.py`
101
102### database
103
104**extension**: `pg_trgm` for trigram-based fuzzy matching
105
106**indexes** (GIN with `gin_trgm_ops`):
107- `ix_tracks_title_trgm` on `tracks.title`
108- `ix_artists_handle_trgm` on `artists.handle`
109- `ix_artists_display_name_trgm` on `artists.display_name`
110- `ix_albums_title_trgm` on `albums.title`
111- `ix_tags_name_trgm` on `tags.name`
112
113**migration**: `backend/alembic/versions/2025_12_03_..._add_pg_trgm_extension_and_search_indexes.py`
114
115## fuzzy matching
116
117uses postgresql's `similarity()` function from `pg_trgm`:
118
119```sql
120SELECT title, similarity(title, 'query') as relevance
121FROM tracks
122WHERE similarity(title, 'query') > 0.1
123ORDER BY relevance DESC
124```
125
126**threshold**: 0.1 minimum similarity (configurable)
127
128**scoring**: 0.0 to 1.0, where 1.0 is exact match
129
130**examples**:
131- "bufo" matches "bufo" (1.0), "bufo mix" (0.6), "buffalo" (0.4)
132- "zz" matches "zzstoatzz" (0.3), "jazz" (0.25)
133
134## result types
135
136### tracks
137
138- links to `/track/{id}`
139- shows artwork if available
140- subtitle: "by {artist_display_name}"
141
142### artists
143
144- links to `/u/{handle}`
145- shows avatar if available
146- subtitle: "@{handle}"
147
148### albums
149
150- links to `/u/{artist_handle}/album/{slug}`
151- shows cover art if available
152- subtitle: "by {artist_display_name}"
153
154### tags
155
156- links to `/tag/{name}`
157- no artwork
158- subtitle: "{count} tracks"
159
160## error handling
161
162**client-side validation**:
163- minimum 2 characters to search
164- maximum 100 characters (shows inline error)
165
166**api errors**:
167- 422: query validation failed
168- displayed as error message in modal
169
170**image loading**:
171- lazy loading via `loading="lazy"`
172- on error: hides image, shows fallback icon
173
174## scaling
175
176pg_trgm with GIN indexes scales well:
177- handles millions of rows efficiently
178- index size grows ~3x text size
179- queries remain sub-millisecond for typical workloads
180
181current production scale (~100 entities) is trivial.
182
183## future enhancements
184
185- search trigger button in header (for discoverability)
186- recent searches history
187- search within specific entity type tabs
188- full-text search with `tsvector` for longer content
189- search suggestions/autocomplete