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