music on atproto
plyr.fm
1#!/usr/bin/env -S uv run --script --quiet
2"""admin script to delete a track and all associated data.
3
4usage:
5 uv run scripts/delete_track.py <track_id>
6 uv run scripts/delete_track.py --url <track_url>
7
8this will:
9- delete audio file from R2
10- delete cover image from R2 (if exists)
11- delete ATProto record (if exists)
12- delete track from database (cascades to likes, queue entries, etc.)
13
14environment variables (use ADMIN_ prefix):
15 ADMIN_DATABASE_URL - database connection string
16 ADMIN_AWS_ACCESS_KEY_ID - R2 access key
17 ADMIN_AWS_SECRET_ACCESS_KEY - R2 secret key
18 ADMIN_R2_ENDPOINT_URL - R2 endpoint
19 ADMIN_R2_BUCKET - R2 bucket name
20
21example:
22 export ADMIN_DATABASE_URL="postgresql+psycopg://..."
23 export ADMIN_AWS_ACCESS_KEY_ID="..."
24 export ADMIN_AWS_SECRET_ACCESS_KEY="..."
25 export ADMIN_R2_ENDPOINT_URL="https://...r2.cloudflarestorage.com"
26 export ADMIN_R2_BUCKET="audio-prod"
27 uv run scripts/delete_track.py 34
28"""
29
30import asyncio
31import sys
32from pathlib import Path
33
34# add src to path
35sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
36
37from pydantic import Field
38from pydantic_settings import BaseSettings, SettingsConfigDict
39
40
41class AdminSettings(BaseSettings):
42 """settings for admin script with dedicated namespace."""
43
44 model_config = SettingsConfigDict(
45 env_file=".env",
46 case_sensitive=False,
47 extra="ignore",
48 )
49
50 database_url: str = Field(validation_alias="ADMIN_DATABASE_URL")
51 aws_access_key_id: str = Field(validation_alias="ADMIN_AWS_ACCESS_KEY_ID")
52 aws_secret_access_key: str = Field(validation_alias="ADMIN_AWS_SECRET_ACCESS_KEY")
53 r2_endpoint_url: str = Field(validation_alias="ADMIN_R2_ENDPOINT_URL")
54 r2_bucket: str = Field(validation_alias="ADMIN_R2_BUCKET")
55
56
57def setup_admin_env(admin_settings: AdminSettings) -> None:
58 """setup environment variables from admin settings."""
59 import os
60
61 os.environ["DATABASE_URL"] = admin_settings.database_url
62 os.environ["AWS_ACCESS_KEY_ID"] = admin_settings.aws_access_key_id
63 os.environ["AWS_SECRET_ACCESS_KEY"] = admin_settings.aws_secret_access_key
64 os.environ["R2_ENDPOINT_URL"] = admin_settings.r2_endpoint_url
65 os.environ["R2_BUCKET"] = admin_settings.r2_bucket
66 os.environ["R2_IMAGE_BUCKET"] = admin_settings.r2_bucket # use same bucket
67 os.environ["R2_PUBLIC_BUCKET_URL"] = "" # not needed for deletion
68 os.environ["R2_PUBLIC_IMAGE_BUCKET_URL"] = "" # not needed for deletion
69
70
71async def delete_track(track_id: int, dry_run: bool = False) -> None:
72 """delete a track and all associated data."""
73 # import backend modules AFTER env setup
74 from sqlalchemy import select
75
76 from backend.models import Track
77 from backend.storage import storage
78 from backend.utilities.database import db_session
79
80 async with db_session() as db:
81 # fetch track
82 result = await db.execute(select(Track).where(Track.id == track_id))
83 track = result.scalar_one_or_none()
84
85 if not track:
86 print(f"❌ track {track_id} not found")
87 return
88
89 print(f"\n{'[DRY RUN] ' if dry_run else ''}deleting track {track_id}:")
90 print(f" title: {track.title}")
91 print(f" artist: {track.artist_did}")
92 print(f" file_id: {track.file_id}")
93 print(f" image_id: {track.image_id}")
94 print(f" atproto_uri: {track.atproto_record_uri}")
95
96 if dry_run:
97 print("\n🔍 dry run - no changes made")
98 print("\nwould delete:")
99 print(f" - audio file: {track.file_id}")
100 if track.image_id:
101 print(f" - image file: {track.image_id}")
102 if track.atproto_record_uri:
103 print(f" - atproto record: {track.atproto_record_uri}")
104 print(f" - database record: track {track_id}")
105 return
106
107 # 1. delete audio file from R2
108 print(f"\n🗑️ deleting audio file: {track.file_id}")
109 try:
110 storage.delete(track.file_id)
111 print(" ✅ audio file deleted")
112 except Exception as e:
113 print(f" ⚠️ failed to delete audio file: {e}")
114
115 # 2. delete image file from R2 (if exists)
116 if track.image_id:
117 print(f"🗑️ deleting image file: {track.image_id}")
118 try:
119 storage.delete(track.image_id)
120 print(" ✅ image file deleted")
121 except Exception as e:
122 print(f" ⚠️ failed to delete image file: {e}")
123
124 # 3. delete ATProto record (if exists)
125 if track.atproto_record_uri:
126 print(f"🗑️ deleting atproto record: {track.atproto_record_uri}")
127 try:
128 # need to get artist's session for this
129 # for now, just note that it needs manual cleanup
130 print(
131 " ⚠️ atproto record requires manual cleanup (needs artist auth)"
132 )
133 print(f" uri: {track.atproto_record_uri}")
134 except Exception as e:
135 print(f" ⚠️ failed to delete atproto record: {e}")
136
137 # 4. delete from database (cascades to likes, queue entries, etc.)
138 print(f"🗑️ deleting database record: track {track_id}")
139 await db.delete(track)
140 await db.commit()
141 print(" ✅ database record deleted (cascaded to related records)")
142
143 print(f"\n✅ track {track_id} deleted successfully")
144
145
146async def main() -> None:
147 """main entry point."""
148 if len(sys.argv) < 2:
149 print("usage: uv run scripts/delete_track.py <track_id>")
150 print(" or: uv run scripts/delete_track.py --url <track_url>")
151 print(" add --dry-run to see what would be deleted without making changes")
152 sys.exit(1)
153
154 # load admin settings BEFORE any backend imports
155 try:
156 admin_settings = AdminSettings()
157 print(
158 f"✓ loaded admin settings for database: {admin_settings.database_url.split('@')[1].split('/')[0]}"
159 )
160 except Exception as e:
161 print(f"❌ failed to load admin settings: {e}")
162 print("\nrequired environment variables:")
163 print(" ADMIN_DATABASE_URL")
164 print(" ADMIN_AWS_ACCESS_KEY_ID")
165 print(" ADMIN_AWS_SECRET_ACCESS_KEY")
166 print(" ADMIN_R2_ENDPOINT_URL")
167 print(" ADMIN_R2_BUCKET")
168 sys.exit(1)
169
170 # setup environment BEFORE any backend imports
171 setup_admin_env(admin_settings)
172
173 dry_run = "--dry-run" in sys.argv
174 if dry_run:
175 sys.argv.remove("--dry-run")
176
177 skip_confirm = "--yes" in sys.argv or "-y" in sys.argv
178 if "--yes" in sys.argv:
179 sys.argv.remove("--yes")
180 if "-y" in sys.argv:
181 sys.argv.remove("-y")
182
183 # handle --url flag
184 if sys.argv[1] == "--url":
185 if len(sys.argv) < 3:
186 print("error: --url requires a URL argument")
187 sys.exit(1)
188 url = sys.argv[2]
189 # extract track id from URL like https://plyr.fm/track/34
190 try:
191 track_id = int(url.rstrip("/").split("/")[-1])
192 except (ValueError, IndexError):
193 print(f"error: could not extract track id from URL: {url}")
194 sys.exit(1)
195 else:
196 try:
197 track_id = int(sys.argv[1])
198 except ValueError:
199 print(f"error: invalid track id: {sys.argv[1]}")
200 sys.exit(1)
201
202 # confirm deletion
203 if not dry_run and not skip_confirm:
204 print(f"\n⚠️ you are about to DELETE track {track_id}")
205 print("this will:")
206 print(" - delete the audio file from R2")
207 print(" - delete the cover image from R2 (if exists)")
208 print(" - delete the database record")
209 print(" - cascade delete likes and queue entries")
210 print("\nthis CANNOT be undone!")
211 confirm = input("\ntype 'yes' to confirm: ")
212 if confirm.lower() != "yes":
213 print("❌ deletion cancelled")
214 return
215
216 await delete_track(track_id, dry_run=dry_run)
217
218
219if __name__ == "__main__":
220 asyncio.run(main())