diff --git a/README.md b/README.md index c8adb69..0dae700 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ podtx format --all --readable --cleanup - Data (SQLite state, transcripts, temp audio): `~/.local/share/podcast-transcriber/` - Optional config: `~/.config/podcast-transcriber/config.toml` +- Transcript filenames: `{YYYY-MM-DD}_{episode:03d}_{slug}`. Episode comes from + RSS `itunes:episode` when present; otherwise a clear leading number in the title + is used (`860 - …`, `#860 …`, `Episode 860: …`). Section-style ids like `1.1 - …` + are ignored (fallback `000`). ```toml engine = "parakeet" diff --git a/src/podtx/naming.py b/src/podtx/naming.py index 2d70b46..cb0068d 100644 --- a/src/podtx/naming.py +++ b/src/podtx/naming.py @@ -7,6 +7,18 @@ _SLUG_RE = re.compile(r"[^a-z0-9]+") +# "Episode 860:", "Ep. 25", "Ep 9 — …" +_EPISODE_WORD = re.compile( + r"(?i)^\s*(?:episode|ep\.?)\s*#?\s*(\d+)\b", +) +# "#860 …" +_HASH_NUM = re.compile(r"^\s*#\s*(\d+)\b") +# "860 - Title" / "860: Title" / "860 | Title" / "860 — Title" +# Reject section ids like "1.1 - …" / "5.6.3 …" via (?!\.\d). +_LEADING_NUM = re.compile( + r"^\s*(\d+)(?!\.\d)\s*[-–—|:]\s+\S", +) + def slugify(text: str, *, max_length: int = 80) -> str: slug = _SLUG_RE.sub("-", text.lower()).strip("-") @@ -15,9 +27,28 @@ def slugify(text: str, *, max_length: int = 80) -> str: return slug[:max_length].rstrip("-") +def parse_episode_number_from_title(title: str) -> int | None: + """Extract a leading episode number from a title, if clearly present. + + Recognizes patterns like ``860 - …``, ``#860 …``, ``Episode 860: …``. + Rejects section-style ids such as ``1.1 - …`` / ``5.6.3 …``. + """ + if not title or not title.strip(): + return None + for pattern in (_EPISODE_WORD, _HASH_NUM, _LEADING_NUM): + match = pattern.search(title) + if match: + return int(match.group(1)) + return None + + def episode_number(episode: Episode) -> int: + """Prefer RSS ``itunes:episode``; else parse a clear number from the title.""" if episode.episode_num is not None and episode.episode_num >= 0: return episode.episode_num + parsed = parse_episode_number_from_title(episode.title) + if parsed is not None and parsed >= 0: + return parsed return 0 diff --git a/tests/test_naming.py b/tests/test_naming.py index 2ad0627..f406b39 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -3,7 +3,13 @@ from datetime import datetime, timezone from podtx.models import Episode -from podtx.naming import slugify, transcript_basename, unique_basename +from podtx.naming import ( + episode_number, + parse_episode_number_from_title, + slugify, + transcript_basename, + unique_basename, +) def _ep(**kwargs: object) -> Episode: @@ -26,10 +32,41 @@ def test_transcript_basename_padded_episode() -> None: assert transcript_basename(_ep()) == "2026-03-15_003_interview-with-ada" -def test_transcript_basename_missing_episode() -> None: +def test_transcript_basename_missing_episode_without_number_in_title() -> None: assert transcript_basename(_ep(episode_num=None)) == "2026-03-15_000_interview-with-ada" +def test_parse_episode_number_from_title_common_patterns() -> None: + assert parse_episode_number_from_title("860 - Module Federation") == 860 + assert parse_episode_number_from_title("860: New APIs") == 860 + assert parse_episode_number_from_title("#702 Potluck") == 702 + assert parse_episode_number_from_title("Episode 122: The Bitter Lesson") == 122 + assert parse_episode_number_from_title("Ep. 25 Why ML Needs a New Language") == 25 + assert parse_episode_number_from_title("Ep 9 — Inside look") == 9 + + +def test_parse_episode_number_from_title_rejects_section_style() -> None: + # "1.1 - Introduction" / "5.6.3 and …" are section ids, not episode numbers. + assert parse_episode_number_from_title("1.1 - Introduction to Software Engineering") is None + assert parse_episode_number_from_title( + "5.6.3 and 5.6.4 - Dependency Inversion" + ) is None + assert parse_episode_number_from_title("Hasty Treat - The Future of Testing") is None + assert parse_episode_number_from_title("") is None + + +def test_episode_number_prefers_rss_over_title() -> None: + ep = _ep(episode_num=3, title="860 - Module Federation") + assert episode_number(ep) == 3 + + +def test_episode_number_falls_back_to_title_when_rss_missing() -> None: + ep = _ep(episode_num=None, title="860 - Module Federation") + assert episode_number(ep) == 860 + # Slug still includes title text as-is (slug rules unchanged). + assert transcript_basename(ep) == "2026-03-15_860_860-module-federation" + + def test_unique_basename_collision() -> None: base = transcript_basename(_ep()) uniq = unique_basename(_ep(), existing={base})