Scripts already referenced from CLAUDE.md but never committed: deploy_searxng.sh (which stands up the SearXNG instance Devon's web_search depends on), post_milestone.py, generate_milestone_images.py, make_x_launch_assets.py, schedule_x_launch.py, download_music.py, and the Reaper bleep-selection script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Download vocal-free background music from Jamendo (CC-licensed).
|
|
|
|
Targets late-night talk-radio vibe + hip-hop. Skips tracks shorter than 60s,
|
|
dedupes against existing files in music/, and appends CREDITS.txt entries.
|
|
|
|
Usage:
|
|
python download_music.py # 100 tracks across all buckets
|
|
python download_music.py --count 30 # smaller batch
|
|
python download_music.py --dry-run # show what would be downloaded
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.request import urlopen, Request
|
|
from urllib.parse import urlencode
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
CLIENT_ID = os.getenv("JAMENDO_CLIENT_ID")
|
|
if not CLIENT_ID:
|
|
print("ERROR: JAMENDO_CLIENT_ID not set in .env", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
MUSIC_DIR = Path(__file__).parent / "music"
|
|
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
|
|
|
# (tag_query, genre_label, target_count) — hip-hop weighted heaviest per user pref
|
|
BUCKETS = [
|
|
("hiphop+instrumental", "Hip-Hop", 40),
|
|
("jazz", "Jazz", 20),
|
|
("lofi", "Lo-Fi", 15),
|
|
("funk", "Funk", 15),
|
|
("soul", "Soul", 10),
|
|
]
|
|
|
|
# Filenames already on disk — skip duplicates by (artist, title) signature
|
|
def _existing_signatures() -> set[str]:
|
|
sigs = set()
|
|
for f in MUSIC_DIR.glob("*.mp3"):
|
|
# "Artist - Title [Genre].mp3" or "Artist - Title.mp3"
|
|
stem = f.stem
|
|
stem = re.sub(r"\s*\[[^\]]+\]\s*$", "", stem)
|
|
sigs.add(stem.lower().strip())
|
|
for f in MUSIC_DIR.glob("*.wav"):
|
|
sigs.add(f.stem.lower().strip())
|
|
return sigs
|
|
|
|
|
|
def _sanitize(s: str) -> str:
|
|
s = s.replace("/", "-").replace("\\", "-")
|
|
s = re.sub(r'[<>:"|?*]', "", s)
|
|
return s.strip()
|
|
|
|
|
|
def _fetch_jamendo_page(tag_query: str, offset: int, limit: int = 50) -> list[dict]:
|
|
params = {
|
|
"client_id": CLIENT_ID,
|
|
"format": "json",
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"vocalinstrumental": "instrumental",
|
|
"fuzzytags": tag_query,
|
|
"audioformat": "mp32",
|
|
"include": "musicinfo+licenses",
|
|
"audiodlallowed": "true",
|
|
"ccnd": "true", # allow non-derivative (we won't modify)
|
|
"order": "popularity_total",
|
|
}
|
|
url = "https://api.jamendo.com/v3.0/tracks/?" + urlencode(params)
|
|
with urlopen(Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}), timeout=30) as r:
|
|
import json
|
|
data = json.load(r)
|
|
if data.get("headers", {}).get("status") != "success":
|
|
print(f" API error: {data.get('headers', {}).get('error_message')}")
|
|
return []
|
|
return data.get("results", [])
|
|
|
|
|
|
def _download(url: str, dest: Path) -> bool:
|
|
try:
|
|
req = Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"})
|
|
with urlopen(req, timeout=120) as r, open(dest, "wb") as out:
|
|
while True:
|
|
chunk = r.read(64 * 1024)
|
|
if not chunk:
|
|
break
|
|
out.write(chunk)
|
|
return True
|
|
except Exception as e:
|
|
print(f" download failed: {e}")
|
|
if dest.exists():
|
|
dest.unlink()
|
|
return False
|
|
|
|
|
|
def fetch_bucket(tag_query: str, genre_label: str, target: int, existing: set[str], dry_run: bool) -> list[tuple[Path, dict]]:
|
|
"""Returns list of (path, track_info) successfully downloaded."""
|
|
print(f"\n=== {genre_label} (target {target}) ===")
|
|
downloaded: list[tuple[Path, dict]] = []
|
|
offset = 0
|
|
seen_ids = set()
|
|
while len(downloaded) < target and offset < 500: # cap pagination
|
|
page = _fetch_jamendo_page(tag_query, offset)
|
|
if not page:
|
|
break
|
|
offset += len(page)
|
|
for track in page:
|
|
if len(downloaded) >= target:
|
|
break
|
|
tid = track.get("id")
|
|
if tid in seen_ids:
|
|
continue
|
|
seen_ids.add(tid)
|
|
|
|
if track.get("duration", 0) < 60:
|
|
continue
|
|
if not track.get("audiodownload_allowed"):
|
|
continue
|
|
|
|
artist = _sanitize(track.get("artist_name", "Unknown"))
|
|
name = _sanitize(track.get("name", "Untitled"))
|
|
sig = f"{artist} - {name}".lower().strip()
|
|
if sig in existing:
|
|
continue
|
|
|
|
filename = f"{artist} - {name} [{genre_label}].mp3"
|
|
dest = MUSIC_DIR / filename
|
|
|
|
audio_url = track.get("audiodownload") or track.get("audio")
|
|
if not audio_url:
|
|
continue
|
|
|
|
if dry_run:
|
|
print(f" [DRY] {filename} ({track.get('duration')}s)")
|
|
downloaded.append((dest, track))
|
|
existing.add(sig)
|
|
continue
|
|
|
|
print(f" ↓ {filename} ({track.get('duration')}s)")
|
|
if _download(audio_url, dest):
|
|
downloaded.append((dest, track))
|
|
existing.add(sig)
|
|
time.sleep(0.5) # be polite
|
|
if len(page) < 50:
|
|
break
|
|
return downloaded
|
|
|
|
|
|
def append_credits(entries: list[tuple[Path, dict]]):
|
|
if not entries:
|
|
return
|
|
with open(CREDITS_FILE, "a") as f:
|
|
f.write(f"\n# Added {time.strftime('%Y-%m-%d')} — vocal-free batch via Jamendo API\n")
|
|
for dest, track in entries:
|
|
license_url = track.get("license_ccurl", "")
|
|
share_url = track.get("shareurl", "")
|
|
artist = track.get("artist_name", "")
|
|
name = track.get("name", "")
|
|
f.write(f"{dest.name} | {artist} - {name} | {license_url} | {share_url}\n")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--count", type=int, default=100, help="Total tracks (default 100)")
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
MUSIC_DIR.mkdir(exist_ok=True)
|
|
existing = _existing_signatures()
|
|
print(f"Existing tracks: {len(existing)}")
|
|
|
|
# Scale bucket targets proportionally to --count
|
|
scale = args.count / sum(b[2] for b in BUCKETS)
|
|
all_new: list[tuple[Path, dict]] = []
|
|
for tag, label, weight in BUCKETS:
|
|
target = max(1, round(weight * scale))
|
|
all_new.extend(fetch_bucket(tag, label, target, existing, args.dry_run))
|
|
|
|
print(f"\n=== Done. {len(all_new)} new tracks {'planned' if args.dry_run else 'downloaded'}. ===")
|
|
if not args.dry_run:
|
|
append_credits(all_new)
|
|
print(f"CREDITS.txt updated.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|