#!/usr/bin/env python3 """ daily_precache.py — Claude Code CLI로 음악 트랙 2개 기획 JSON 생성 Usage: python daily_precache.py [--date 2026-05-25] Output: precache/{date}.json """ import os import sys import json import subprocess import logging from datetime import datetime, date from pathlib import Path # Project paths PROJECT_DIR = Path("/home/javamon/project/animily_music") PRECACHE_DIR = PROJECT_DIR / "precache" GUIDE_PATH = PROJECT_DIR / "PRECACHE_GUIDE_MUSIC.md" LOG_DIR = PROJECT_DIR / "logs" # Logging LOG_DIR.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(LOG_DIR / "precache.log"), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) def get_target_date(args=None): """Parse target date from args or use tomorrow.""" if args and "--date" in args: idx = args.index("--date") if idx + 1 < len(args): return args[idx + 1] # Default: tomorrow from datetime import timedelta tomorrow = date.today() + timedelta(days=1) return tomorrow.strftime("%Y-%m-%d") def build_prompt(target_date: str) -> str: """Build the prompt for Claude Code CLI.""" d = datetime.strptime(target_date, "%Y-%m-%d") weekday_kr = ["월", "화", "수", "목", "금", "토", "일"][d.weekday()] month = d.month # Season if month in (3, 4, 5): season = "봄" elif month in (6, 7, 8): season = "여름" elif month in (9, 10, 11): season = "가을" else: season = "겨울" prompt = f"""You are a music curator for the ANIMILY YouTube channel. Generate a JSON plan for 2 BGM tracks to be published on {target_date} ({weekday_kr}요일). Season: {season} Date context: {target_date} ({weekday_kr}요일) Read the guide at: {GUIDE_PATH} CRITICAL RULES: 1. Output ONLY valid JSON (no markdown, no explanation, no code blocks) 2. The 2 tracks MUST be different genres 3. Caption focuses on style/mood/instruments only (NOT tempo/key info) 4. BPM must be within the genre's valid range per the guide 5. Duration: 180-300 seconds 6. image_prompt must include "makoto shinkai style" and "no text" 7. youtube_title must include emoji + Korean + English 8. playlist_category must be one of the 8 categories in the guide Output format: {{ "date": "{target_date}", "theme": "<한국어로 날짜/계절/분위기 한줄 요약>", "tracks": [ {{ "id": "track_001", "caption": "", "lyrics": "[Instrumental]", "bpm": , "key_scale": " ", "time_signature": "4", "duration": <180-300>, "image_prompt": "makoto shinkai style, , no text, no watermark", "youtube_title": " <한국어 제목> | BGM", "youtube_tags": ["", "", ...], "playlist_category": "" }}, {{ "id": "track_002", ... }} ] }} """ return prompt def run_claude_code_cli(prompt: str, target_date: str) -> dict: """Run Claude Code CLI and parse JSON output.""" logger.info("Running Claude Code CLI for precache generation...") try: result = subprocess.run( ["claude", "-p", prompt, "--output-format", "text"], capture_output=True, text=True, timeout=120, cwd=str(PROJECT_DIR) ) if result.returncode != 0: logger.error(f"Claude CLI failed (rc={result.returncode}): {result.stderr[:500]}") return None output = result.stdout.strip() # Try to extract JSON from output (handle potential wrapping) json_start = output.find("{") json_end = output.rfind("}") + 1 if json_start == -1 or json_end == 0: logger.error(f"No JSON found in output: {output[:300]}") return None json_str = output[json_start:json_end] data = json.loads(json_str) # Validate structure if "tracks" not in data or len(data["tracks"]) != 2: logger.error(f"Invalid structure: expected 2 tracks, got {len(data.get('tracks', []))}") return None # Validate each track required_fields = ["id", "caption", "lyrics", "bpm", "key_scale", "time_signature", "duration", "image_prompt", "youtube_title", "youtube_tags", "playlist_category"] valid_categories = ["신나는 음악", "감성 음악", "잔잔한 음악", "집중 음악", "수면 음악", "카페 음악", "운동 음악", "드라이브 음악"] for i, track in enumerate(data["tracks"]): for field in required_fields: if field not in track: logger.error(f"Track {i} missing field: {field}") return None if track["playlist_category"] not in valid_categories: logger.warning(f"Track {i} invalid category '{track['playlist_category']}', fixing...") track["playlist_category"] = "감성 음악" if not (180 <= track["duration"] <= 300): logger.warning(f"Track {i} duration {track['duration']} out of range, clamping") track["duration"] = max(180, min(300, track["duration"])) return data except subprocess.TimeoutExpired: logger.error("Claude CLI timed out (120s)") return None except json.JSONDecodeError as e: logger.error(f"JSON parse error: {e}") return None except Exception as e: logger.error(f"Unexpected error: {e}") return None def save_precache(data: dict, target_date: str) -> Path: """Save precache JSON to file.""" PRECACHE_DIR.mkdir(parents=True, exist_ok=True) output_path = PRECACHE_DIR / f"{target_date}.json" with open(output_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) logger.info(f"Precache saved: {output_path}") return output_path def main(): target_date = get_target_date(sys.argv[1:]) logger.info(f"=== Daily Precache: {target_date} ===") # Check if already exists output_path = PRECACHE_DIR / f"{target_date}.json" if output_path.exists(): logger.info(f"Precache already exists: {output_path}") with open(output_path, "r") as f: data = json.load(f) print(json.dumps(data, ensure_ascii=False, indent=2)) return 0 # Generate with Claude Code CLI (up to 3 retries) prompt = build_prompt(target_date) data = None for attempt in range(3): logger.info(f"Attempt {attempt + 1}/3...") data = run_claude_code_cli(prompt, target_date) if data: break logger.warning(f"Attempt {attempt + 1} failed, retrying...") if not data: logger.error("All attempts failed. Precache generation failed.") return 1 # Save save_precache(data, target_date) print(json.dumps(data, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())