music on atproto
plyr.fm
1//! Review endpoints for batch flag review.
2//!
3//! These endpoints are behind the same auth as admin endpoints.
4
5use axum::{
6 extract::{Path, State},
7 http::header::CONTENT_TYPE,
8 response::{IntoResponse, Response},
9 Json,
10};
11use serde::{Deserialize, Serialize};
12
13use crate::admin::FlaggedTrack;
14use crate::state::{AppError, AppState};
15
16/// Response for review page data.
17#[derive(Debug, Serialize)]
18pub struct ReviewPageData {
19 pub batch_id: String,
20 pub flags: Vec<FlaggedTrack>,
21 pub status: String,
22}
23
24/// Request to submit review decisions.
25#[derive(Debug, Deserialize)]
26pub struct SubmitReviewRequest {
27 pub decisions: Vec<ReviewDecision>,
28}
29
30/// A single review decision.
31#[derive(Debug, Deserialize)]
32pub struct ReviewDecision {
33 pub uri: String,
34 /// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation)
35 pub decision: String,
36}
37
38/// Response after submitting review.
39#[derive(Debug, Serialize)]
40pub struct SubmitReviewResponse {
41 pub resolved_count: usize,
42 pub message: String,
43}
44
45/// Get review page HTML.
46pub async fn review_page(
47 State(state): State<AppState>,
48 Path(batch_id): Path<String>,
49) -> Result<Response, AppError> {
50 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
51
52 let batch = db
53 .get_batch(&batch_id)
54 .await?
55 .ok_or(AppError::NotFound("batch not found".to_string()))?;
56
57 let flags = db.get_batch_flags(&batch_id).await?;
58 let html = render_review_page(&batch_id, &flags, &batch.status);
59
60 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response())
61}
62
63/// Get review data as JSON.
64pub async fn review_data(
65 State(state): State<AppState>,
66 Path(batch_id): Path<String>,
67) -> Result<Json<ReviewPageData>, AppError> {
68 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
69
70 let batch = db
71 .get_batch(&batch_id)
72 .await?
73 .ok_or(AppError::NotFound("batch not found".to_string()))?;
74
75 let flags = db.get_batch_flags(&batch_id).await?;
76
77 Ok(Json(ReviewPageData {
78 batch_id,
79 flags,
80 status: batch.status,
81 }))
82}
83
84/// Submit review decisions.
85pub async fn submit_review(
86 State(state): State<AppState>,
87 Path(batch_id): Path<String>,
88 Json(request): Json<SubmitReviewRequest>,
89) -> Result<Json<SubmitReviewResponse>, AppError> {
90 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
91 let signer = state
92 .signer
93 .as_ref()
94 .ok_or(AppError::LabelerNotConfigured)?;
95
96 let _batch = db
97 .get_batch(&batch_id)
98 .await?
99 .ok_or(AppError::NotFound("batch not found".to_string()))?;
100
101 let mut resolved_count = 0;
102
103 for decision in &request.decisions {
104 tracing::info!(
105 batch_id = %batch_id,
106 uri = %decision.uri,
107 decision = %decision.decision,
108 "processing review decision"
109 );
110
111 db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision)
112 .await?;
113
114 match decision.decision.as_str() {
115 "clear" => {
116 // False positive - emit negation label to clear the flag
117 let label =
118 crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation")
119 .negated();
120 let label = signer.sign_label(label)?;
121 let seq = db.store_label(&label).await?;
122
123 db.store_resolution(
124 &decision.uri,
125 crate::db::ResolutionReason::FingerprintNoise,
126 Some("batch review: cleared"),
127 )
128 .await?;
129
130 if let Some(tx) = &state.label_tx {
131 let _ = tx.send((seq, label));
132 }
133
134 resolved_count += 1;
135 }
136 "defer" => {
137 // Acknowledge but take no action - flag stays active
138 // Just mark as reviewed in the batch, no label changes
139 tracing::info!(uri = %decision.uri, "deferred - no action taken");
140 }
141 "confirm" => {
142 // Real violation - flag stays active, could add enforcement later
143 tracing::info!(uri = %decision.uri, "confirmed as violation");
144 }
145 _ => {
146 tracing::warn!(uri = %decision.uri, decision = %decision.decision, "unknown decision type");
147 }
148 }
149 }
150
151 let pending = db.get_batch_pending_uris(&batch_id).await?;
152 if pending.is_empty() {
153 db.update_batch_status(&batch_id, "completed").await?;
154 }
155
156 Ok(Json(SubmitReviewResponse {
157 resolved_count,
158 message: format!(
159 "processed {} decisions, resolved {} flags",
160 request.decisions.len(),
161 resolved_count
162 ),
163 }))
164}
165
166/// Render the review page.
167fn render_review_page(batch_id: &str, flags: &[FlaggedTrack], status: &str) -> String {
168 let pending: Vec<_> = flags.iter().filter(|f| !f.resolved).collect();
169 let resolved: Vec<_> = flags.iter().filter(|f| f.resolved).collect();
170
171 let pending_cards: Vec<String> = pending.iter().map(|f| render_review_card(f)).collect();
172 let resolved_cards: Vec<String> = resolved.iter().map(|f| render_review_card(f)).collect();
173
174 let pending_html = if pending_cards.is_empty() {
175 "<div class=\"empty\">all flags reviewed!</div>".to_string()
176 } else {
177 pending_cards.join("\n")
178 };
179
180 let resolved_html = if resolved_cards.is_empty() {
181 String::new()
182 } else {
183 format!(
184 r#"<details class="resolved-section">
185 <summary>{} resolved</summary>
186 {}
187 </details>"#,
188 resolved_cards.len(),
189 resolved_cards.join("\n")
190 )
191 };
192
193 let status_badge = if status == "completed" {
194 r#"<span class="badge resolved">completed</span>"#
195 } else {
196 ""
197 };
198
199 format!(
200 r#"<!DOCTYPE html>
201<html lang="en">
202<head>
203 <meta charset="utf-8">
204 <meta name="viewport" content="width=device-width, initial-scale=1">
205 <title>review batch - plyr.fm</title>
206 <link rel="stylesheet" href="/static/admin.css">
207 <style>{}</style>
208</head>
209<body>
210 <h1>plyr.fm moderation</h1>
211 <p class="subtitle">
212 <a href="/admin">← back to dashboard</a>
213 <span style="margin: 0 12px; color: var(--text-muted);">|</span>
214 batch review: {} pending {}
215 </p>
216
217 <div class="auth-section" id="auth-section">
218 <input type="password" id="auth-token" placeholder="auth token"
219 onkeyup="if(event.key==='Enter')authenticate()">
220 <button class="btn btn-primary" onclick="authenticate()">authenticate</button>
221 </div>
222
223 <form id="review-form" style="display: none;">
224 <div class="flags-list">
225 {}
226 </div>
227
228 {}
229
230 <div class="submit-bar">
231 <button type="submit" class="btn btn-primary" id="submit-btn" disabled>
232 submit decisions
233 </button>
234 </div>
235 </form>
236
237 <script>
238 const form = document.getElementById('review-form');
239 const submitBtn = document.getElementById('submit-btn');
240 const authSection = document.getElementById('auth-section');
241 const batchId = '{}';
242
243 let currentToken = '';
244 const decisions = {{}};
245
246 function authenticate() {{
247 const token = document.getElementById('auth-token').value;
248 if (token && token !== '••••••••') {{
249 localStorage.setItem('mod_token', token);
250 currentToken = token;
251 showReviewForm();
252 }}
253 }}
254
255 function showReviewForm() {{
256 authSection.style.display = 'none';
257 form.style.display = 'block';
258 }}
259
260 // Check for saved token on load
261 const savedToken = localStorage.getItem('mod_token');
262 if (savedToken) {{
263 currentToken = savedToken;
264 document.getElementById('auth-token').value = '••••••••';
265 showReviewForm();
266 }}
267
268 function updateSubmitBtn() {{
269 const count = Object.keys(decisions).length;
270 submitBtn.disabled = count === 0;
271 submitBtn.textContent = count > 0 ? `submit ${{count}} decision${{count > 1 ? 's' : ''}}` : 'submit decisions';
272 }}
273
274 function setDecision(uri, decision) {{
275 // Toggle off if clicking the same decision
276 if (decisions[uri] === decision) {{
277 delete decisions[uri];
278 const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
279 if (card) card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm');
280 }} else {{
281 decisions[uri] = decision;
282 const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
283 if (card) {{
284 card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm');
285 card.classList.add('decision-' + decision);
286 }}
287 }}
288 updateSubmitBtn();
289 }}
290
291 form.addEventListener('submit', async (e) => {{
292 e.preventDefault();
293 submitBtn.disabled = true;
294 submitBtn.textContent = 'submitting...';
295
296 try {{
297 const response = await fetch(`/admin/review/${{batchId}}/submit`, {{
298 method: 'POST',
299 headers: {{
300 'Content-Type': 'application/json',
301 'X-Moderation-Key': currentToken
302 }},
303 body: JSON.stringify({{
304 decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }}))
305 }})
306 }});
307
308 if (response.status === 401) {{
309 localStorage.removeItem('mod_token');
310 currentToken = '';
311 authSection.style.display = 'block';
312 form.style.display = 'none';
313 document.getElementById('auth-token').value = '';
314 alert('invalid token');
315 return;
316 }}
317
318 if (response.ok) {{
319 const result = await response.json();
320 alert(result.message);
321 location.reload();
322 }} else {{
323 const err = await response.json();
324 alert('error: ' + (err.message || 'unknown error'));
325 submitBtn.disabled = false;
326 updateSubmitBtn();
327 }}
328 }} catch (err) {{
329 alert('network error: ' + err.message);
330 submitBtn.disabled = false;
331 updateSubmitBtn();
332 }}
333 }});
334 </script>
335</body>
336</html>"#,
337 REVIEW_CSS,
338 pending.len(),
339 status_badge,
340 pending_html,
341 resolved_html,
342 html_escape(batch_id)
343 )
344}
345
346/// Render a single review card.
347fn render_review_card(track: &FlaggedTrack) -> String {
348 let ctx = track.context.as_ref();
349
350 let title = ctx
351 .and_then(|c| c.track_title.as_deref())
352 .unwrap_or("unknown track");
353 let artist = ctx
354 .and_then(|c| c.artist_handle.as_deref())
355 .unwrap_or("unknown");
356 let track_id = ctx.and_then(|c| c.track_id);
357
358 let title_html = if let Some(id) = track_id {
359 format!(
360 r#"<a href="https://plyr.fm/track/{}" target="_blank">{}</a>"#,
361 id,
362 html_escape(title)
363 )
364 } else {
365 html_escape(title)
366 };
367
368 let matches_html = ctx
369 .and_then(|c| c.matches.as_ref())
370 .filter(|m| !m.is_empty())
371 .map(|matches| {
372 let items: Vec<String> = matches
373 .iter()
374 .take(3)
375 .map(|m| {
376 format!(
377 r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#,
378 html_escape(&m.title),
379 html_escape(&m.artist)
380 )
381 })
382 .collect();
383 format!(
384 r#"<div class="matches"><h4>potential matches</h4>{}</div>"#,
385 items.join("\n")
386 )
387 })
388 .unwrap_or_default();
389
390 let resolved_badge = if track.resolved {
391 r#"<span class="badge resolved">resolved</span>"#
392 } else {
393 r#"<span class="badge pending">pending</span>"#
394 };
395
396 let action_buttons = if !track.resolved {
397 format!(
398 r#"<div class="flag-actions">
399 <button type="button" class="btn btn-clear" onclick="setDecision('{}', 'clear')">clear</button>
400 <button type="button" class="btn btn-defer" onclick="setDecision('{}', 'defer')">defer</button>
401 <button type="button" class="btn btn-confirm" onclick="setDecision('{}', 'confirm')">confirm</button>
402 </div>"#,
403 html_escape(&track.uri),
404 html_escape(&track.uri),
405 html_escape(&track.uri)
406 )
407 } else {
408 String::new()
409 };
410
411 format!(
412 r#"<div class="flag-card{}" data-uri="{}">
413 <div class="flag-header">
414 <div class="track-info">
415 <h3>{}</h3>
416 <div class="artist">@{}</div>
417 </div>
418 <div class="flag-badges">
419 {}
420 </div>
421 </div>
422 {}
423 {}
424 </div>"#,
425 if track.resolved { " resolved" } else { "" },
426 html_escape(&track.uri),
427 title_html,
428 html_escape(artist),
429 resolved_badge,
430 matches_html,
431 action_buttons
432 )
433}
434
435fn html_escape(s: &str) -> String {
436 s.replace('&', "&")
437 .replace('<', "<")
438 .replace('>', ">")
439 .replace('"', """)
440 .replace('\'', "'")
441}
442
443/// Additional CSS for review page (supplements admin.css)
444const REVIEW_CSS: &str = r#"
445/* review page specific styles */
446body { padding-bottom: 80px; }
447
448.subtitle a {
449 color: var(--accent);
450 text-decoration: none;
451}
452.subtitle a:hover { text-decoration: underline; }
453
454/* action buttons */
455.btn-clear {
456 background: rgba(74, 222, 128, 0.15);
457 color: var(--success);
458 border: 1px solid rgba(74, 222, 128, 0.3);
459}
460.btn-clear:hover {
461 background: rgba(74, 222, 128, 0.25);
462}
463
464.btn-defer {
465 background: rgba(251, 191, 36, 0.15);
466 color: var(--warning);
467 border: 1px solid rgba(251, 191, 36, 0.3);
468}
469.btn-defer:hover {
470 background: rgba(251, 191, 36, 0.25);
471}
472
473.btn-confirm {
474 background: rgba(239, 68, 68, 0.15);
475 color: var(--error);
476 border: 1px solid rgba(239, 68, 68, 0.3);
477}
478.btn-confirm:hover {
479 background: rgba(239, 68, 68, 0.25);
480}
481
482/* card selection states */
483.flag-card.decision-clear {
484 border-color: var(--success);
485 background: rgba(74, 222, 128, 0.05);
486}
487.flag-card.decision-defer {
488 border-color: var(--warning);
489 background: rgba(251, 191, 36, 0.05);
490}
491.flag-card.decision-confirm {
492 border-color: var(--error);
493 background: rgba(239, 68, 68, 0.05);
494}
495
496/* submit bar */
497.submit-bar {
498 position: fixed;
499 bottom: 0;
500 left: 0;
501 right: 0;
502 padding: 16px 24px;
503 background: var(--bg-secondary);
504 border-top: 1px solid var(--border-subtle);
505}
506.submit-bar .btn {
507 width: 100%;
508 max-width: 900px;
509 margin: 0 auto;
510 display: block;
511 padding: 14px;
512}
513
514/* resolved section */
515.resolved-section {
516 margin-top: 24px;
517 padding-top: 16px;
518 border-top: 1px solid var(--border-subtle);
519}
520.resolved-section summary {
521 cursor: pointer;
522 color: var(--text-tertiary);
523 font-size: 0.85rem;
524 margin-bottom: 12px;
525}
526"#;