Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions src/podtx/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("-")
Expand All @@ -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


Expand Down
41 changes: 39 additions & 2 deletions tests/test_naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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})
Expand Down
Loading