feat: add Spotify publish metadata config (#464)

* feat: add Spotify publish metadata config Adds spotify_publish section to podcast.json with title/description templates, season/episode number templates, and draft publish mode. Closes #463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: forward spotify_publish config in Podcaster handoff payload The build_payload function only forwarded podcast_config and script_directions but not the new spotify_publish section, so Podcaster would never receive it. Add forwarding + validation, and a test assertion. Resolves Copilot review comment on PR #464. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Resolve spotify publish templates in handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Resolve spotify publish output fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: catch ValueError/IndexError in spotify_publish template rendering Addresses PR #464 review comments: - Catch ValueError/IndexError from malformed format strings in _render_template_value (was only catching KeyError) - Add docstring to _resolve_spotify_publish explaining the design decision to render templates before sending to Podcaster - Add test for malformed template error path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 13, 2026 at 21:09 UTC 4f61bcb5f8a5bf826dc8335b6b85330da22ebf5b
3 files changed +175 -11
config/podcast.json
+7
@@ -55,5 +55,12 @@
55 "play_to_end": true
56 }
57 }
58 + },
59 + "spotify_publish": {
60 + "title_template": "{year}-W{week}: {article_title}",
61 + "description_template": "<p>{article_summary}</p><p>Read more at <a href=\"https://claracle.com\" target=\"_blank\" rel=\"ugc noopener noreferrer\">Claracle</a></p><p><br /></p><p>Intro and Outro: Summer Sport by AudioCoffee | https://www.audiocoffee.net/ Music promoted by https://www.chosic.com/free-music/all/ Creative Commons CC BY-SA 3.0 https://creativecommons.org/licenses/by-sa/3.0/</p>",
62 + "season_number": "{year}",
63 + "episode_number": "{week}",
64 + "publish_mode": "draft"
65 }
66 }
scripts/podcaster_handoff.py
+92 -8
@@ -16,6 +16,8 @@ DEFAULT_TIMEOUT_SECONDS = 180
16 DEFAULT_PODCAST_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "podcast.json"
17 REPO_ROOT = Path(__file__).resolve().parent.parent
18 MAX_ARTICLE_CONTENT_CHARS = 50_000
19 +MAX_SPOTIFY_TITLE_CHARS = 200
20 +MAX_SPOTIFY_DESCRIPTION_CHARS = 4_000
21
22
23 class PodcasterHandoffError(RuntimeError):
@@ -145,11 +147,80 @@ def _extract_title(content: str) -> str | None:
147 return None
148
149
148 -def _read_article_content(article_path: str, repo_root: Path = REPO_ROOT) -> tuple[str | None, str | None]:
150 +def _extract_frontmatter_field(content: str, field_name: str) -> str | None:
151 + if not content.startswith("---"):
152 + return None
153 + end = content.find("\n---", 3)
154 + if end == -1:
155 + return None
156 + frontmatter = content[3:end]
157 + match = re.search(rf"^{re.escape(field_name)}:\s*(.+)$", frontmatter, re.MULTILINE)
158 + if not match:
159 + return None
160 + value = match.group(1).strip().strip("\"'")
161 + return value or None
162 +
163 +
164 +def _render_template_value(value: Any, context: dict[str, Any]) -> Any:
165 + if isinstance(value, dict):
166 + return {key: _render_template_value(item, context) for key, item in value.items()}
167 + if isinstance(value, list):
168 + return [_render_template_value(item, context) for item in value]
169 + if not isinstance(value, str):
170 + return value
171 + exact_match = re.fullmatch(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", value)
172 + if exact_match:
173 + key = exact_match.group(1)
174 + if key in context:
175 + return context[key]
176 + try:
177 + return value.format(**context)
178 + except KeyError as exc:
179 + missing = exc.args[0]
180 + raise PodcasterHandoffError(f"spotify_publish template references unknown field: {missing}") from exc
181 + except (ValueError, IndexError) as exc:
182 + raise PodcasterHandoffError(f"spotify_publish template has invalid format syntax: {value!r}") from exc
183 +
184 +
185 +def _truncate_text(value: str, limit: int) -> str:
186 + return value[:limit]
187 +
188 +
189 +def _resolve_spotify_publish(config: dict[str, Any], *, week: str, article_title: str | None, article_summary: str | None) -> dict[str, Any]:
190 + """Render spotify_publish templates into concrete values for the Podcaster API.
191 +
192 + Design: SquadScope resolves templates (title_template, description_template)
193 + into final strings before sending. Podcaster receives ready-to-use metadata,
194 + not raw templates — this keeps rendering logic in the source-of-truth repo.
195 + """
196 + match = re.fullmatch(r"(?P<year>\d{4})-W(?P<week>\d{1,2})", week)
197 + if not match:
198 + raise PodcasterHandoffError(f"Week must use YYYY-WNN format for spotify_publish templating: {week}")
199 + context: dict[str, Any] = {
200 + "year": int(match.group("year")),
201 + "week": int(match.group("week")),
202 + "article_title": article_title or "",
203 + "article_summary": article_summary or "",
204 + }
205 + resolved = _render_template_value(config, context)
206 + title = resolved.pop("title_template", None)
207 + if isinstance(title, str):
208 + resolved["title"] = _truncate_text(title, MAX_SPOTIFY_TITLE_CHARS)
209 + elif title is not None:
210 + resolved["title"] = title
211 + description = resolved.pop("description_template", None)
212 + if isinstance(description, str):
213 + resolved["description"] = _truncate_text(description, MAX_SPOTIFY_DESCRIPTION_CHARS)
214 + elif description is not None:
215 + resolved["description"] = description
216 + return resolved
217 +
218 +
219 +def _read_article_content(article_path: str, repo_root: Path = REPO_ROOT) -> tuple[str | None, str | None, str | None]:
220 """Read article file content and extract title.
221
151 - Returns (content, title). Content is truncated to MAX_ARTICLE_CONTENT_CHARS.
152 - Returns (None, None) if the file does not exist.
222 + Returns (content, title, summary). Content is truncated to MAX_ARTICLE_CONTENT_CHARS.
223 + Returns (None, None, None) if the file does not exist.
224 Raises PodcasterHandoffError if the file exists but cannot be read, or if
225 the resolved path escapes the repo root (path traversal prevention).
226 """
@@ -162,7 +233,7 @@ def _read_article_content(article_path: str, repo_root: Path = REPO_ROOT) -> tup
233 f"article_path resolves outside the repository root: {article_path}"
234 )
235 if not resolved.exists():
165 - return None, None
236 + return None, None, None
237 try:
238 content = resolved.read_text(encoding="utf-8")
239 except OSError as exc:
@@ -170,11 +241,12 @@ def _read_article_content(article_path: str, repo_root: Path = REPO_ROOT) -> tup
241 f"Article file exists but could not be read: {resolved} ({exc})"
242 )
243 if not content.strip():
173 - return None, None
174 - title = _extract_title(content)
244 + return None, None, None
245 + title = _extract_frontmatter_field(content, "title") or _extract_title(content)
246 + summary = _extract_frontmatter_field(content, "summary")
247 if len(content) > MAX_ARTICLE_CONTENT_CHARS:
248 content = content[:MAX_ARTICLE_CONTENT_CHARS]
177 - return content, title
249 + return content, title, summary
250
251
252 def _manifest_allows_handoff(manifest: dict[str, Any], *, week: str, publish_mode: str) -> bool:
@@ -220,11 +292,13 @@ def build_payload(
292
293 # Read article content and extract title
294 root = repo_root if repo_root is not None else REPO_ROOT
223 - content, title = _read_article_content(normalized_path, repo_root=root)
295 + content, title, summary = _read_article_content(normalized_path, repo_root=root)
296 if content:
297 payload["article_content"] = content
298 if title:
299 payload["article_title"] = title
300 + if summary:
301 + payload["article_summary"] = summary
302 article_sha = (
303 manifest.get("candidate", {}).get("summary_sha256")
304 if isinstance(manifest.get("candidate"), dict)
@@ -247,6 +321,16 @@ def build_payload(
321 if not isinstance(val, dict):
322 raise PodcasterHandoffError("script_directions must be a JSON object")
323 payload["script_directions"] = val
324 + if "spotify_publish" in podcast_cfg:
325 + val = podcast_cfg["spotify_publish"]
326 + if not isinstance(val, dict):
327 + raise PodcasterHandoffError("spotify_publish must be a JSON object")
328 + payload["spotify_publish"] = _resolve_spotify_publish(
329 + val,
330 + week=week,
331 + article_title=title,
332 + article_summary=summary,
333 + )
334
335 if podcaster_dry_run:
336 payload["dry_run"] = True
tests/test_podcaster_handoff.py
+76 -3
@@ -53,7 +53,10 @@ class PodcasterHandoffTests(unittest.TestCase):
53 article_dir = Path(tmpdir) / "content" / "weekly" / "2026"
54 article_dir.mkdir(parents=True)
55 article_file = article_dir / "W23.md"
56 - article_file.write_text("---\ntitle: Week 23 Report\n---\n# Heading\nBody content here.\n", encoding="utf-8")
56 + article_file.write_text(
57 + "---\ntitle: Week 23 Report\nsummary: Week 23 summary.\n---\n# Heading\nBody content here.\n",
58 + encoding="utf-8",
59 + )
60
61 payload = podcaster_handoff.build_payload(
62 week="2026-W23",
@@ -83,6 +86,7 @@ class PodcasterHandoffTests(unittest.TestCase):
86 # article_content and article_title from the article file
87 self.assertIn("article_content", payload)
88 self.assertIn("Week 23 Report", payload["article_title"])
89 + self.assertEqual(payload["article_summary"], "Week 23 summary.")
90 self.assertIn("Body content here.", payload["article_content"])
91
92 def test_podcaster_dry_run_sets_payload_flag(self) -> None:
@@ -166,6 +170,10 @@ class PodcasterHandoffTests(unittest.TestCase):
170 self.assertEqual(sent_payload["podcast_config"]["name"], "Claracle")
171 self.assertIn("script_directions", sent_payload)
172 self.assertIn("music_mix", sent_payload["script_directions"])
173 + self.assertIn("spotify_publish", sent_payload)
174 + self.assertEqual(sent_payload["spotify_publish"]["publish_mode"], "draft")
175 + self.assertIsInstance(sent_payload["spotify_publish"]["season_number"], int)
176 + self.assertIsInstance(sent_payload["spotify_publish"]["episode_number"], int)
177 self.assertNotIn("super-secret-value", stdout.getvalue())
178
179 def test_non_normal_publish_mode_skips_without_calling_podcaster(self) -> None:
@@ -309,7 +317,10 @@ class PodcasterHandoffTests(unittest.TestCase):
317 article_dir = base / "content" / "weekly" / "2026"
318 article_dir.mkdir(parents=True)
319 article = article_dir / "W24.md"
312 - article.write_text("---\ntitle: My Title\n---\n# Heading\nHello world.\n", encoding="utf-8")
320 + article.write_text(
321 + "---\ntitle: My Title\nsummary: My summary.\n---\n# Heading\nHello world.\n",
322 + encoding="utf-8",
323 + )
324
325 payload = podcaster_handoff.build_payload(
326 week="2026-W24",
@@ -322,8 +333,70 @@ class PodcasterHandoffTests(unittest.TestCase):
333 )
334
335 self.assertEqual(payload["article_title"], "My Title")
336 + self.assertEqual(payload["article_summary"], "My summary.")
337 self.assertIn("Hello world.", payload["article_content"])
326 - self.assertIn("---\ntitle: My Title\n---", payload["article_content"])
338 + self.assertIn("---\ntitle: My Title\nsummary: My summary.\n---", payload["article_content"])
339 +
340 + def test_build_payload_resolves_spotify_publish_templates(self) -> None:
341 + with tempfile.TemporaryDirectory() as tmpdir:
342 + base = Path(tmpdir)
343 + article_dir = base / "content" / "weekly" / "2026"
344 + article_dir.mkdir(parents=True)
345 + article = article_dir / "W24.md"
346 + article.write_text(
347 + "---\ntitle: Skills Go Vertical\nsummary: This week we explore agent skills.\n---\n# Heading\nHello world.\n",
348 + encoding="utf-8",
349 + )
350 +
351 + payload = podcaster_handoff.build_payload(
352 + week="2026-W24",
353 + article_url="https://example.com/weekly/2026/w24/",
354 + article_path="content/weekly/2026/W24.md",
355 + publish_run_id="999",
356 + publish_mode="normal",
357 + podcaster_dry_run=True,
358 + repo_root=base,
359 + )
360 +
361 + self.assertEqual(
362 + payload["spotify_publish"]["title"],
363 + "2026-W24: Skills Go Vertical",
364 + )
365 + self.assertIn("This week we explore agent skills.", payload["spotify_publish"]["description"])
366 + self.assertEqual(payload["spotify_publish"]["season_number"], 2026)
367 + self.assertEqual(payload["spotify_publish"]["episode_number"], 24)
368 +
369 + def test_build_payload_truncates_resolved_spotify_publish_fields(self) -> None:
370 + with tempfile.TemporaryDirectory() as tmpdir:
371 + base = Path(tmpdir)
372 + article_dir = base / "content" / "weekly" / "2026"
373 + article_dir.mkdir(parents=True)
374 + article = article_dir / "W24.md"
375 + article.write_text(
376 + f"---\ntitle: {'T' * 250}\nsummary: {'S' * 5000}\n---\n# Heading\nHello world.\n",
377 + encoding="utf-8",
378 + )
379 +
380 + payload = podcaster_handoff.build_payload(
381 + week="2026-W24",
382 + article_url="https://example.com/weekly/2026/w24/",
383 + article_path="content/weekly/2026/W24.md",
384 + publish_run_id="999",
385 + publish_mode="normal",
386 + podcaster_dry_run=True,
387 + repo_root=base,
388 + )
389 +
390 + self.assertEqual(len(payload["spotify_publish"]["title"]), 200)
391 + self.assertTrue(payload["spotify_publish"]["title"].startswith("2026-W24: "))
392 + self.assertEqual(len(payload["spotify_publish"]["description"]), 4000)
393 + self.assertTrue(payload["spotify_publish"]["description"].startswith("<p>"))
394 +
395 + def test_render_template_value_raises_on_malformed_format_string(self) -> None:
396 + context = {"year": 2026, "week": 24}
397 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError) as cm:
398 + podcaster_handoff._render_template_value("{year}-W{week}: {unclosed", context)
399 + self.assertIn("invalid format syntax", str(cm.exception))
400
401 def test_build_payload_extracts_title_from_heading_when_no_frontmatter(self) -> None:
402 with tempfile.TemporaryDirectory() as tmpdir: