music on atproto
plyr.fm
1# sensitive content moderation
2
3## overview
4
5plyr.fm allows artists to upload cover art and use their Bluesky avatars. some of this content may be inappropriate for general audiences (nudity, graphic imagery, etc.). rather than blocking uploads, we blur sensitive images by default and let users opt-in to view them.
6
7this follows our core moderation philosophy: **information, not enforcement**. we flag content and let users decide what they want to see.
8
9## current implementation
10
11### database schema
12
13```sql
14-- tracks flagged images
15CREATE TABLE sensitive_images (
16 id SERIAL PRIMARY KEY,
17 image_id VARCHAR, -- R2 image ID (for uploaded images)
18 url TEXT, -- full URL (for external images like Bluesky avatars)
19 reason VARCHAR, -- why flagged: 'nudity', 'violence', etc.
20 flagged_at TIMESTAMPTZ,
21 flagged_by VARCHAR -- admin identifier
22);
23
24-- user preference
25ALTER TABLE user_preferences ADD COLUMN show_sensitive_artwork BOOLEAN DEFAULT false;
26```
27
28images can be flagged by either:
29- `image_id` - for images uploaded to R2 (track artwork, album covers)
30- `url` - for external images (Bluesky avatars synced from PDS)
31
32### frontend architecture
33
34```
35┌─────────────────────────────────────────────────────────────────┐
36│ page load │
37└─────────────────────────────────────────────────────────────────┘
38 │
39 ┌─────────────────────┼─────────────────────┐
40 │ │ │
41 ▼ ▼ ▼
42┌───────────────┐ ┌───────────────┐ ┌───────────────┐
43│ +layout.server│ │ +layout.ts │ │ +layout.svelte│
44│ fetch flagged │ │ pass through │ │ init client │
45│ images (SSR) │ │ to pages │ │ moderation │
46└───────────────┘ └───────────────┘ └───────────────┘
47 │ │
48 ▼ ▼
49┌───────────────┐ ┌───────────────┐
50│ meta tags use │ │ SensitiveImage│
51│ SSR data │ │ component uses│
52│ (link preview)│ │ client store │
53└───────────────┘ └───────────────┘
54```
55
56two-pronged approach:
571. **SSR** - `+layout.server.ts` fetches flagged images for meta tag filtering (link previews)
582. **client** - moderation store fetches same data for runtime blur effect
59
60### SensitiveImage component
61
62wraps any image that might need blurring:
63
64```svelte
65<SensitiveImage src={imageUrl}>
66 <img src={imageUrl} alt="..." />
67</SensitiveImage>
68```
69
70the component:
71- checks if `src` matches any flagged image
72- applies CSS blur filter if flagged
73- shows tooltip on hover: "sensitive - enable in portal"
74- respects user's `show_sensitive_artwork` preference
75
76### matching logic
77
78```typescript
79function checkImageSensitive(url: string, data: SensitiveImagesData): boolean {
80 // exact URL match (for external images)
81 if (data.urls.includes(url)) return true;
82
83 // R2 image ID extraction and match
84 const r2Match = url.match(/r2\.dev\/([^/.]+)\./);
85 if (r2Match && data.image_ids.includes(r2Match[1])) return true;
86
87 const cdnMatch = url.match(/\/images\/([^/.]+)\./);
88 if (cdnMatch && data.image_ids.includes(cdnMatch[1])) return true;
89
90 return false;
91}
92```
93
94### API endpoint
95
96```
97GET /moderation/sensitive-images
98
99Response:
100{
101 "image_ids": ["abc123", "def456"],
102 "urls": ["https://cdn.bsky.app/..."]
103}
104```
105
106returns arrays for SSR compatibility (Sets don't serialize to JSON).
107
108## user experience
109
110### default behavior
111
112- all images matching `sensitive_images` table are blurred
113- tooltip on hover explains how to enable
114- link previews (og:image) exclude sensitive images entirely
115
116### opt-in flow
117
1181. user navigates to portal → "your data"
1192. toggles "sensitive artwork" to enabled
1203. `show_sensitive_artwork` preference saved to database
1214. all sensitive images immediately unblur
122
123## current limitations
124
125### manual flagging only
126
127the `sensitive_images` table is currently populated manually by admins. this is "whack-a-mole" moderation - we flag images as we discover them.
128
129**future improvements needed:**
130
1311. **perceptual hashing** - hash images at upload time, detect re-uploads of flagged content
1322. **AI detection** - integrate NSFW detection API (AWS Rekognition, Google Vision, etc.)
1333. **user reporting** - let users flag inappropriate content
1344. **artist self-labeling** - let artists mark their own content as sensitive
135
136### no ATProto labels yet
137
138unlike copyright moderation, sensitive content flags don't emit ATProto labels. this is intentional for now - we're still figuring out the right taxonomy for content labels vs. copyright labels.
139
140future work might include:
141- `content-warning` label type
142- integration with Bluesky's existing content label system
143- respecting labels from other ATProto services
144
145## moderation workflow
146
147### current process
148
1491. admin discovers inappropriate image (user report, browsing, etc.)
1502. admin identifies the image source:
151 - R2 upload: extract `image_id` from URL
152 - external: copy full URL
1533. admin inserts row into `sensitive_images` via SQL or Neon console
1544. image is immediately blurred for all users
155
156### example: flagging an R2 image
157
158```sql
159-- image URL: https://pub-xxx.r2.dev/images/abc123.jpg
160INSERT INTO sensitive_images (image_id, reason, flagged_by)
161VALUES ('abc123', 'nudity', 'admin');
162```
163
164### example: flagging a Bluesky avatar
165
166```sql
167-- avatar URL from artist profile
168INSERT INTO sensitive_images (url, reason, flagged_by)
169VALUES (
170 'https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkrei...@jpeg',
171 'nudity',
172 'admin'
173);
174```
175
176## related documentation
177
178- [overview.md](./overview.md) - moderation philosophy and architecture
179- [copyright-detection.md](./copyright-detection.md) - automated copyright scanning
180- [atproto-labeler.md](./atproto-labeler.md) - ATProto label emission