music on atproto
plyr.fm
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())