Clips page, new episodes, TTS/audio improvements, publish pipeline updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 05:38:58 -06:00
parent 2c7fcdb5ae
commit f7b75fa72f
56 changed files with 4827 additions and 356 deletions

View File

@@ -95,6 +95,47 @@ DB_PASS = os.getenv("CASTOPOD_DB_PASS")
DB_NAME = "castopod"
LOCK_FILE = Path(__file__).parent / ".publish.lock"
PUBLISH_STATE_FILE = Path(__file__).parent / "data" / "publish_state.json"
def _load_publish_state() -> dict:
"""Load publish state tracking which steps completed per episode."""
if PUBLISH_STATE_FILE.exists():
with open(PUBLISH_STATE_FILE) as f:
return json.load(f)
return {}
def _save_publish_state(state: dict):
"""Save publish state."""
PUBLISH_STATE_FILE.parent.mkdir(exist_ok=True)
with open(PUBLISH_STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def _mark_step_done(episode_number: int, step: str, details: dict = None):
"""Mark a publish step as completed for an episode."""
state = _load_publish_state()
key = str(episode_number)
if key not in state:
state[key] = {"steps": {}, "started_at": datetime.now(timezone.utc).isoformat()}
state[key]["steps"][step] = {
"completed_at": datetime.now(timezone.utc).isoformat(),
**(details or {}),
}
_save_publish_state(state)
def _is_step_done(episode_number: int, step: str) -> bool:
"""Check if a publish step was already completed for an episode."""
state = _load_publish_state()
return step in state.get(str(episode_number), {}).get("steps", {})
def _get_step_details(episode_number: int, step: str) -> dict | None:
"""Get details from a completed publish step."""
state = _load_publish_state()
return state.get(str(episode_number), {}).get("steps", {}).get(step)
def get_auth_header():
@@ -541,14 +582,15 @@ def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
return False, str(e)
def _check_episode_exists_in_db(episode_number: int) -> bool:
"""Check if an episode with this number already exists in Castopod DB."""
def _check_episode_exists_in_db(episode_number: int) -> bool | None:
"""Check if an episode with this number already exists in Castopod DB.
Returns True/False on success, None if the check itself failed."""
cmd = (f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} '
f'-N -e "SELECT COUNT(*) FROM cp_episodes WHERE number = {episode_number};"')
success, output = run_ssh_command(cmd)
if success and output.strip():
return int(output.strip()) > 0
return False
return None
def _srt_to_castopod_json(srt_path: str) -> str:
@@ -1022,9 +1064,31 @@ def get_youtube_service():
print(" Warning: YouTube token missing or invalid. Run: python yt_auth.py")
return None
# Warn if token scopes are insufficient (e.g. upload-only, missing youtube scope)
if creds.scopes and not set(YT_SCOPES).issubset(creds.scopes):
missing = set(YT_SCOPES) - set(creds.scopes)
print(f" Warning: YouTube token missing scopes: {missing}")
print(f" Run: python yt_auth.py (to re-authorize with full scopes)")
return yt_build("youtube", "v3", credentials=creds)
def _check_youtube_duplicate(youtube, title: str) -> str | None:
"""Search our channel's uploads for an existing video with this title. Returns video ID if found."""
from googleapiclient.errors import HttpError
try:
response = youtube.search().list(
part="snippet", q=title, type="video",
forMine=True, maxResults=5,
).execute()
for item in response.get("items", []):
if item["snippet"]["title"].strip().lower() == title.strip().lower():
return item["id"]["videoId"]
except HttpError as e:
print(f" Warning: Could not check for YouTube duplicates: {e}")
return None
def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
episode_slug: str) -> str | None:
"""Convert audio to video with cover art, upload to YouTube, add to podcast playlist."""
@@ -1037,14 +1101,23 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
if not youtube:
return None
# Check for existing upload with same title
yt_title = metadata["title"][:100]
existing_id = _check_youtube_duplicate(youtube, yt_title)
if existing_id:
print(f" Video already exists on YouTube: https://youtube.com/watch?v={existing_id}")
print(f" Skipping duplicate upload")
return existing_id
cover_art = Path(__file__).parent / "website" / "images" / "cover.png"
video_path = Path(audio_path).with_suffix(".yt.mp4")
# Convert MP3 + cover art to MP4
# Convert MP3 + cover art to MP4 (pad to 1920x1080 for YouTube compatibility)
print(" Converting audio to video...")
result = subprocess.run([
"ffmpeg", "-y", "-loop", "1",
"-i", str(cover_art), "-i", audio_path,
"-vf", "scale=-1:1080,pad=1920:1080:(ow-iw)/2:0:black",
"-c:v", "libx264", "-tune", "stillimage",
"-c:a", "aac", "-b:a", "192k",
"-pix_fmt", "yuv420p", "-shortest",
@@ -1221,12 +1294,20 @@ def main():
print(f"Episode number: {episode_number}")
# Guard against duplicate publish
if not args.dry_run and _check_episode_exists_in_db(episode_number):
print(f"Error: Episode {episode_number} already exists in Castopod. "
f"Use --episode-number to specify a different number, or remove the existing episode first.")
lock_fp.close()
LOCK_FILE.unlink(missing_ok=True)
sys.exit(1)
if not args.dry_run:
exists = _check_episode_exists_in_db(episode_number)
if exists is None:
print(f"Error: Could not reach Castopod DB to check for duplicates. "
f"Aborting to prevent duplicate uploads. Fix NAS connectivity and retry.")
lock_fp.close()
LOCK_FILE.unlink(missing_ok=True)
sys.exit(1)
if exists:
print(f"Error: Episode {episode_number} already exists in Castopod. "
f"Use --episode-number to specify a different number, or remove the existing episode first.")
lock_fp.close()
LOCK_FILE.unlink(missing_ok=True)
sys.exit(1)
# Load session data if provided
session_data = None
@@ -1291,6 +1372,7 @@ def main():
# Step 3: Create episode
direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT
episode = create_episode(str(audio_path), metadata, episode_number, duration=transcript["duration"])
_mark_step_done(episode_number, "castopod", {"episode_id": episode["id"], "slug": episode.get("slug")})
# Step 3.5: Upload to BunnyCDN
print("[3.5/5] Uploading to BunnyCDN...")
@@ -1388,15 +1470,26 @@ def main():
print(f" Warning: Website deploy failed: {deploy_result.stderr[:200]}")
# Step 5.5: Upload to YouTube
print("[5.5] Uploading to YouTube...")
yt_video_id = upload_to_youtube(
str(audio_path), metadata, metadata["chapters"], episode["slug"]
)
yt_step = _get_step_details(episode_number, "youtube")
if yt_step:
yt_video_id = yt_step.get("video_id")
print(f"[5.5] YouTube upload already done: https://youtube.com/watch?v={yt_video_id}")
else:
print("[5.5] Uploading to YouTube...")
yt_video_id = upload_to_youtube(
str(audio_path), metadata, metadata["chapters"], episode["slug"]
)
if yt_video_id:
_mark_step_done(episode_number, "youtube", {"video_id": yt_video_id})
# Step 5.7: Generate social image and post
social_image_path = str(audio_path.with_suffix(".social.jpg"))
generate_social_image(episode_number, metadata["description"], social_image_path)
post_to_social(metadata, episode["slug"], social_image_path)
if _is_step_done(episode_number, "social"):
print("[5.7] Social posting already done, skipping")
else:
social_image_path = str(audio_path.with_suffix(".social.jpg"))
generate_social_image(episode_number, metadata["description"], social_image_path)
post_to_social(metadata, episode["slug"], social_image_path)
_mark_step_done(episode_number, "social")
# Step 6: Summary
print("\n[6/6] Done!")