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):
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
"""
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:
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:
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)
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