at main 8.4 kB view raw
1#!/usr/bin/env -S uv run --script --quiet 2"""admin script to simulate broken ATProto records for local testing. 3 4usage: 5 uv run scripts/simulate_broken_tracks.py <track_id> [<track_id> ...] 6 uv run scripts/simulate_broken_tracks.py --restore <track_id> 7 8this will: 9- nullify atproto_record_uri and atproto_record_cid for specified tracks 10- optionally restore the original values from backup 11 12environment variables (defaults to local dev): 13 DATABASE_URL - database connection string (defaults to local postgres) 14 15examples: 16 # break tracks 1 and 2 for testing 17 uv run scripts/simulate_broken_tracks.py 1 2 18 19 # restore track 1 20 uv run scripts/simulate_broken_tracks.py --restore 1 21 22 # break all tracks for a specific artist 23 uv run scripts/simulate_broken_tracks.py --artist-did did:plc:... 24""" 25 26import asyncio 27import sys 28from pathlib import Path 29 30# add src to path 31sys.path.insert(0, str(Path(__file__).parent.parent / "src")) 32 33 34async def break_track(track_id: int) -> None: 35 """nullify ATProto record fields for a track and delete from PDS.""" 36 import subprocess 37 38 from dotenv import load_dotenv 39 from sqlalchemy import select 40 41 from backend.models import Track 42 from backend.utilities.database import db_session 43 44 async with db_session() as db: 45 # fetch track first to show current state 46 result = await db.execute(select(Track).where(Track.id == track_id)) 47 track = result.scalar_one_or_none() 48 49 if not track: 50 print(f"❌ track {track_id} not found") 51 return 52 53 if not track.atproto_record_uri: 54 print(f"⚠️ track {track_id} already has null ATProto record") 55 print(f" title: {track.title}") 56 return 57 58 print(f"\n🔧 breaking ATProto record for track {track_id}:") 59 print(f" title: {track.title}") 60 print(f" artist: {track.artist_did}") 61 print(f" current uri: {track.atproto_record_uri}") 62 print(f" current cid: {track.atproto_record_cid}") 63 64 # delete from PDS first 65 load_dotenv() 66 import os 67 68 handle = os.getenv("ATPROTO_MAIN_HANDLE") 69 password = os.getenv("ATPROTO_MAIN_PASSWORD") 70 71 if handle and password: 72 print(" deleting from PDS...") 73 result = subprocess.run( 74 [ 75 "uvx", 76 "pdsx", 77 "--pds", 78 "https://pds.zzstoatzz.io", 79 "--handle", 80 handle, 81 "--password", 82 password, 83 "rm", 84 track.atproto_record_uri, 85 ], 86 capture_output=True, 87 text=True, 88 ) 89 if result.returncode == 0: 90 print(" ✓ deleted from PDS") 91 else: 92 print(f" ⚠️ PDS deletion failed: {result.stderr}") 93 print(" continuing with DB nullification...") 94 else: 95 print(" ⚠️ ATPROTO_MAIN_HANDLE/PASSWORD not set, skipping PDS deletion") 96 97 # store original values in extra field for potential restoration 98 if track.extra is None: 99 track.extra = {} 100 track.extra["_backup_atproto_uri"] = track.atproto_record_uri 101 track.extra["_backup_atproto_cid"] = track.atproto_record_cid 102 103 # nullify the fields 104 track.atproto_record_uri = None 105 track.atproto_record_cid = None 106 107 await db.commit() 108 109 print(f"✅ track {track_id} ATProto record nullified") 110 print(" (backup stored in extra field for restoration)") 111 112 113async def restore_track(track_id: int) -> None: 114 """restore ATProto record fields from backup.""" 115 from sqlalchemy import select 116 117 from backend.models import Track 118 from backend.utilities.database import db_session 119 120 async with db_session() as db: 121 result = await db.execute(select(Track).where(Track.id == track_id)) 122 track = result.scalar_one_or_none() 123 124 if not track: 125 print(f"❌ track {track_id} not found") 126 return 127 128 if not track.extra or "_backup_atproto_uri" not in track.extra: 129 print(f"❌ no backup found for track {track_id}") 130 return 131 132 print(f"\n🔄 restoring ATProto record for track {track_id}:") 133 print(f" title: {track.title}") 134 print(f" backup uri: {track.extra['_backup_atproto_uri']}") 135 print(f" backup cid: {track.extra['_backup_atproto_cid']}") 136 137 # restore from backup 138 track.atproto_record_uri = track.extra["_backup_atproto_uri"] 139 track.atproto_record_cid = track.extra["_backup_atproto_cid"] 140 141 # clean up backup 142 del track.extra["_backup_atproto_uri"] 143 del track.extra["_backup_atproto_cid"] 144 145 await db.commit() 146 147 print(f"✅ track {track_id} ATProto record restored") 148 149 150async def break_artist_tracks(artist_did: str) -> None: 151 """break all tracks for a specific artist.""" 152 from sqlalchemy import select 153 154 from backend.models import Track 155 from backend.utilities.database import db_session 156 157 async with db_session() as db: 158 result = await db.execute(select(Track).where(Track.artist_did == artist_did)) 159 tracks = result.scalars().all() 160 161 if not tracks: 162 print(f"❌ no tracks found for artist {artist_did}") 163 return 164 165 print(f"\n🔧 breaking {len(tracks)} tracks for artist {artist_did}:") 166 for track in tracks: 167 if track.atproto_record_uri: 168 await break_track(track.id) 169 else: 170 print(f"⏭️ skipping track {track.id} (already broken)") 171 172 173async def list_broken_tracks() -> None: 174 """list all tracks with null ATProto records.""" 175 from sqlalchemy import select 176 177 from backend.models import Track 178 from backend.utilities.database import db_session 179 180 async with db_session() as db: 181 stmt = select(Track).where( 182 (Track.atproto_record_uri.is_(None)) | (Track.atproto_record_uri == "") 183 ) 184 result = await db.execute(stmt) 185 tracks = result.scalars().all() 186 187 if not tracks: 188 print("✅ no broken tracks found") 189 return 190 191 print(f"\n📋 found {len(tracks)} broken tracks:") 192 for track in tracks: 193 has_backup = ( 194 track.extra and "_backup_atproto_uri" in track.extra 195 if track.extra 196 else False 197 ) 198 print(f" - track {track.id}: {track.title}") 199 print(f" artist: {track.artist_did}") 200 if has_backup: 201 print(" ✓ has backup (can restore)") 202 else: 203 print(" ✗ no backup (truly broken)") 204 205 206async def main() -> None: 207 """main entry point.""" 208 if len(sys.argv) < 2: 209 print("usage: uv run scripts/simulate_broken_tracks.py <track_id> [...]") 210 print(" or: uv run scripts/simulate_broken_tracks.py --restore <track_id>") 211 print(" or: uv run scripts/simulate_broken_tracks.py --artist-did <did>") 212 print(" or: uv run scripts/simulate_broken_tracks.py --list") 213 sys.exit(1) 214 215 mode = sys.argv[1] 216 217 if mode == "--list": 218 await list_broken_tracks() 219 return 220 221 if mode == "--restore": 222 if len(sys.argv) < 3: 223 print("error: --restore requires a track id") 224 sys.exit(1) 225 try: 226 track_id = int(sys.argv[2]) 227 except ValueError: 228 print(f"error: invalid track id: {sys.argv[2]}") 229 sys.exit(1) 230 await restore_track(track_id) 231 return 232 233 if mode == "--artist-did": 234 if len(sys.argv) < 3: 235 print("error: --artist-did requires a DID") 236 sys.exit(1) 237 artist_did = sys.argv[2] 238 await break_artist_tracks(artist_did) 239 return 240 241 # default: break specified tracks 242 track_ids = [] 243 for arg in sys.argv[1:]: 244 try: 245 track_ids.append(int(arg)) 246 except ValueError: 247 print(f"warning: skipping invalid track id: {arg}") 248 249 if not track_ids: 250 print("error: no valid track ids provided") 251 sys.exit(1) 252 253 for track_id in track_ids: 254 await break_track(track_id) 255 256 257if __name__ == "__main__": 258 asyncio.run(main())