music on atproto
plyr.fm
1//! Admin API for reviewing and resolving copyright flags.
2//!
3//! Uses htmx for interactivity with server-rendered HTML.
4
5use axum::{
6 extract::{Query, State},
7 http::header::CONTENT_TYPE,
8 response::{IntoResponse, Response},
9 Json,
10};
11use serde::{Deserialize, Serialize};
12
13use crate::db::LabelContext;
14use crate::state::{AppError, AppState};
15
16/// A flagged track pending review.
17#[derive(Debug, Serialize)]
18pub struct FlaggedTrack {
19 pub seq: i64,
20 pub uri: String,
21 pub val: String,
22 pub created_at: String,
23 /// Track status: pending review, resolved (false positive), or confirmed (takedown).
24 pub resolved: bool,
25 /// Optional context about the track (title, artist, matches).
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub context: Option<LabelContext>,
28}
29
30/// Response for listing flagged tracks.
31#[derive(Debug, Serialize)]
32pub struct ListFlaggedResponse {
33 pub tracks: Vec<FlaggedTrack>,
34}
35
36/// Query parameters for listing flags.
37#[derive(Debug, Deserialize, Default)]
38pub struct ListFlagsQuery {
39 /// Filter: "pending" (default), "resolved", or "all"
40 #[serde(default = "default_filter")]
41 pub filter: String,
42}
43
44fn default_filter() -> String {
45 "pending".to_string()
46}
47
48/// Request to resolve (negate) a flag.
49#[derive(Debug, Deserialize)]
50pub struct ResolveRequest {
51 pub uri: String,
52 #[serde(default = "default_val")]
53 pub val: String,
54 /// Reason for marking as false positive.
55 pub reason: Option<String>,
56 /// Additional notes about the resolution.
57 pub notes: Option<String>,
58}
59
60fn default_val() -> String {
61 "copyright-violation".to_string()
62}
63
64/// Response after resolving a flag.
65#[derive(Debug, Serialize)]
66pub struct ResolveResponse {
67 pub seq: i64,
68 pub message: String,
69}
70
71/// Request to store label context (for backfill).
72#[derive(Debug, Deserialize)]
73pub struct StoreContextRequest {
74 pub uri: String,
75 pub context: ContextPayload,
76}
77
78/// Context payload for storage.
79#[derive(Debug, Deserialize)]
80pub struct ContextPayload {
81 pub track_id: Option<i64>,
82 pub track_title: Option<String>,
83 pub artist_handle: Option<String>,
84 pub artist_did: Option<String>,
85 pub highest_score: Option<f64>,
86 pub matches: Option<Vec<crate::db::CopyrightMatch>>,
87}
88
89/// Response after storing context.
90#[derive(Debug, Serialize)]
91pub struct StoreContextResponse {
92 pub message: String,
93}
94
95/// Request to check which URIs have active labels.
96#[derive(Debug, Deserialize)]
97pub struct ActiveLabelsRequest {
98 pub uris: Vec<String>,
99}
100
101/// Response with active (non-negated) URIs.
102#[derive(Debug, Serialize)]
103pub struct ActiveLabelsResponse {
104 pub active_uris: Vec<String>,
105}
106
107/// Request to add a sensitive image.
108#[derive(Debug, Deserialize)]
109pub struct AddSensitiveImageRequest {
110 /// R2 storage ID (for track/album artwork)
111 pub image_id: Option<String>,
112 /// Full URL (for external images like avatars)
113 pub url: Option<String>,
114 /// Why this image was flagged
115 pub reason: Option<String>,
116 /// Admin who flagged it
117 pub flagged_by: Option<String>,
118}
119
120/// Response after adding a sensitive image.
121#[derive(Debug, Serialize)]
122pub struct AddSensitiveImageResponse {
123 pub id: i64,
124 pub message: String,
125}
126
127/// Request to remove a sensitive image.
128#[derive(Debug, Deserialize)]
129pub struct RemoveSensitiveImageRequest {
130 pub id: i64,
131}
132
133/// Response after removing a sensitive image.
134#[derive(Debug, Serialize)]
135pub struct RemoveSensitiveImageResponse {
136 pub removed: bool,
137 pub message: String,
138}
139
140/// Request to create a review batch.
141#[derive(Debug, Deserialize)]
142pub struct CreateBatchRequest {
143 /// URIs to include. If empty, uses all pending flags.
144 #[serde(default)]
145 pub uris: Vec<String>,
146 /// Who created this batch.
147 pub created_by: Option<String>,
148}
149
150/// Response after creating a review batch.
151#[derive(Debug, Serialize)]
152pub struct CreateBatchResponse {
153 pub id: String,
154 pub url: String,
155 pub flag_count: usize,
156}
157
158/// List all flagged tracks - returns JSON for API, HTML for htmx.
159pub async fn list_flagged(
160 State(state): State<AppState>,
161 Query(query): Query<ListFlagsQuery>,
162) -> Result<Json<ListFlaggedResponse>, AppError> {
163 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
164 let all_tracks = db.get_pending_flags().await?;
165 let tracks = filter_tracks(all_tracks, &query.filter);
166 Ok(Json(ListFlaggedResponse { tracks }))
167}
168
169/// Render flags as HTML partial for htmx.
170pub async fn list_flagged_html(
171 State(state): State<AppState>,
172 Query(query): Query<ListFlagsQuery>,
173) -> Result<Response, AppError> {
174 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
175 let all_tracks = db.get_pending_flags().await?;
176 let tracks = filter_tracks(all_tracks, &query.filter);
177
178 let html = render_flags_list(&tracks, &query.filter);
179
180 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response())
181}
182
183/// Filter tracks based on filter parameter.
184fn filter_tracks(tracks: Vec<FlaggedTrack>, filter: &str) -> Vec<FlaggedTrack> {
185 match filter {
186 "resolved" => tracks.into_iter().filter(|t| t.resolved).collect(),
187 "all" => tracks,
188 _ => tracks.into_iter().filter(|t| !t.resolved).collect(), // "pending" is default
189 }
190}
191
192/// Resolve (negate) a copyright flag, marking it as a false positive.
193pub async fn resolve_flag(
194 State(state): State<AppState>,
195 Json(request): Json<ResolveRequest>,
196) -> Result<Json<ResolveResponse>, AppError> {
197 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
198 let signer = state
199 .signer
200 .as_ref()
201 .ok_or(AppError::LabelerNotConfigured)?;
202
203 // Parse the reason
204 let reason = request
205 .reason
206 .as_deref()
207 .and_then(crate::db::ResolutionReason::from_str);
208
209 tracing::info!(
210 uri = %request.uri,
211 val = %request.val,
212 reason = ?reason,
213 notes = ?request.notes,
214 "resolving flag (creating negation)"
215 );
216
217 // Create a negation label
218 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated();
219 let label = signer.sign_label(label)?;
220
221 let seq = db.store_label(&label).await?;
222
223 // Store resolution reason in context
224 if let Some(r) = reason {
225 db.store_resolution(&request.uri, r, request.notes.as_deref())
226 .await?;
227 }
228
229 // Broadcast to subscribers
230 if let Some(tx) = &state.label_tx {
231 let _ = tx.send((seq, label));
232 }
233
234 Ok(Json(ResolveResponse {
235 seq,
236 message: format!("created negation label for {}", request.uri),
237 }))
238}
239
240/// Resolve flag and return HTML response for htmx.
241pub async fn resolve_flag_htmx(
242 State(state): State<AppState>,
243 axum::Form(request): axum::Form<ResolveRequest>,
244) -> Result<Response, AppError> {
245 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
246 let signer = state
247 .signer
248 .as_ref()
249 .ok_or(AppError::LabelerNotConfigured)?;
250
251 // Parse the reason
252 let reason = request
253 .reason
254 .as_deref()
255 .and_then(crate::db::ResolutionReason::from_str);
256
257 tracing::info!(
258 uri = %request.uri,
259 val = %request.val,
260 reason = ?reason,
261 notes = ?request.notes,
262 "resolving flag via htmx"
263 );
264
265 // Create a negation label
266 let label = crate::labels::Label::new(signer.did(), &request.uri, &request.val).negated();
267 let label = signer.sign_label(label)?;
268
269 let seq = db.store_label(&label).await?;
270
271 // Store resolution reason in context
272 if let Some(r) = reason {
273 db.store_resolution(&request.uri, r, request.notes.as_deref())
274 .await?;
275 }
276
277 // Broadcast to subscribers
278 if let Some(tx) = &state.label_tx {
279 let _ = tx.send((seq, label));
280 }
281
282 // Return success toast + trigger refresh
283 let reason_label = reason.map(|r| r.label()).unwrap_or("unknown");
284 let html = format!(
285 r#"<div id="toast" class="toast success" hx-swap-oob="true">resolved: {} (seq: {})</div>"#,
286 reason_label, seq
287 );
288
289 Ok((
290 [(CONTENT_TYPE, "text/html; charset=utf-8")],
291 [(
292 axum::http::header::HeaderName::from_static("hx-trigger"),
293 "flagsUpdated",
294 )],
295 html,
296 )
297 .into_response())
298}
299
300/// Get which URIs have active (non-negated) copyright-violation labels.
301///
302/// Used by the backend to determine which tracks are still flagged.
303pub async fn get_active_labels(
304 State(state): State<AppState>,
305 Json(request): Json<ActiveLabelsRequest>,
306) -> Result<Json<ActiveLabelsResponse>, AppError> {
307 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
308
309 tracing::debug!(uri_count = request.uris.len(), "checking active labels");
310
311 let active_uris = db.get_active_labels(&request.uris).await?;
312
313 tracing::debug!(
314 active_count = active_uris.len(),
315 "returning active labels"
316 );
317
318 Ok(Json(ActiveLabelsResponse { active_uris }))
319}
320
321/// Store context for a label (for backfill without re-emitting labels).
322pub async fn store_context(
323 State(state): State<AppState>,
324 Json(request): Json<StoreContextRequest>,
325) -> Result<Json<StoreContextResponse>, AppError> {
326 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
327
328 tracing::info!(uri = %request.uri, "storing label context");
329
330 let label_ctx = LabelContext {
331 track_id: request.context.track_id,
332 track_title: request.context.track_title,
333 artist_handle: request.context.artist_handle,
334 artist_did: request.context.artist_did,
335 highest_score: request.context.highest_score,
336 matches: request.context.matches,
337 resolution_reason: None,
338 resolution_notes: None,
339 };
340
341 db.store_context(&request.uri, &label_ctx).await?;
342
343 Ok(Json(StoreContextResponse {
344 message: format!("context stored for {}", request.uri),
345 }))
346}
347
348/// Create a review batch from pending flags.
349pub async fn create_batch(
350 State(state): State<AppState>,
351 Json(request): Json<CreateBatchRequest>,
352) -> Result<Json<CreateBatchResponse>, AppError> {
353 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
354
355 // Get URIs to include
356 let uris = if request.uris.is_empty() {
357 let pending = db.get_pending_flags().await?;
358 pending
359 .into_iter()
360 .filter(|t| !t.resolved)
361 .map(|t| t.uri)
362 .collect()
363 } else {
364 request.uris
365 };
366
367 if uris.is_empty() {
368 return Err(AppError::BadRequest("no flags to review".to_string()));
369 }
370
371 let id = generate_batch_id();
372 let flag_count = uris.len();
373
374 tracing::info!(
375 batch_id = %id,
376 flag_count = flag_count,
377 "creating review batch"
378 );
379
380 db.create_batch(&id, &uris, request.created_by.as_deref())
381 .await?;
382
383 let url = format!("/admin/review/{}", id);
384
385 Ok(Json(CreateBatchResponse { id, url, flag_count }))
386}
387
388/// Generate a short, URL-safe batch ID.
389fn generate_batch_id() -> String {
390 use std::time::{SystemTime, UNIX_EPOCH};
391 let now = SystemTime::now()
392 .duration_since(UNIX_EPOCH)
393 .unwrap()
394 .as_millis();
395 let rand_part: u32 = rand::random();
396 format!("{:x}{:x}", (now as u64) & 0xFFFFFFFF, rand_part & 0xFFFF)
397}
398
399/// Add a sensitive image entry.
400pub async fn add_sensitive_image(
401 State(state): State<AppState>,
402 Json(request): Json<AddSensitiveImageRequest>,
403) -> Result<Json<AddSensitiveImageResponse>, AppError> {
404 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
405
406 // Validate: at least one of image_id or url must be provided
407 if request.image_id.is_none() && request.url.is_none() {
408 return Err(AppError::BadRequest(
409 "at least one of image_id or url must be provided".to_string(),
410 ));
411 }
412
413 tracing::info!(
414 image_id = ?request.image_id,
415 url = ?request.url,
416 reason = ?request.reason,
417 flagged_by = ?request.flagged_by,
418 "adding sensitive image"
419 );
420
421 let id = db
422 .add_sensitive_image(
423 request.image_id.as_deref(),
424 request.url.as_deref(),
425 request.reason.as_deref(),
426 request.flagged_by.as_deref(),
427 )
428 .await?;
429
430 Ok(Json(AddSensitiveImageResponse {
431 id,
432 message: "sensitive image added".to_string(),
433 }))
434}
435
436/// Remove a sensitive image entry.
437pub async fn remove_sensitive_image(
438 State(state): State<AppState>,
439 Json(request): Json<RemoveSensitiveImageRequest>,
440) -> Result<Json<RemoveSensitiveImageResponse>, AppError> {
441 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
442
443 tracing::info!(id = request.id, "removing sensitive image");
444
445 let removed = db.remove_sensitive_image(request.id).await?;
446
447 let message = if removed {
448 format!("sensitive image {} removed", request.id)
449 } else {
450 format!("sensitive image {} not found", request.id)
451 };
452
453 Ok(Json(RemoveSensitiveImageResponse { removed, message }))
454}
455
456/// Serve the admin UI HTML from static file.
457pub async fn admin_ui() -> Result<Response, AppError> {
458 let html = tokio::fs::read_to_string("static/admin.html").await?;
459 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response())
460}
461
462/// Render the flags list as HTML with filter controls.
463fn render_flags_list(tracks: &[FlaggedTrack], current_filter: &str) -> String {
464 let pending_active = if current_filter == "pending" { " active" } else { "" };
465 let resolved_active = if current_filter == "resolved" { " active" } else { "" };
466 let all_active = if current_filter == "all" { " active" } else { "" };
467
468 let count = tracks.len();
469 let count_label = match current_filter {
470 "pending" => format!("{} pending", count),
471 "resolved" => format!("{} resolved", count),
472 _ => format!("{} total", count),
473 };
474
475 let filter_buttons = format!(
476 "<div class=\"filter-row\">\
477 <span class=\"filter-label\">show:</span>\
478 <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\
479 <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\
480 <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=all\" hx-target=\"#flags-list\">all</button>\
481 <span class=\"filter-count\">{}</span>\
482 </div>",
483 pending_active,
484 resolved_active,
485 all_active,
486 count_label,
487 );
488
489 if tracks.is_empty() {
490 let empty_msg = match current_filter {
491 "pending" => "no pending flags",
492 "resolved" => "no resolved flags",
493 _ => "no flagged tracks",
494 };
495 return format!(
496 "{}<div class=\"empty\">{}</div>",
497 filter_buttons, empty_msg
498 );
499 }
500
501 let cards: Vec<String> = tracks.iter().map(render_flag_card).collect();
502 format!("{}\n{}", filter_buttons, cards.join("\n"))
503}
504
505/// Extract namespace from AT URI (e.g., "fm.plyr.dev" from "at://did:plc:xxx/fm.plyr.dev.track/yyy")
506fn extract_namespace(uri: &str) -> Option<&str> {
507 // URI format: at://did:plc:xxx/fm.plyr[.env].track/rkey
508 let collection = uri.split('/').nth(3)?;
509 // Strip ".track" suffix to get namespace
510 collection.strip_suffix(".track")
511}
512
513/// Determine environment from namespace
514fn namespace_to_env(namespace: &str) -> Option<(&'static str, &'static str)> {
515 match namespace {
516 "fm.plyr" => None, // production - no badge needed
517 "fm.plyr.stg" => Some(("staging", "stg")),
518 "fm.plyr.dev" => Some(("development", "dev")),
519 _ => Some(("unknown", "?")),
520 }
521}
522
523/// Render a single flag card as HTML.
524fn render_flag_card(track: &FlaggedTrack) -> String {
525 let ctx = track.context.as_ref();
526 let has_context = ctx.is_some_and(|c| c.track_title.is_some() || c.artist_handle.is_some());
527
528 let track_info = if has_context {
529 let c = ctx.unwrap();
530 let handle = c.artist_handle.as_deref().unwrap_or("unknown");
531 let title = c.track_title.as_deref().unwrap_or("unknown track");
532
533 // Link to track if we have track_id
534 let title_html = if let Some(track_id) = c.track_id {
535 format!(
536 r#"<a href="https://plyr.fm/track/{}" target="_blank" rel="noopener">{}</a>"#,
537 track_id,
538 html_escape(title)
539 )
540 } else {
541 html_escape(title)
542 };
543
544 // Link to artist if we have handle
545 let artist_link = if handle != "unknown" {
546 format!(
547 r#"<a href="https://plyr.fm/u/{}" target="_blank" rel="noopener">@{}</a>"#,
548 html_escape(handle),
549 html_escape(handle)
550 )
551 } else {
552 format!("@{}", html_escape(handle))
553 };
554 format!(
555 r#"<h3>{}</h3>
556 <div class="artist">by {}</div>"#,
557 title_html,
558 artist_link
559 )
560 } else {
561 r#"<div class="no-context">no track info available</div>"#.to_string()
562 };
563
564 // Add environment badge for non-production namespaces
565 let env_badge = extract_namespace(&track.uri)
566 .and_then(namespace_to_env)
567 .map(|(label, short)| {
568 format!(
569 r#"<span class="badge env" title="{}">{}</span>"#,
570 label, short
571 )
572 })
573 .unwrap_or_default();
574
575 // Show match count instead of score (AuDD doesn't provide scores in accurate_offsets mode)
576 let match_count_badge = ctx
577 .and_then(|c| c.matches.as_ref())
578 .filter(|m| !m.is_empty())
579 .map(|matches| {
580 format!(
581 r#"<span class="badge matches">{} matches</span>"#,
582 matches.len()
583 )
584 })
585 .unwrap_or_default();
586
587 let status_badge = if track.resolved {
588 r#"<span class="badge resolved">resolved</span>"#
589 } else {
590 r#"<span class="badge pending">pending</span>"#
591 };
592
593 let matches_html = ctx
594 .and_then(|c| c.matches.as_ref())
595 .filter(|m| !m.is_empty())
596 .map(|matches| {
597 let items: Vec<String> = matches
598 .iter()
599 .take(3)
600 .map(|m| {
601 format!(
602 r#"<div class="match-item">
603 <span class="title">{}</span> <span class="artist">by {}</span>
604 </div>"#,
605 html_escape(&m.title),
606 html_escape(&m.artist),
607 )
608 })
609 .collect();
610 format!(
611 r#"<div class="matches">
612 <h4>potential matches</h4>
613 {}
614 </div>"#,
615 items.join("\n")
616 )
617 })
618 .unwrap_or_default();
619
620 let action_button = if track.resolved {
621 // Show the resolution reason and notes if available
622 let reason_text = ctx
623 .and_then(|c| c.resolution_reason.as_ref())
624 .map(|r| r.label())
625 .unwrap_or("resolved");
626 let notes_html = ctx
627 .and_then(|c| c.resolution_notes.as_ref())
628 .map(|n| format!(r#"<div class="resolution-notes">{}</div>"#, html_escape(n)))
629 .unwrap_or_default();
630 format!(
631 r#"<div class="resolution-info">
632 <span class="resolution-reason">{}</span>
633 {}
634 </div>"#,
635 reason_text, notes_html
636 )
637 } else {
638 // Multi-step flow: button -> reason select -> confirm
639 format!(
640 r#"<div class="resolve-flow" data-uri="{}" data-val="{}">
641 <button type="button" class="btn btn-warning" onclick="showReasonSelect(this)">
642 mark false positive
643 </button>
644 </div>"#,
645 html_escape(&track.uri),
646 html_escape(&track.val)
647 )
648 };
649
650 let resolved_class = if track.resolved { " resolved" } else { "" };
651
652 format!(
653 r#"<div class="flag-card{}">
654 <div class="flag-header">
655 <div class="track-info">
656 {}
657 <div class="uri">{}</div>
658 </div>
659 <div class="flag-badges">
660 {}
661 {}
662 {}
663 </div>
664 </div>
665 {}
666 <div class="flag-actions">
667 {}
668 </div>
669 </div>"#,
670 resolved_class,
671 track_info,
672 html_escape(&track.uri),
673 env_badge,
674 match_count_badge,
675 status_badge,
676 matches_html,
677 action_button
678 )
679}
680
681/// Simple HTML escaping.
682fn html_escape(s: &str) -> String {
683 s.replace('&', "&")
684 .replace('<', "<")
685 .replace('>', ">")
686 .replace('"', """)
687 .replace('\'', "'")
688}