Recover real episode metadata on --resume instead of rebuilding from the slug

A --resume run rebuilt the episode title from the Castopod URL slug, which is
lowercased and stripped of punctuation, so episode 58 went to YouTube as
"Episode 58: Rayfield S Nephew The Marfa Lights And Why Nobody Believes Concho"
with a placeholder description. save_chapters() only persisted chapters, so
resume had no real metadata to fall back on.

Metadata is now written to <audio>.metadata.json during a normal run and read
back on resume. If that file is missing (episodes published before this change)
the title and description are read from Castopod instead. Slug reconstruction
survives only as a last resort: it warns loudly, flags the result, and anchors
the episode prefix strip so it no longer eats the phrase mid-title.

Reading back from Castopod needed its own fix — TO_BASE64 wraps every 76 chars
and mysql renders those breaks as a literal backslash-n, which made b64decode
throw and the lookup silently return None.

Also includes two changes that were already sitting in the working tree: the
YouTube tag budget fix that episode 58's invalidTags failure prompted (with its
test), and a metadata model bump to claude-haiku-4.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 02:54:29 -05:00
co-authored by Claude Opus 5
parent 1c7ac334b4
commit 00ddc2d1b4
3 changed files with 363 additions and 13 deletions
+140
View File
@@ -0,0 +1,140 @@
"""A --resume run must not invent the episode title.
Episode 58's first run published to Castopod, then died on the YouTube upload
(see test_youtube_tags.py). The re-run with --resume rebuilt the title from the
URL slug, which is lowercase and punctuation-free, so
"Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
went to YouTube as
"Episode 58: Rayfield S Nephew The Marfa Lights And Why Nobody Believes Concho"
with the description replaced by a placeholder.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from publish_episode import recover_metadata, save_metadata, _decode_db_row
EP58_TITLE = "Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
EP58_DESC = "Rayfield calls about his nephew and the catalytic converters."
CHAPTERS = [{"startTime": 0, "title": "Intro"}]
def test_metadata_file_roundtrips_losslessly(tmp_path):
path = tmp_path / "ep58.metadata.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
assert meta["title"] == EP58_TITLE
assert meta["description"] == EP58_DESC
def test_metadata_file_wins_over_slug(tmp_path):
"""The exact ep58 regression: apostrophes, commas and case must survive."""
path = tmp_path / "ep58.metadata.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
assert "Rayfield S Nephew" not in meta["title"]
assert "Rayfield's Nephew" in meta["title"]
assert "the Marfa Lights" in meta["title"]
assert meta["description"] != "Episode 58 of Luke at the Roost."
def test_falls_back_to_castopod_db_when_file_missing(tmp_path):
path = tmp_path / "missing.metadata.json"
calls = []
def db_lookup(episode_number):
calls.append(episode_number)
return {"title": EP58_TITLE, "description": EP58_DESC}
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS, db_lookup=db_lookup)
assert calls == [58]
assert meta["title"] == EP58_TITLE
assert meta["description"] == EP58_DESC
def test_slug_fallback_only_when_file_and_db_unavailable(tmp_path, capsys):
path = tmp_path / "missing.metadata.json"
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS,
db_lookup=lambda n: None)
assert meta["title"].startswith("Episode 58: ")
warning = capsys.readouterr().out
assert "lossy" in warning.lower() or "warning" in warning.lower()
def test_slug_fallback_does_not_title_case_away_real_words(tmp_path):
"""Even degraded, the fallback should not be silently trusted."""
meta = recover_metadata(58, EP58_SLUG, tmp_path / "nope.json", CHAPTERS,
db_lookup=lambda n: None)
assert meta.get("title_is_reconstructed") is True
def test_episode_prefix_stripped_only_at_start(tmp_path):
"""A global replace would also eat the phrase inside the title."""
slug = "episode-5-the-episode-5-mixup"
meta = recover_metadata(5, slug, tmp_path / "nope.json", CHAPTERS,
db_lookup=lambda n: None)
assert meta["title"].lower().count("episode 5") == 2
def test_chapters_come_from_chapters_json_not_metadata_file(tmp_path):
path = tmp_path / "ep58.metadata.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": [{"startTime": 999, "title": "Stale"}],
"thumbnail_text": "X"}, str(path))
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
assert meta["chapters"] == CHAPTERS
def test_decodes_base64_wrapped_by_mariadb():
"""TO_BASE64 wraps at 76 chars and mysql renders those breaks as a literal
backslash-n, which b64decode rejects outright."""
import base64 as b64
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
encoded = b64.b64encode(payload.encode()).decode()
wrapped = "\\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
assert "\\n" in wrapped, "test needs a payload long enough to wrap"
row = _decode_db_row(wrapped)
assert row["title"] == EP58_TITLE
assert row["description"] == EP58_DESC
def test_decodes_base64_with_real_newlines():
import base64 as b64
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
encoded = b64.b64encode(payload.encode()).decode()
wrapped = "\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
assert _decode_db_row(wrapped)["title"] == EP58_TITLE
def test_decode_db_row_returns_none_on_garbage():
assert _decode_db_row("") is None
assert _decode_db_row("not base64 at all !!!") is None
def test_save_metadata_keeps_only_publishable_fields(tmp_path):
path = tmp_path / "m.json"
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD",
"transcript": "huge blob that should not be persisted"}, str(path))
saved = json.loads(path.read_text())
assert "transcript" not in saved
assert saved["title"] == EP58_TITLE
+79
View File
@@ -0,0 +1,79 @@
"""YouTube rejects the whole upload with `invalidTags` if the tag list busts
its 500-character budget. Any tag containing a space gets wrapped in quotes and
those quotes count, so the naive sum of tag lengths understates the real cost.
Episode 58 failed here after a 284 MB upload had already completed.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from publish_episode import _extract_youtube_tags, YOUTUBE_TAG_BUDGET
def tag_cost(tags):
"""Mirror YouTube's accounting: quotes around multi-word tags, plus commas."""
return sum(len(t) + (2 if " " in t else 0) for t in tags) + max(0, len(tags) - 1)
# The real chapter titles from episode 58, which produced a 534-char tag list.
EP58_CHAPTERS = [
{"title": "Intro & Election Fraud Voicemail"},
{"title": "Rayfield: Nephew Stealing Catalytic Converters"},
{"title": "Suki & the Marfa Lights"},
{"title": "Aurora Toothbrush Sponsor"},
{"title": "Gus: Partner's Unlogged Stop at Meth House"},
{"title": "Merritt: Son Stealing from Family Store"},
{"title": "Concho: Mysterious Water Source Surveyors"},
{"title": "Desmond: Colleague's Research Misconduct"},
{"title": "Iron Heart Survival School Sponsor"},
{"title": "Fern: Donald Judd's Aluminum Boxes in Marfa"},
{"title": "Outro & Show Reflection"},
]
def test_episode_58_tags_fit_the_budget():
"""The exact input that broke the ep58 upload."""
tags = _extract_youtube_tags({"chapters": EP58_CHAPTERS})
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET, (
f"{tag_cost(tags)} chars > {YOUTUBE_TAG_BUDGET} budget: {tags}"
)
def test_pathological_long_titles_still_fit():
chapters = [{"title": "A" * 49 + f" {i}"} for i in range(40)]
tags = _extract_youtube_tags({"chapters": chapters})
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET, tag_cost(tags)
assert len(tags) <= 25
def test_no_chapters_still_returns_base_tags():
tags = _extract_youtube_tags({"chapters": []})
assert tags, "should still return the base SEO tags"
assert tag_cost(tags) <= YOUTUBE_TAG_BUDGET
def test_base_tags_are_prioritised_over_chapter_titles():
"""Base SEO tags matter more than chapter titles; they must survive trimming."""
chapters = [{"title": "B" * 48 + f" {i}"} for i in range(30)]
tags = _extract_youtube_tags({"chapters": chapters})
assert "podcast" in tags
assert "Luke at the Roost" in tags
def test_angle_brackets_are_stripped():
"""YouTube rejects tags containing < or >."""
chapters = [{"title": "Weird <script> Chapter"}]
tags = _extract_youtube_tags({"chapters": chapters})
assert not any("<" in t or ">" in t for t in tags), tags
def test_skips_intro_outro_and_short_titles():
chapters = [{"title": "Intro"}, {"title": "Outro"}, {"title": "ab"},
{"title": "A Real Chapter Title"}]
tags = _extract_youtube_tags({"chapters": chapters})
assert "Intro" not in tags and "Outro" not in tags and "ab" not in tags
assert "A Real Chapter Title" in tags