at main 8.1 kB view raw
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())