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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user