feat: add reason selection for false positive resolution in moderation UI (#408)

* feat: add reason selection for false positive resolution in moderation UI

when marking a copyright flag as a false positive, admins must now select
a reason from preset options:
- original_artist: artist uploaded their own distributed music
- licensed: has licensing/permission rights
- fingerprint_noise: matcher produced false match
- cover_version: legal cover or remix
- other: free text in notes

the reason is stored in label_context table (resolution_reason, resolution_notes)
for audit purposes. the ATProto negation label stays minimal per protocol spec.

resolved flags now display the reason instead of just "resolved".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* rm random file

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub bfabc87a e5f5195a

Changed files
+426 -93
moderation
+92 -17
moderation/src/admin.rs
··· 39 39 pub uri: String, 40 40 #[serde(default = "default_val")] 41 41 pub val: String, 42 + /// Reason for marking as false positive. 43 + pub reason: Option<String>, 44 + /// Additional notes about the resolution. 45 + pub notes: Option<String>, 42 46 } 43 47 44 48 fn default_val() -> String { ··· 100 104 Json(request): Json<ResolveRequest>, 101 105 ) -> Result<Json<ResolveResponse>, AppError> { 102 106 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 103 - let signer = state.signer.as_ref().ok_or(AppError::LabelerNotConfigured)?; 107 + let signer = state 108 + .signer 109 + .as_ref() 110 + .ok_or(AppError::LabelerNotConfigured)?; 104 111 105 112 tracing::info!(uri = %request.uri, val = %request.val, "resolving flag (creating negation)"); 106 113 ··· 127 134 axum::Form(request): axum::Form<ResolveRequest>, 128 135 ) -> Result<Response, AppError> { 129 136 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 130 - let signer = state.signer.as_ref().ok_or(AppError::LabelerNotConfigured)?; 137 + let signer = state 138 + .signer 139 + .as_ref() 140 + .ok_or(AppError::LabelerNotConfigured)?; 141 + 142 + // Parse the reason 143 + let reason = request 144 + .reason 145 + .as_deref() 146 + .and_then(crate::db::ResolutionReason::from_str); 131 147 132 - tracing::info!(uri = %request.uri, val = %request.val, "resolving flag via htmx"); 148 + tracing::info!( 149 + uri = %request.uri, 150 + val = %request.val, 151 + reason = ?reason, 152 + notes = ?request.notes, 153 + "resolving flag via htmx" 154 + ); 133 155 134 156 // Create a negation label 135 157 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated(); ··· 137 159 138 160 let seq = db.store_label(&label).await?; 139 161 162 + // Store resolution reason in context 163 + if let Some(r) = reason { 164 + db.store_resolution(&request.uri, r, request.notes.as_deref()) 165 + .await?; 166 + } 167 + 140 168 // Broadcast to subscribers 141 169 if let Some(tx) = &state.label_tx { 142 170 let _ = tx.send((seq, label)); 143 171 } 144 172 145 173 // Return success toast + trigger refresh 174 + let reason_label = reason.map(|r| r.label()).unwrap_or("unknown"); 146 175 let html = format!( 147 - r#"<div id="toast" class="toast success" hx-swap-oob="true">resolved (seq: {})</div>"#, 148 - seq 176 + r#"<div id="toast" class="toast success" hx-swap-oob="true">resolved: {} (seq: {})</div>"#, 177 + reason_label, seq 149 178 ); 150 179 151 180 Ok(( 152 - [ 153 - (CONTENT_TYPE, "text/html; charset=utf-8"), 154 - ], 155 - [(axum::http::header::HeaderName::from_static("hx-trigger"), "flagsUpdated")], 181 + [(CONTENT_TYPE, "text/html; charset=utf-8")], 182 + [( 183 + axum::http::header::HeaderName::from_static("hx-trigger"), 184 + "flagsUpdated", 185 + )], 156 186 html, 157 187 ) 158 188 .into_response()) ··· 173 203 artist_did: request.context.artist_did, 174 204 highest_score: request.context.highest_score, 175 205 matches: request.context.matches, 206 + resolution_reason: None, 207 + resolution_notes: None, 176 208 }; 177 209 178 210 db.store_context(&request.uri, &label_ctx).await?; ··· 219 251 /// Render a single flag card as HTML. 220 252 fn render_flag_card(track: &FlaggedTrack) -> String { 221 253 let ctx = track.context.as_ref(); 222 - let has_context = ctx.map_or(false, |c| c.track_title.is_some() || c.artist_handle.is_some()); 254 + let has_context = ctx.is_some_and(|c| c.track_title.is_some() || c.artist_handle.is_some()); 223 255 224 256 let track_info = if has_context { 225 257 let c = ctx.unwrap(); ··· 247 279 let score_badge = ctx 248 280 .and_then(|c| c.highest_score) 249 281 .filter(|&s| s > 0.0) 250 - .map(|s| format!(r#"<span class="badge score">{}% match</span>"#, (s * 100.0) as i32)) 282 + .map(|s| { 283 + format!( 284 + r#"<span class="badge score">{}% match</span>"#, 285 + (s * 100.0) as i32 286 + ) 287 + }) 251 288 .unwrap_or_default(); 252 289 253 290 let status_badge = if track.resolved { ··· 286 323 .unwrap_or_default(); 287 324 288 325 let action_button = if track.resolved { 289 - r#"<button class="btn btn-secondary" disabled>resolved</button>"#.to_string() 326 + // Show the resolution reason if available 327 + let reason_text = ctx 328 + .and_then(|c| c.resolution_reason.as_ref()) 329 + .map(|r| r.label()) 330 + .unwrap_or("resolved"); 331 + let notes_text = ctx 332 + .and_then(|c| c.resolution_notes.as_ref()) 333 + .map(|n| format!(r#" title="{}""#, html_escape(n))) 334 + .unwrap_or_default(); 335 + format!( 336 + r#"<span class="resolution-reason"{}>{}</span>"#, 337 + notes_text, reason_text 338 + ) 290 339 } else { 291 340 format!( 292 - r#"<form hx-post="/admin/resolve-htmx" hx-swap="none" style="display:inline"> 293 - <input type="hidden" name="uri" value="{}"> 294 - <input type="hidden" name="val" value="{}"> 295 - <button type="submit" class="btn btn-warning">mark false positive</button> 296 - </form>"#, 341 + r#"<div class="resolve-dropdown"> 342 + <button type="button" class="btn btn-warning dropdown-toggle" onclick="toggleDropdown(this)"> 343 + mark false positive 344 + </button> 345 + <div class="dropdown-menu"> 346 + <form hx-post="/admin/resolve-htmx" hx-swap="none"> 347 + <input type="hidden" name="uri" value="{}"> 348 + <input type="hidden" name="val" value="{}"> 349 + <button type="submit" name="reason" value="original_artist" class="dropdown-item"> 350 + original artist 351 + <span class="item-desc">artist uploaded their own work</span> 352 + </button> 353 + <button type="submit" name="reason" value="licensed" class="dropdown-item"> 354 + licensed 355 + <span class="item-desc">has rights/permission</span> 356 + </button> 357 + <button type="submit" name="reason" value="fingerprint_noise" class="dropdown-item"> 358 + fingerprint noise 359 + <span class="item-desc">matcher false positive</span> 360 + </button> 361 + <button type="submit" name="reason" value="cover_version" class="dropdown-item"> 362 + cover/remix 363 + <span class="item-desc">legal derivative work</span> 364 + </button> 365 + <button type="submit" name="reason" value="other" class="dropdown-item"> 366 + other 367 + <span class="item-desc">see notes</span> 368 + </button> 369 + </form> 370 + </div> 371 + </div>"#, 297 372 html_escape(&track.uri), 298 373 html_escape(&track.val) 299 374 )
+1 -3
moderation/src/audd.rs
··· 113 113 114 114 info!( 115 115 match_count = matches.len(), 116 - highest_score, 117 - is_flagged, 118 - "scan complete" 116 + highest_score, is_flagged, "scan complete" 119 117 ); 120 118 121 119 Ok(Json(ScanResponse {
+228 -45
moderation/src/db.rs
··· 14 14 Option<String>, 15 15 Option<f64>, 16 16 Option<serde_json::Value>, 17 + Option<String>, 18 + Option<String>, 17 19 ); 18 20 19 21 /// Type alias for flagged track row from database query. ··· 27 29 Option<String>, 28 30 Option<f64>, 29 31 Option<serde_json::Value>, 32 + Option<String>, 33 + Option<String>, 30 34 ); 31 35 32 36 /// Copyright match info stored alongside labels. ··· 37 41 pub score: f64, 38 42 } 39 43 44 + /// Reason for resolving a false positive. 45 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] 46 + #[serde(rename_all = "snake_case")] 47 + pub enum ResolutionReason { 48 + /// Artist uploaded their own distributed music 49 + OriginalArtist, 50 + /// Artist has licensing/permission for the content 51 + Licensed, 52 + /// Fingerprint matcher produced a false match 53 + FingerprintNoise, 54 + /// Legal cover version or remix 55 + CoverVersion, 56 + /// Other reason (see resolution_notes) 57 + Other, 58 + } 59 + 60 + impl ResolutionReason { 61 + /// Human-readable label for the reason. 62 + pub fn label(&self) -> &'static str { 63 + match self { 64 + Self::OriginalArtist => "original artist", 65 + Self::Licensed => "licensed", 66 + Self::FingerprintNoise => "fingerprint noise", 67 + Self::CoverVersion => "cover/remix", 68 + Self::Other => "other", 69 + } 70 + } 71 + 72 + /// Parse from string. 73 + pub fn from_str(s: &str) -> Option<Self> { 74 + match s { 75 + "original_artist" => Some(Self::OriginalArtist), 76 + "licensed" => Some(Self::Licensed), 77 + "fingerprint_noise" => Some(Self::FingerprintNoise), 78 + "cover_version" => Some(Self::CoverVersion), 79 + "other" => Some(Self::Other), 80 + _ => None, 81 + } 82 + } 83 + } 84 + 40 85 /// Context stored alongside a label for display in admin UI. 41 86 #[derive(Debug, Clone, Serialize, Deserialize, Default)] 42 87 pub struct LabelContext { ··· 45 90 pub artist_did: Option<String>, 46 91 pub highest_score: Option<f64>, 47 92 pub matches: Option<Vec<CopyrightMatch>>, 93 + /// Why the flag was resolved as false positive (set on resolution). 94 + #[serde(skip_serializing_if = "Option::is_none")] 95 + pub resolution_reason: Option<ResolutionReason>, 96 + /// Additional notes about the resolution. 97 + #[serde(skip_serializing_if = "Option::is_none")] 98 + pub resolution_notes: Option<String>, 48 99 } 49 100 50 101 /// Database connection pool and operations. ··· 134 185 .execute(&self.pool) 135 186 .await?; 136 187 188 + // Add resolution columns (migration-safe: only adds if missing) 189 + sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_reason TEXT") 190 + .execute(&self.pool) 191 + .await?; 192 + sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT") 193 + .execute(&self.pool) 194 + .await?; 195 + 137 196 Ok(()) 138 197 } 139 198 140 199 /// Store or update label context for a URI. 141 - pub async fn store_context(&self, uri: &str, context: &LabelContext) -> Result<(), sqlx::Error> { 200 + pub async fn store_context( 201 + &self, 202 + uri: &str, 203 + context: &LabelContext, 204 + ) -> Result<(), sqlx::Error> { 142 205 let matches_json = context 143 206 .matches 144 207 .as_ref() 145 208 .map(|m| serde_json::to_value(m).unwrap_or_default()); 209 + let reason_str = context 210 + .resolution_reason 211 + .map(|r| format!("{:?}", r).to_lowercase()); 146 212 147 213 sqlx::query( 148 214 r#" 149 - INSERT INTO label_context (uri, track_title, artist_handle, artist_did, highest_score, matches) 150 - VALUES ($1, $2, $3, $4, $5, $6) 215 + INSERT INTO label_context (uri, track_title, artist_handle, artist_did, highest_score, matches, resolution_reason, resolution_notes) 216 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 151 217 ON CONFLICT (uri) DO UPDATE SET 152 - track_title = EXCLUDED.track_title, 153 - artist_handle = EXCLUDED.artist_handle, 154 - artist_did = EXCLUDED.artist_did, 155 - highest_score = EXCLUDED.highest_score, 156 - matches = EXCLUDED.matches 218 + track_title = COALESCE(EXCLUDED.track_title, label_context.track_title), 219 + artist_handle = COALESCE(EXCLUDED.artist_handle, label_context.artist_handle), 220 + artist_did = COALESCE(EXCLUDED.artist_did, label_context.artist_did), 221 + highest_score = COALESCE(EXCLUDED.highest_score, label_context.highest_score), 222 + matches = COALESCE(EXCLUDED.matches, label_context.matches), 223 + resolution_reason = COALESCE(EXCLUDED.resolution_reason, label_context.resolution_reason), 224 + resolution_notes = COALESCE(EXCLUDED.resolution_notes, label_context.resolution_notes) 157 225 "#, 158 226 ) 159 227 .bind(uri) ··· 162 230 .bind(&context.artist_did) 163 231 .bind(context.highest_score) 164 232 .bind(matches_json) 233 + .bind(reason_str) 234 + .bind(&context.resolution_notes) 235 + .execute(&self.pool) 236 + .await?; 237 + 238 + Ok(()) 239 + } 240 + 241 + /// Store resolution reason for a URI (without overwriting other context). 242 + pub async fn store_resolution( 243 + &self, 244 + uri: &str, 245 + reason: ResolutionReason, 246 + notes: Option<&str>, 247 + ) -> Result<(), sqlx::Error> { 248 + let reason_str = format!("{:?}", reason).to_lowercase(); 249 + sqlx::query( 250 + r#" 251 + INSERT INTO label_context (uri, resolution_reason, resolution_notes) 252 + VALUES ($1, $2, $3) 253 + ON CONFLICT (uri) DO UPDATE SET 254 + resolution_reason = EXCLUDED.resolution_reason, 255 + resolution_notes = EXCLUDED.resolution_notes 256 + "#, 257 + ) 258 + .bind(uri) 259 + .bind(reason_str) 260 + .bind(notes) 165 261 .execute(&self.pool) 166 262 .await?; 167 263 ··· 172 268 pub async fn get_context(&self, uri: &str) -> Result<Option<LabelContext>, sqlx::Error> { 173 269 let row: Option<ContextRow> = sqlx::query_as( 174 270 r#" 175 - SELECT track_title, artist_handle, artist_did, highest_score, matches 271 + SELECT track_title, artist_handle, artist_did, highest_score, matches, resolution_reason, resolution_notes 176 272 FROM label_context 177 273 WHERE uri = $1 178 274 "#, ··· 181 277 .fetch_optional(&self.pool) 182 278 .await?; 183 279 184 - Ok(row.map(|(track_title, artist_handle, artist_did, highest_score, matches)| { 185 - LabelContext { 280 + Ok(row.map( 281 + |( 186 282 track_title, 187 283 artist_handle, 188 284 artist_did, 189 285 highest_score, 190 - matches: matches.and_then(|v| serde_json::from_value(v).ok()), 191 - } 192 - })) 286 + matches, 287 + resolution_reason, 288 + resolution_notes, 289 + )| { 290 + LabelContext { 291 + track_title, 292 + artist_handle, 293 + artist_did, 294 + highest_score, 295 + matches: matches.and_then(|v| serde_json::from_value(v).ok()), 296 + resolution_reason: resolution_reason 297 + .and_then(|s| ResolutionReason::from_str(&s)), 298 + resolution_notes, 299 + } 300 + }, 301 + )) 193 302 } 194 303 195 304 /// Store a signed label and return its sequence number. 196 305 pub async fn store_label(&self, label: &Label) -> Result<i64, sqlx::Error> { 197 306 let sig = label.sig.as_ref().map(|b| b.to_vec()).unwrap_or_default(); 198 - let cts: DateTime<Utc> = label 199 - .cts 200 - .parse() 201 - .unwrap_or_else(|_| Utc::now()); 307 + let cts: DateTime<Utc> = label.cts.parse().unwrap_or_else(|_| Utc::now()); 202 308 let exp: Option<DateTime<Utc>> = label.exp.as_ref().and_then(|e| e.parse().ok()); 203 309 204 310 let row = sqlx::query_scalar::<_, i64>( ··· 328 434 } 329 435 330 436 /// Get labels since a sequence number (for subscribeLabels). 331 - pub async fn get_labels_since(&self, cursor: i64, limit: i64) -> Result<Vec<LabelRow>, sqlx::Error> { 437 + pub async fn get_labels_since( 438 + &self, 439 + cursor: i64, 440 + limit: i64, 441 + ) -> Result<Vec<LabelRow>, sqlx::Error> { 332 442 sqlx::query_as::<_, LabelRow>( 333 443 r#" 334 444 SELECT seq, src, uri, cid, val, neg, cts, exp, sig ··· 358 468 pub async fn get_pending_flags(&self) -> Result<Vec<FlaggedTrack>, sqlx::Error> { 359 469 // Get all copyright-violation labels with context via LEFT JOIN 360 470 let rows: Vec<FlaggedRow> = sqlx::query_as( 361 - r#" 471 + r#" 362 472 SELECT l.seq, l.uri, l.val, l.cts, 363 - c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches 473 + c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches, 474 + c.resolution_reason, c.resolution_notes 364 475 FROM labels l 365 476 LEFT JOIN label_context c ON l.uri = c.uri 366 477 WHERE l.val = 'copyright-violation' AND l.neg = false 367 478 ORDER BY l.seq DESC 368 479 "#, 369 - ) 370 - .fetch_all(&self.pool) 371 - .await?; 480 + ) 481 + .fetch_all(&self.pool) 482 + .await?; 372 483 373 484 // Get all negation labels 374 485 let negated_uris: std::collections::HashSet<String> = sqlx::query_scalar::<_, String>( ··· 385 496 386 497 let tracks = rows 387 498 .into_iter() 388 - .map(|(seq, uri, val, cts, track_title, artist_handle, artist_did, highest_score, matches)| { 389 - let context = if track_title.is_some() || artist_handle.is_some() { 390 - Some(LabelContext { 391 - track_title, 392 - artist_handle, 393 - artist_did, 394 - highest_score, 395 - matches: matches.and_then(|v| serde_json::from_value(v).ok()), 396 - }) 397 - } else { 398 - None 399 - }; 400 - 401 - FlaggedTrack { 499 + .map( 500 + |( 402 501 seq, 403 - uri: uri.clone(), 502 + uri, 404 503 val, 405 - created_at: cts.format("%Y-%m-%d %H:%M:%S").to_string(), 406 - resolved: negated_uris.contains(&uri), 407 - context, 408 - } 409 - }) 504 + cts, 505 + track_title, 506 + artist_handle, 507 + artist_did, 508 + highest_score, 509 + matches, 510 + resolution_reason, 511 + resolution_notes, 512 + )| { 513 + let context = if track_title.is_some() 514 + || artist_handle.is_some() 515 + || resolution_reason.is_some() 516 + { 517 + Some(LabelContext { 518 + track_title, 519 + artist_handle, 520 + artist_did, 521 + highest_score, 522 + matches: matches.and_then(|v| serde_json::from_value(v).ok()), 523 + resolution_reason: resolution_reason 524 + .and_then(|s| ResolutionReason::from_str(&s)), 525 + resolution_notes, 526 + }) 527 + } else { 528 + None 529 + }; 530 + 531 + FlaggedTrack { 532 + seq, 533 + uri: uri.clone(), 534 + val, 535 + created_at: cts.format("%Y-%m-%d %H:%M:%S").to_string(), 536 + resolved: negated_uris.contains(&uri), 537 + context, 538 + } 539 + }, 540 + ) 410 541 .collect(); 411 542 412 543 Ok(tracks) ··· 424 555 val: self.val.clone(), 425 556 neg: if self.neg { Some(true) } else { None }, 426 557 cts: self.cts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), 427 - exp: self.exp.map(|e| e.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()), 558 + exp: self 559 + .exp 560 + .map(|e| e.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()), 428 561 sig: Some(bytes::Bytes::from(self.sig.clone())), 429 562 } 430 563 } 431 564 } 565 + 566 + #[cfg(test)] 567 + mod tests { 568 + use super::*; 569 + 570 + #[test] 571 + fn test_resolution_reason_from_str() { 572 + assert_eq!( 573 + ResolutionReason::from_str("original_artist"), 574 + Some(ResolutionReason::OriginalArtist) 575 + ); 576 + assert_eq!( 577 + ResolutionReason::from_str("licensed"), 578 + Some(ResolutionReason::Licensed) 579 + ); 580 + assert_eq!( 581 + ResolutionReason::from_str("fingerprint_noise"), 582 + Some(ResolutionReason::FingerprintNoise) 583 + ); 584 + assert_eq!( 585 + ResolutionReason::from_str("cover_version"), 586 + Some(ResolutionReason::CoverVersion) 587 + ); 588 + assert_eq!( 589 + ResolutionReason::from_str("other"), 590 + Some(ResolutionReason::Other) 591 + ); 592 + assert_eq!(ResolutionReason::from_str("invalid"), None); 593 + } 594 + 595 + #[test] 596 + fn test_resolution_reason_labels() { 597 + assert_eq!(ResolutionReason::OriginalArtist.label(), "original artist"); 598 + assert_eq!(ResolutionReason::Licensed.label(), "licensed"); 599 + assert_eq!( 600 + ResolutionReason::FingerprintNoise.label(), 601 + "fingerprint noise" 602 + ); 603 + assert_eq!(ResolutionReason::CoverVersion.label(), "cover/remix"); 604 + assert_eq!(ResolutionReason::Other.label(), "other"); 605 + } 606 + 607 + #[test] 608 + fn test_label_context_default() { 609 + let ctx = LabelContext::default(); 610 + assert!(ctx.track_title.is_none()); 611 + assert!(ctx.resolution_reason.is_none()); 612 + assert!(ctx.resolution_notes.is_none()); 613 + } 614 + }
+6 -1
moderation/src/handlers.rs
··· 142 142 Json(request): Json<EmitLabelRequest>, 143 143 ) -> Result<Json<EmitLabelResponse>, AppError> { 144 144 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 145 - let signer = state.signer.as_ref().ok_or(AppError::LabelerNotConfigured)?; 145 + let signer = state 146 + .signer 147 + .as_ref() 148 + .ok_or(AppError::LabelerNotConfigured)?; 146 149 147 150 info!(uri = %request.uri, val = %request.val, neg = request.neg, "emitting label"); 148 151 ··· 168 171 artist_did: ctx.artist_did, 169 172 highest_score: ctx.highest_score, 170 173 matches: ctx.matches, 174 + resolution_reason: None, 175 + resolution_notes: None, 171 176 }; 172 177 if let Err(e) = db.store_context(&request.uri, &label_ctx).await { 173 178 // Log but don't fail - context is supplementary
+5 -2
moderation/src/main.rs
··· 20 20 21 21 mod admin; 22 22 mod audd; 23 + mod auth; 23 24 mod config; 24 25 mod db; 25 26 mod handlers; 26 27 mod labels; 27 - mod auth; 28 28 mod state; 29 29 mod xrpc; 30 30 ··· 86 86 // Static files (CSS, JS for admin UI) 87 87 .nest_service("/static", ServeDir::new("static")) 88 88 // ATProto XRPC endpoints (public) 89 - .route("/xrpc/com.atproto.label.queryLabels", get(xrpc::query_labels)) 89 + .route( 90 + "/xrpc/com.atproto.label.queryLabels", 91 + get(xrpc::query_labels), 92 + ) 90 93 .route( 91 94 "/xrpc/com.atproto.label.subscribeLabels", 92 95 get(xrpc::subscribe_labels),
+68
moderation/static/admin.css
··· 267 267 border-top: 1px solid var(--border-subtle); 268 268 } 269 269 270 + /* resolution reason display */ 271 + .resolution-reason { 272 + color: var(--success); 273 + font-size: 0.85rem; 274 + font-weight: 500; 275 + cursor: help; 276 + } 277 + 278 + /* dropdown for reason selection */ 279 + .resolve-dropdown { 280 + position: relative; 281 + display: inline-block; 282 + } 283 + 284 + .dropdown-toggle::after { 285 + content: ' ▼'; 286 + font-size: 0.7em; 287 + } 288 + 289 + .dropdown-menu { 290 + display: none; 291 + position: absolute; 292 + right: 0; 293 + bottom: 100%; 294 + margin-bottom: 4px; 295 + background: var(--bg-tertiary); 296 + border: 1px solid var(--border-default); 297 + border-radius: 8px; 298 + min-width: 220px; 299 + z-index: 100; 300 + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4); 301 + overflow: hidden; 302 + } 303 + 304 + .resolve-dropdown.open .dropdown-menu { 305 + display: block; 306 + } 307 + 308 + .dropdown-item { 309 + display: flex; 310 + flex-direction: column; 311 + width: 100%; 312 + padding: 12px 16px; 313 + background: transparent; 314 + border: none; 315 + border-bottom: 1px solid var(--border-subtle); 316 + color: var(--text-primary); 317 + font-family: inherit; 318 + font-size: 0.85rem; 319 + text-align: left; 320 + cursor: pointer; 321 + transition: background 0.1s ease; 322 + } 323 + 324 + .dropdown-item:last-child { 325 + border-bottom: none; 326 + } 327 + 328 + .dropdown-item:hover { 329 + background: var(--bg-hover); 330 + } 331 + 332 + .dropdown-item .item-desc { 333 + color: var(--text-muted); 334 + font-size: 0.75rem; 335 + margin-top: 2px; 336 + } 337 + 270 338 /* states */ 271 339 .empty, .loading { 272 340 color: var(--text-muted);
+26
moderation/static/admin.js
··· 47 47 toast.style.display = 'block'; 48 48 setTimeout(() => { toast.style.display = 'none'; }, 3000); 49 49 } 50 + 51 + // Dropdown toggle for reason selection 52 + function toggleDropdown(btn) { 53 + const dropdown = btn.closest('.resolve-dropdown'); 54 + const wasOpen = dropdown.classList.contains('open'); 55 + 56 + // Close all dropdowns first 57 + document.querySelectorAll('.resolve-dropdown.open').forEach(d => d.classList.remove('open')); 58 + 59 + // Toggle this one 60 + if (!wasOpen) { 61 + dropdown.classList.add('open'); 62 + } 63 + } 64 + 65 + // Close dropdowns when clicking outside 66 + document.addEventListener('click', function(evt) { 67 + if (!evt.target.closest('.resolve-dropdown')) { 68 + document.querySelectorAll('.resolve-dropdown.open').forEach(d => d.classList.remove('open')); 69 + } 70 + }); 71 + 72 + // Close dropdowns after form submission 73 + document.body.addEventListener('htmx:afterRequest', function(evt) { 74 + document.querySelectorAll('.resolve-dropdown.open').forEach(d => d.classList.remove('open')); 75 + });
-25
podcast_script.txt
··· 1 - Host: Welcome to the plyr.fm update for early December 2025. Just shipped some major copyright moderation infrastructure this past week. 2 - 3 - Cohost: Yeah, this is a big one. We integrated an ATProto labeler service into the Rust-based moderation system. Think of it like a referee for the music sharing platform - when copyright violations get detected, proper labels get emitted that other ATProto apps can subscribe to. 4 - 5 - Host: The key piece is the admin UI that went live at the end of November. It's built with htmx for server-rendered interactivity, no JavaScript bloat. Admins can review copyright flags, see what tracks got matched against the AuDD fingerprinting API, and mark false positives with a single button click. 6 - 7 - Cohost: And those false positive resolutions emit negation labels, which is a nice touch. The system already reviewed 25 flagged tracks - found 8 actual violations, 11 false positives from fingerprint noise, and 6 cases where artists uploaded their own distributed music. 8 - 9 - Host: There's also some UX polish worth mentioning. The artist portal now shows a copyright flag indicator on flagged tracks with a tooltip showing the primary match. So artists can see immediately if something got flagged and why. 10 - 11 - Cohost: Late November also brought platform stats to the homepage - total plays, tracks, and artists displayed right in the header. Small detail, but it gives visitors an immediate sense of activity. 12 - 13 - Host: The Media Session API integration is pretty slick too. Your plyr.fm tracks now show up in CarPlay, lock screens, and system control centers with full metadata and artwork. Play, pause, skip - all works from the system UI. 14 - 15 - Cohost: And timed comments shipped. You can drop a comment at a specific timestamp in a track, and clicking that timestamp seeks right to that moment. Makes discussing specific parts of a song much more natural. 16 - 17 - Host: There's also developer token functionality with independent OAuth grants. Each token gets its own OAuth flow, separate from your browser session. So you can script uploads or deletions without worrying about your browser logout invalidating the token. 18 - 19 - Cohost: Looking back, the project started in November 2025, so we're about a month in. The copyright moderation system was the big push this past week, but earlier in November there was work on export reliability, oEmbed support for embeds, and a lot of mobile UI polish. 20 - 21 - Host: The oEmbed endpoint lets plyr.fm tracks embed properly in Leaflet.pub and other services. Before that, you'd just see a basic HTML5 audio element instead of the custom player. 22 - 23 - Cohost: Database-backed jobs fixed export failures on large files. Used to hit out-of-memory errors on 90-minute WAV files, but now exports stream to disk with constant memory usage. 24 - 25 - Host: That's the snapshot for early December. Copyright moderation infrastructure is live, platform stats are visible, and timed comments let people discuss specific moments in tracks.