+47
-3
.github/workflows/check-rust.yml
+47
-3
.github/workflows/check-rust.yml
···
11
11
contents: read
12
12
13
13
jobs:
14
+
changes:
15
+
name: detect changes
16
+
runs-on: ubuntu-latest
17
+
outputs:
18
+
moderation: ${{ steps.filter.outputs.moderation }}
19
+
transcoder: ${{ steps.filter.outputs.transcoder }}
20
+
steps:
21
+
- uses: actions/checkout@v4
22
+
- uses: dorny/paths-filter@v3
23
+
id: filter
24
+
with:
25
+
filters: |
26
+
moderation:
27
+
- 'moderation/**'
28
+
- '.github/workflows/check-rust.yml'
29
+
transcoder:
30
+
- 'transcoder/**'
31
+
- '.github/workflows/check-rust.yml'
32
+
14
33
check:
15
34
name: cargo check
16
35
runs-on: ubuntu-latest
17
36
timeout-minutes: 15
37
+
needs: changes
18
38
19
39
strategy:
40
+
fail-fast: false
20
41
matrix:
21
-
service: [moderation, transcoder]
42
+
include:
43
+
- service: moderation
44
+
changed: ${{ needs.changes.outputs.moderation }}
45
+
- service: transcoder
46
+
changed: ${{ needs.changes.outputs.transcoder }}
22
47
23
48
steps:
24
49
- uses: actions/checkout@v4
50
+
if: matrix.changed == 'true'
25
51
26
52
- name: install rust toolchain
53
+
if: matrix.changed == 'true'
27
54
uses: dtolnay/rust-toolchain@stable
28
55
29
56
- name: cache cargo
57
+
if: matrix.changed == 'true'
30
58
uses: Swatinem/rust-cache@v2
31
59
with:
32
60
workspaces: ${{ matrix.service }}
33
61
34
62
- name: cargo check
63
+
if: matrix.changed == 'true'
35
64
working-directory: ${{ matrix.service }}
36
65
run: cargo check --release
37
66
67
+
- name: skip (no changes)
68
+
if: matrix.changed != 'true'
69
+
run: echo "skipping ${{ matrix.service }} - no changes"
70
+
38
71
docker-build:
39
72
name: docker build
40
73
runs-on: ubuntu-latest
41
74
timeout-minutes: 10
42
-
needs: check
75
+
needs: [changes, check]
43
76
44
77
strategy:
78
+
fail-fast: false
45
79
matrix:
46
-
service: [moderation, transcoder]
80
+
include:
81
+
- service: moderation
82
+
changed: ${{ needs.changes.outputs.moderation }}
83
+
- service: transcoder
84
+
changed: ${{ needs.changes.outputs.transcoder }}
47
85
48
86
steps:
49
87
- uses: actions/checkout@v4
88
+
if: matrix.changed == 'true'
50
89
51
90
- name: build docker image
91
+
if: matrix.changed == 'true'
52
92
working-directory: ${{ matrix.service }}
53
93
run: docker build -t ${{ matrix.service }}:ci-test .
94
+
95
+
- name: skip (no changes)
96
+
if: matrix.changed != 'true'
97
+
run: echo "skipping ${{ matrix.service }} - no changes"
+10
-1
moderation/src/admin.rs
+10
-1
moderation/src/admin.rs
···
380
380
db.create_batch(&id, &uris, request.created_by.as_deref())
381
381
.await?;
382
382
383
-
let url = format!("/review/{}", id);
383
+
let url = format!("/admin/review/{}", id);
384
384
385
385
Ok(Json(CreateBatchResponse { id, url, flag_count }))
386
386
}
···
465
465
let resolved_active = if current_filter == "resolved" { " active" } else { "" };
466
466
let all_active = if current_filter == "all" { " active" } else { "" };
467
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
+
468
475
let filter_buttons = format!(
469
476
"<div class=\"filter-row\">\
470
477
<span class=\"filter-label\">show:</span>\
471
478
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\
472
479
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\
473
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>\
474
482
</div>",
475
483
pending_active,
476
484
resolved_active,
477
485
all_active,
486
+
count_label,
478
487
);
479
488
480
489
if tracks.is_empty() {
+5
-1
moderation/src/auth.rs
+5
-1
moderation/src/auth.rs
···
12
12
let path = req.uri().path();
13
13
14
14
// Public endpoints - no auth required
15
-
// Note: /admin serves HTML, auth is handled client-side for API calls
15
+
// Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls
16
16
// Static files must be public for admin UI CSS/JS to load
17
+
let is_review_page = path.starts_with("/admin/review/")
18
+
&& !path.ends_with("/data")
19
+
&& !path.ends_with("/submit");
17
20
if path == "/"
18
21
|| path == "/health"
19
22
|| path == "/sensitive-images"
20
23
|| path == "/admin"
24
+
|| is_review_page
21
25
|| path.starts_with("/static/")
22
26
|| path.starts_with("/xrpc/com.atproto.label.")
23
27
{
+4
moderation/src/db.rs
+4
moderation/src/db.rs
···
95
95
FingerprintNoise,
96
96
/// Legal cover version or remix
97
97
CoverVersion,
98
+
/// Content was deleted from plyr.fm
99
+
ContentDeleted,
98
100
/// Other reason (see resolution_notes)
99
101
Other,
100
102
}
···
107
109
Self::Licensed => "licensed",
108
110
Self::FingerprintNoise => "fingerprint noise",
109
111
Self::CoverVersion => "cover/remix",
112
+
Self::ContentDeleted => "content deleted",
110
113
Self::Other => "other",
111
114
}
112
115
}
···
118
121
"licensed" => Some(Self::Licensed),
119
122
"fingerprint_noise" => Some(Self::FingerprintNoise),
120
123
"cover_version" => Some(Self::CoverVersion),
124
+
"content_deleted" => Some(Self::ContentDeleted),
121
125
"other" => Some(Self::Other),
122
126
_ => None,
123
127
}
+4
-4
moderation/src/main.rs
+4
-4
moderation/src/main.rs
···
93
93
post(admin::remove_sensitive_image),
94
94
)
95
95
.route("/admin/batches", post(admin::create_batch))
96
-
// Review endpoints (auth protected)
97
-
.route("/review/:id", get(review::review_page))
98
-
.route("/review/:id/data", get(review::review_data))
99
-
.route("/review/:id/submit", post(review::submit_review))
96
+
// Review endpoints (under admin, auth protected)
97
+
.route("/admin/review/:id", get(review::review_page))
98
+
.route("/admin/review/:id/data", get(review::review_data))
99
+
.route("/admin/review/:id/submit", post(review::submit_review))
100
100
// Static files (CSS, JS for admin UI)
101
101
.nest_service("/static", ServeDir::new("static"))
102
102
// ATProto XRPC endpoints (public)
+190
-117
moderation/src/review.rs
+190
-117
moderation/src/review.rs
···
31
31
#[derive(Debug, Deserialize)]
32
32
pub struct ReviewDecision {
33
33
pub uri: String,
34
-
pub decision: String, // "approved" or "rejected"
34
+
/// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation)
35
+
pub decision: String,
35
36
}
36
37
37
38
/// Response after submitting review.
···
110
111
db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision)
111
112
.await?;
112
113
113
-
if decision.decision == "approved" {
114
-
let label =
115
-
crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation")
116
-
.negated();
117
-
let label = signer.sign_label(label)?;
118
-
let seq = db.store_label(&label).await?;
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?;
119
122
120
-
db.store_resolution(
121
-
&decision.uri,
122
-
crate::db::ResolutionReason::FingerprintNoise,
123
-
Some("batch review"),
124
-
)
125
-
.await?;
123
+
db.store_resolution(
124
+
&decision.uri,
125
+
crate::db::ResolutionReason::FingerprintNoise,
126
+
Some("batch review: cleared"),
127
+
)
128
+
.await?;
126
129
127
-
if let Some(tx) = &state.label_tx {
128
-
let _ = tx.send((seq, label));
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");
129
147
}
130
-
131
-
resolved_count += 1;
132
148
}
133
149
}
134
150
···
175
191
};
176
192
177
193
let status_badge = if status == "completed" {
178
-
r#"<span class="status-badge completed">completed</span>"#
194
+
r#"<span class="badge resolved">completed</span>"#
179
195
} else {
180
196
""
181
197
};
···
187
203
<meta charset="utf-8">
188
204
<meta name="viewport" content="width=device-width, initial-scale=1">
189
205
<title>review batch - plyr.fm</title>
206
+
<link rel="stylesheet" href="/static/admin.css">
190
207
<style>{}</style>
191
208
</head>
192
209
<body>
193
-
<div class="container">
194
-
<header>
195
-
<h1>plyr.fm moderation</h1>
196
-
<div class="batch-info">{} pending {}</div>
197
-
</header>
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>
198
216
199
-
<form id="review-form" class="review-form">
200
-
<div class="flags-list">
201
-
{}
202
-
</div>
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>
203
222
223
+
<form id="review-form" style="display: none;">
224
+
<div class="flags-list">
204
225
{}
226
+
</div>
205
227
206
-
<div class="submit-bar">
207
-
<button type="submit" class="btn-submit" id="submit-btn" disabled>
208
-
submit decisions
209
-
</button>
210
-
</div>
211
-
</form>
212
-
</div>
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>
213
236
214
237
<script>
215
238
const form = document.getElementById('review-form');
216
239
const submitBtn = document.getElementById('submit-btn');
240
+
const authSection = document.getElementById('auth-section');
217
241
const batchId = '{}';
218
242
243
+
let currentToken = '';
219
244
const decisions = {{}};
220
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
+
221
268
function updateSubmitBtn() {{
222
269
const count = Object.keys(decisions).length;
223
270
submitBtn.disabled = count === 0;
···
225
272
}}
226
273
227
274
function setDecision(uri, decision) {{
228
-
decisions[uri] = decision;
229
-
const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
230
-
if (card) {{
231
-
card.classList.remove('approved', 'rejected');
232
-
card.classList.add(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
+
}}
233
287
}}
234
288
updateSubmitBtn();
235
289
}}
···
240
294
submitBtn.textContent = 'submitting...';
241
295
242
296
try {{
243
-
const response = await fetch(`/review/${{batchId}}/submit`, {{
297
+
const response = await fetch(`/admin/review/${{batchId}}/submit`, {{
244
298
method: 'POST',
245
-
headers: {{ 'Content-Type': 'application/json' }},
299
+
headers: {{
300
+
'Content-Type': 'application/json',
301
+
'X-Moderation-Key': currentToken
302
+
}},
246
303
body: JSON.stringify({{
247
304
decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }}))
248
305
}})
249
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
+
}}
250
317
251
318
if (response.ok) {{
252
319
const result = await response.json();
···
304
371
.map(|matches| {
305
372
let items: Vec<String> = matches
306
373
.iter()
307
-
.take(2)
374
+
.take(3)
308
375
.map(|m| {
309
376
format!(
310
-
r#"<span class="match">{} - {}</span>"#,
377
+
r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#,
311
378
html_escape(&m.title),
312
379
html_escape(&m.artist)
313
380
)
314
381
})
315
382
.collect();
316
-
format!(r#"<div class="matches">{}</div>"#, items.join(""))
383
+
format!(
384
+
r#"<div class="matches"><h4>potential matches</h4>{}</div>"#,
385
+
items.join("\n")
386
+
)
317
387
})
318
388
.unwrap_or_default();
319
389
320
390
let resolved_badge = if track.resolved {
321
391
r#"<span class="badge resolved">resolved</span>"#
322
392
} else {
323
-
""
393
+
r#"<span class="badge pending">pending</span>"#
324
394
};
325
395
326
396
let action_buttons = if !track.resolved {
327
397
format!(
328
-
r#"<div class="actions">
329
-
<button type="button" class="btn-approve" onclick="setDecision('{}', 'approved')">approve</button>
330
-
<button type="button" class="btn-reject" onclick="setDecision('{}', 'rejected')">reject</button>
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>
331
402
</div>"#,
403
+
html_escape(&track.uri),
332
404
html_escape(&track.uri),
333
405
html_escape(&track.uri)
334
406
)
···
337
409
};
338
410
339
411
format!(
340
-
r#"<div class="review-card{}" data-uri="{}">
341
-
<div class="track-info">
342
-
<div class="title">{}</div>
343
-
<div class="artist">@{}</div>
344
-
{}
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>
345
421
</div>
346
422
{}
347
423
{}
···
350
426
html_escape(&track.uri),
351
427
title_html,
352
428
html_escape(artist),
353
-
matches_html,
354
429
resolved_badge,
430
+
matches_html,
355
431
action_buttons
356
432
)
357
433
}
···
364
440
.replace('\'', "'")
365
441
}
366
442
443
+
/// Additional CSS for review page (supplements admin.css)
367
444
const REVIEW_CSS: &str = r#"
368
-
* { box-sizing: border-box; margin: 0; padding: 0; }
445
+
/* review page specific styles */
446
+
body { padding-bottom: 80px; }
369
447
370
-
body {
371
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
372
-
background: #0a0a0a;
373
-
color: #e0e0e0;
374
-
min-height: 100vh;
448
+
.subtitle a {
449
+
color: var(--accent);
450
+
text-decoration: none;
375
451
}
452
+
.subtitle a:hover { text-decoration: underline; }
376
453
377
-
.container {
378
-
max-width: 600px;
379
-
margin: 0 auto;
380
-
padding: 16px;
381
-
padding-bottom: 80px;
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);
382
462
}
383
463
384
-
header {
385
-
display: flex;
386
-
justify-content: space-between;
387
-
align-items: center;
388
-
margin-bottom: 20px;
389
-
padding-bottom: 12px;
390
-
border-bottom: 1px solid #333;
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);
391
471
}
392
472
393
-
h1 { font-size: 1.25rem; font-weight: 600; color: #fff; }
394
-
.batch-info { font-size: 0.875rem; color: #888; }
395
-
.status-badge { font-size: 0.7rem; background: #1a3a1a; color: #6d9; padding: 2px 6px; border-radius: 4px; margin-left: 8px; }
396
-
.flags-list { display: flex; flex-direction: column; gap: 12px; }
397
-
398
-
.review-card {
399
-
background: #1a1a1a;
400
-
border: 1px solid #333;
401
-
border-radius: 8px;
402
-
padding: 12px;
403
-
transition: border-color 0.2s, background 0.2s;
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);
404
480
}
405
481
406
-
.review-card.approved { border-color: #2d5a27; background: #1a2a18; }
407
-
.review-card.rejected { border-color: #5a2727; background: #2a1818; }
408
-
.review-card.resolved { opacity: 0.6; }
409
-
.track-info { margin-bottom: 8px; }
410
-
.title { font-weight: 600; font-size: 1rem; margin-bottom: 2px; }
411
-
.title a { color: inherit; text-decoration: none; }
412
-
.title a:hover { text-decoration: underline; }
413
-
.artist { font-size: 0.875rem; color: #888; }
414
-
.matches { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
415
-
.match { font-size: 0.75rem; background: #2a2a2a; padding: 2px 6px; border-radius: 4px; color: #aaa; }
416
-
.badge { display: inline-block; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 500; }
417
-
.badge.resolved { background: #1a3a1a; color: #6d9; }
418
-
.actions { display: flex; gap: 8px; margin-top: 10px; }
419
-
.actions button { flex: 1; padding: 10px; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
420
-
.btn-approve { background: #2d5a27; color: #fff; }
421
-
.btn-reject { background: #5a2727; color: #fff; }
422
-
.actions button:active { opacity: 0.8; }
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
+
}
423
495
496
+
/* submit bar */
424
497
.submit-bar {
425
498
position: fixed;
426
499
bottom: 0;
427
500
left: 0;
428
501
right: 0;
429
-
padding: 12px 16px;
430
-
background: #111;
431
-
border-top: 1px solid #333;
502
+
padding: 16px 24px;
503
+
background: var(--bg-secondary);
504
+
border-top: 1px solid var(--border-subtle);
432
505
}
433
-
434
-
.btn-submit {
506
+
.submit-bar .btn {
435
507
width: 100%;
436
-
max-width: 600px;
508
+
max-width: 900px;
437
509
margin: 0 auto;
438
510
display: block;
439
511
padding: 14px;
440
-
background: #4a9eff;
441
-
color: #fff;
442
-
border: none;
443
-
border-radius: 8px;
444
-
font-size: 1rem;
445
-
font-weight: 600;
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 {
446
521
cursor: pointer;
522
+
color: var(--text-tertiary);
523
+
font-size: 0.85rem;
524
+
margin-bottom: 12px;
447
525
}
448
-
449
-
.btn-submit:disabled { background: #333; color: #666; cursor: not-allowed; }
450
-
.empty { text-align: center; padding: 40px 20px; color: #666; }
451
-
.resolved-section { margin-top: 20px; border-top: 1px solid #333; padding-top: 16px; }
452
-
.resolved-section summary { cursor: pointer; color: #888; font-size: 0.875rem; margin-bottom: 12px; }
453
526
"#;
+6
moderation/static/admin.css
+6
moderation/static/admin.css
+70
-2
scripts/moderation_loop.py
+70
-2
scripts/moderation_loop.py
···
117
117
118
118
119
119
@dataclass
120
+
class PlyrClient:
121
+
"""client for checking track existence in plyr.fm."""
122
+
123
+
env: str = "prod"
124
+
_client: httpx.AsyncClient = field(init=False, repr=False)
125
+
126
+
def __post_init__(self) -> None:
127
+
base_url = {
128
+
"prod": "https://api.plyr.fm",
129
+
"staging": "https://api-stg.plyr.fm",
130
+
"dev": "http://localhost:8001",
131
+
}.get(self.env, "https://api.plyr.fm")
132
+
self._client = httpx.AsyncClient(base_url=base_url, timeout=10.0)
133
+
134
+
async def close(self) -> None:
135
+
await self._client.aclose()
136
+
137
+
async def track_exists(self, track_id: int) -> bool:
138
+
"""check if a track exists (returns False if 404)."""
139
+
try:
140
+
r = await self._client.get(f"/tracks/{track_id}")
141
+
return r.status_code == 200
142
+
except Exception:
143
+
return True # assume exists on error (don't accidentally delete labels)
144
+
145
+
146
+
@dataclass
120
147
class ModClient:
121
148
base_url: str
122
149
auth_token: str
···
203
230
204
231
dm = DMClient(settings.bot_handle, settings.bot_password, settings.recipient_handle)
205
232
mod = ModClient(settings.moderation_service_url, settings.moderation_auth_token)
233
+
plyr = PlyrClient(env=env)
206
234
207
235
try:
208
236
await dm.setup()
···
215
243
216
244
console.print(f"[bold]{len(pending)} pending flags[/bold]")
217
245
218
-
# analyze flags
246
+
# check for deleted tracks and auto-resolve them
247
+
console.print("[dim]checking for deleted tracks...[/dim]")
248
+
active_flags = []
249
+
deleted_count = 0
250
+
for flag in pending:
251
+
track_id = flag.get("context", {}).get("track_id")
252
+
if track_id and not await plyr.track_exists(track_id):
253
+
# track was deleted - resolve the flag
254
+
if not dry_run:
255
+
try:
256
+
await mod.resolve(
257
+
flag["uri"], "content_deleted", "track no longer exists"
258
+
)
259
+
console.print(
260
+
f" [yellow]โซ[/yellow] deleted: {flag['uri'][-40:]}"
261
+
)
262
+
deleted_count += 1
263
+
except Exception as e:
264
+
console.print(f" [red]โ[/red] {e}")
265
+
active_flags.append(flag)
266
+
else:
267
+
console.print(
268
+
f" [yellow]would resolve deleted:[/yellow] {flag['uri'][-40:]}"
269
+
)
270
+
deleted_count += 1
271
+
else:
272
+
active_flags.append(flag)
273
+
274
+
if deleted_count > 0:
275
+
console.print(f"[yellow]{deleted_count} deleted tracks resolved[/yellow]")
276
+
277
+
pending = active_flags
278
+
if not pending:
279
+
console.print("[green]all flags were for deleted tracks[/green]")
280
+
return
281
+
282
+
# analyze remaining flags
219
283
if limit:
220
284
pending = pending[:limit]
221
285
···
253
317
254
318
if not dry_run:
255
319
batch = await mod.create_batch(human_uris, created_by="moderation_loop")
256
-
msg = f"{get_header(env)} {batch['flag_count']} need review:\n{batch['url']}"
320
+
full_url = f"{mod.base_url.rstrip('/')}{batch['url']}"
321
+
msg = (
322
+
f"{get_header(env)} {batch['flag_count']} need review:\n{full_url}"
323
+
)
257
324
await dm.send(msg)
258
325
console.print(f"[green]sent batch {batch['id']}[/green]")
259
326
else:
···
265
332
266
333
finally:
267
334
await mod.close()
335
+
await plyr.close()
268
336
269
337
270
338
def main() -> None: