feat: auto-resolve flags for deleted tracks (#681)

- add PlyrClient to check track existence via plyr.fm API
- before LLM analysis, check each flagged track still exists
- auto-resolve with reason "content_deleted" if track returns 404
- add ContentDeleted variant to ResolutionReason enum in Rust

prevents labels from persisting in the ether for deleted content

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

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 6c01fe46 3e71772e

Changed files
+70 -1
moderation
src
scripts
+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 }
+66 -1
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 ··· 268 332 269 333 finally: 270 334 await mod.close() 335 + await plyr.close() 271 336 272 337 273 338 def main() -> None: