Skip to content

Commit 8ff4175

Browse files
committed
Transcribe and stream podcast pages (any yt-dlp-extractable URL)
The YouTube download path was gated on a YouTube-specific URL regex, but yt-dlp (already a hard dependency) ships dedicated extractors for podcast hosts (Apple Podcasts, Spreaker, SoundCloud, iHeartRadio, ...). Add youtube.is_downloadable_url(): YouTube still matches by shape alone (so a missing yt-dlp keeps its install hint), and any other http(s) URL routes through the download-first path when a dedicated (non-Generic) extractor claims it. Direct audio URLs and unknown pages still pass through for the API to fetch itself. Wire the new predicate into `aai transcribe`, `aai stream`, and `transcribe --show-code` (which now generates the yt-dlp download block for podcast pages too); `stream --show-code` rejects all downloaded sources with one message. Update help text, onboarding prompt, README, and the aai-cli skill docs to advertise podcast-page support. https://claude.ai/code/session_01RovWF8h4r427GboMXqRoub
1 parent e2bef7e commit 8ff4175

15 files changed

Lines changed: 193 additions & 44 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `--
8989
| --- | --- |
9090
| `aai login` / `logout` / `whoami` | Manage the stored API key. |
9191
| `aai doctor` | Check your environment (API key, network, ffmpeg, microphone, agent tooling). |
92-
| `aai transcribe <file\|url>` | Transcribe a file, URL, or YouTube URL (`--sample`, `--llm`, `--show-code`). |
92+
| `aai transcribe <file\|url>` | Transcribe a file, URL, or YouTube/podcast page URL (`--sample`, `--llm`, `--show-code`). |
9393
| `aai transcripts list` / `get <id>` | Browse and fetch past transcripts. |
9494
| `aai stream [file]` | Real-time transcription from a file or the microphone. |
9595
| `aai agent` | *Run* a live two-way voice conversation (to **build** a voice agent app, use `aai init voice-agent`). |

aai_cli/code_gen/transcribe.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,28 +40,28 @@ def render(
4040
"""
4141
if output is not None:
4242
llm_gateway = None # `-o` returns before the chain runs in the real command
43-
is_youtube = youtube.is_youtube_url(source)
43+
needs_download = youtube.is_downloadable_url(source)
4444
parts = (
45-
_header_block(llm_gateway, output, is_youtube=is_youtube)
46-
+ _transcribe_block(merged, source, is_youtube=is_youtube)
45+
_header_block(llm_gateway, output, needs_download=needs_download)
46+
+ _transcribe_block(merged, source, needs_download=needs_download)
4747
+ _result_block(merged, llm_gateway, output)
4848
)
4949
parts.append("")
5050
return "\n".join(parts)
5151

5252

5353
def _header_block(
54-
llm_gateway: dict[str, object] | None, output: str | None, *, is_youtube: bool
54+
llm_gateway: dict[str, object] | None, output: str | None, *, needs_download: bool
5555
) -> list[str]:
5656
"""Imports plus the api-key (and non-default environment) settings lines."""
5757
stdlib_imports = ["import os"]
58-
if is_youtube:
59-
# The YouTube path downloads audio to a temp dir before uploading.
58+
if needs_download:
59+
# The download path fetches audio to a temp dir before uploading.
6060
stdlib_imports += ["import tempfile"]
6161
if output == "json":
6262
stdlib_imports.insert(0, "import json")
6363
imports = ["import assemblyai as aai"]
64-
if is_youtube:
64+
if needs_download:
6565
imports.append("import yt_dlp")
6666
if llm_gateway:
6767
imports.append("from openai import OpenAI")
@@ -81,20 +81,20 @@ def _header_block(
8181
return parts
8282

8383

84-
def _transcribe_block(merged: dict[str, object], source: str, *, is_youtube: bool) -> list[str]:
84+
def _transcribe_block(merged: dict[str, object], source: str, *, needs_download: bool) -> list[str]:
8585
"""The transcriber setup, optional config, the transcribe call, and error check."""
8686
parts = ["", "transcriber = aai.Transcriber()"]
8787
config_arg = ""
8888
if merged:
8989
kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4))
9090
parts += ["", f"config = aai.TranscriptionConfig(\n{kwargs}\n)"]
9191
config_arg = ", config=config"
92-
if is_youtube:
93-
# AssemblyAI can't read a YouTube watch URL itself, so download the audio
94-
# with yt-dlp into a temp dir and upload the local file — what the CLI does.
92+
if needs_download:
93+
# AssemblyAI can't read a YouTube/podcast page URL itself, so download the
94+
# audio with yt-dlp into a temp dir and upload the local file — what the CLI does.
9595
parts += [
9696
"",
97-
"# AssemblyAI can't fetch a YouTube URL itself; download the audio first.",
97+
"# AssemblyAI can't fetch this page URL itself; download the audio first.",
9898
"with tempfile.TemporaryDirectory() as _tmp:",
9999
" with yt_dlp.YoutubeDL(",
100100
' {"format": "bestaudio/best", "outtmpl": f"{_tmp}/%(id)s.%(ext)s"}',

aai_cli/commands/stream.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None:
5858
# Raw PCM16 mono piped on stdin (e.g. `ffmpeg … -f s16le - | aai stream -`).
5959
stdin_src = StdinSource(sample_rate=opts.sample_rate or TARGET_RATE)
6060
session.run(stdin_src, stdin_src.sample_rate)
61-
elif opts.source and youtube.is_youtube_url(opts.source):
61+
elif opts.source and youtube.is_downloadable_url(opts.source):
6262
# Fetch the audio first, then stream the local file in real time.
6363
with tempfile.TemporaryDirectory(prefix="aai-yt-") as td:
6464
local = youtube.download_audio(opts.source, Path(td))
@@ -101,8 +101,8 @@ def stream(
101101
ctx: typer.Context,
102102
source: str | None = typer.Argument(
103103
None,
104-
help="Audio file path, URL, or YouTube URL to stream. Use - for raw PCM16/mono/16k "
105-
"on stdin. Omit to use the microphone.",
104+
help="Audio file path, URL, or YouTube/podcast page URL to stream. Use - for raw "
105+
"PCM16/mono/16k on stdin. Omit to use the microphone.",
106106
),
107107
sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."),
108108
# audio capture
@@ -395,9 +395,9 @@ def body(state: AppState, json_mode: bool) -> None:
395395
validate_sources(opts, has_llm=bool(llm_prompt), text_mode=text_mode)
396396
if opts.from_system_audio:
397397
raise UsageError("--show-code does not support macOS system audio capture yet.")
398-
if opts.source and youtube.is_youtube_url(opts.source):
398+
if opts.source and youtube.is_downloadable_url(opts.source):
399399
raise UsageError(
400-
"--show-code does not support YouTube sources yet.",
400+
"--show-code does not support downloaded sources (YouTube, podcast pages) yet.",
401401
suggestion="Download the audio first (e.g. yt-dlp) and pass the local file.",
402402
)
403403
code_source: str | None = None

aai_cli/commands/transcribe.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def _validate_speakers_expected(merged: dict[str, object]) -> None:
6666
("Transcribe a local file", "aai transcribe call.mp3"),
6767
("Try it with the hosted sample", "aai transcribe --sample"),
6868
("Transcribe a YouTube video", "aai transcribe https://youtu.be/dtp6b76pMak"),
69+
("Transcribe a podcast episode page", 'aai transcribe "https://podcasts.apple.com/…"'),
6970
("Label who said what", "aai transcribe call.mp3 --speaker-labels"),
7071
("Redact PII for compliance", "aai transcribe call.mp3 --redact-pii"),
7172
("Summarize a recording", "aai transcribe call.mp3 --summarization"),
@@ -75,7 +76,7 @@ def _validate_speakers_expected(merged: dict[str, object]) -> None:
7576
)
7677
def transcribe(
7778
ctx: typer.Context,
78-
source: str | None = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."),
79+
source: str | None = typer.Argument(None, help="Audio file, URL, or YouTube/podcast URL."),
7980
sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."),
8081
# model & language
8182
speech_model: aai.SpeechModel | None = typer.Option(
@@ -357,12 +358,12 @@ def transcribe(
357358
help="Print the equivalent Python SDK code and exit (does not transcribe).",
358359
),
359360
) -> None:
360-
"""Transcribe an audio file, URL, or YouTube link.
361+
"""Transcribe an audio file, URL, or YouTube/podcast link.
361362
362363
Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo).
363364
364-
Save with --out FILE, or pipe one field with -o text. A YouTube URL is downloaded
365-
first, then transcribed.
365+
Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page
366+
URLs (any page yt-dlp can extract) are downloaded first, then transcribed.
366367
367368
Curated flags cover common features; --config KEY=VALUE and --config-file reach
368369
every other field. Analysis (summary, chapters, ...) renders in human mode.

aai_cli/onboard/sections.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult:
6464
prompter.section("Your first transcription")
6565
api_key = config.resolve_api_key(profile=ctx.profile)
6666
source = prompter.text(
67-
"Audio file path or YouTube URL (or press Enter to transcribe a sample clip)",
67+
"Audio file path or YouTube/podcast URL (or press Enter to transcribe a sample clip)",
6868
default="",
6969
).strip()
7070
label = source or "the sample clip"

aai_cli/skills/aai-cli/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: aai-cli
3-
description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold a starter app (init); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command.
3+
description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube/podcast links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold a starter app (init); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command.
44
---
55

66
# AssemblyAI CLI (`aai`)
@@ -72,8 +72,8 @@ agent," reach for `aai init voice-agent`, not `aai agent`.
7272

7373
- **Build/scaffold an app (transcription, live captions, or a voice agent app)**
7474
`aai init` — see `references/setup.md`
75-
- **Transcribe a file/URL/YouTube, stream live audio, run a live voice agent, or
76-
query the LLM Gateway**`references/transcription.md`
75+
- **Transcribe a file/URL/YouTube/podcast page, stream live audio, run a live
76+
voice agent, or query the LLM Gateway**`references/transcription.md`
7777
- **Browse past transcripts or streaming sessions**`references/history.md`
7878
- **Sign in/out, identity, balance, usage, rate limits, API keys, audit log**
7979
`references/account.md`

aai_cli/skills/aai-cli/references/transcription.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ Four commands. All accept `--json` (auto-enabled when piped) and `-o/--output`
44
to print a single field. `transcribe`, `stream`, and `agent` accept
55
`--show-code` to print equivalent Python SDK code without calling the API.
66

7-
## `aai transcribe [SOURCE]` — file / URL / YouTube
7+
## `aai transcribe [SOURCE]` — file / URL / YouTube / podcast page
88

9-
`SOURCE` is a local file path, public URL, or YouTube URL (downloaded first).
9+
`SOURCE` is a local file path, public URL, or a media-page URL yt-dlp can extract
10+
(YouTube, Apple Podcasts, Spreaker, SoundCloud, …) — those are downloaded first.
1011
Use `--sample` for the hosted `wildfires.mp3`. Analysis results (summary,
1112
chapters, sentiment, …) render automatically in human mode.
1213

@@ -38,7 +39,7 @@ aai transcribe call.mp3 --show-code
3839

3940
## `aai stream [SOURCE]` — live real-time transcription
4041

41-
Omit `SOURCE` to use the microphone; pass a file/URL/YouTube to stream that, or
42+
Omit `SOURCE` to use the microphone; pass a file/URL/media page to stream that, or
4243
`--sample`. macOS can capture system audio with `--system-audio` (mic + system)
4344
or `--system-audio-only`.
4445

aai_cli/transcribe_exec.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ def run_transcription(
6969
return client.transcribe(api_key, str(local), config=transcription_config)
7070

7171
audio = client.resolve_audio_source(source, sample=sample)
72-
if youtube.is_youtube_url(audio):
73-
# Fetch first; AssemblyAI can't read a YouTube watch URL itself.
72+
if youtube.is_downloadable_url(audio):
73+
# Fetch first; AssemblyAI can't read a YouTube/podcast page URL itself.
7474
with tempfile.TemporaryDirectory(prefix="aai-yt-") as td:
7575
local = youtube.download_audio(audio, Path(td))
7676
return client.transcribe(api_key, str(local), config=transcription_config)

aai_cli/youtube.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
"""Downloading audio from media-page URLs (YouTube, podcast pages, …) via yt-dlp.
2+
3+
The AssemblyAI API fetches direct audio URLs itself; this module handles the URLs it
4+
can't — HTML pages whose audio yt-dlp knows how to extract.
5+
"""
6+
17
from __future__ import annotations
28

39
import logging
@@ -27,6 +33,34 @@ def is_youtube_url(source: str | None) -> bool:
2733
return bool(_YOUTUBE_RE.match(source.strip()))
2834

2935

36+
def is_downloadable_url(source: str | None) -> bool:
37+
"""True if `source` is a media-page URL whose audio must be downloaded first.
38+
39+
YouTube is matched by shape alone — no yt-dlp import needed, so a missing yt-dlp
40+
still routes to ``download_audio``'s install hint. Other http(s) URLs match when
41+
a dedicated yt-dlp extractor claims them (Apple Podcasts, Spreaker, SoundCloud,
42+
…). Direct audio URLs and unknown pages match only yt-dlp's catch-all ``Generic``
43+
extractor, which is excluded: those pass through untouched for the API to fetch.
44+
"""
45+
if is_youtube_url(source):
46+
return True
47+
url = (source or "").strip()
48+
if not url.startswith(("http://", "https://")):
49+
# Local paths (and other non-URL sources) never need a download; skipping the
50+
# extractor sweep also avoids importing yt-dlp on the common local-file path.
51+
return False
52+
return _ytdlp_extractor_claims(url)
53+
54+
55+
def _ytdlp_extractor_claims(url: str) -> bool:
56+
"""True if a dedicated (non-``Generic``) yt-dlp extractor matches `url`."""
57+
try:
58+
from yt_dlp.extractor import gen_extractor_classes
59+
except ImportError:
60+
return False
61+
return any(ie.suitable(url) and ie.ie_key() != "Generic" for ie in gen_extractor_classes())
62+
63+
3064
def download_audio(url: str, dest_dir: Path) -> Path:
3165
"""Download the best audio track of `url` into `dest_dir` and return its path.
3266

tests/__snapshots__/test_cli_output_snapshots.ambr

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -704,9 +704,9 @@
704704
instead, pipe the text out with -o text | aai llm -f "…".
705705

706706
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
707-
│ source [SOURCE] Audio file path, URL, or YouTube URL to stream. Use
708-
│ - for raw PCM16/mono/16k on stdin. Omit to use the
709-
microphone.
707+
│ source [SOURCE] Audio file path, URL, or YouTube/podcast page URL to │
708+
stream. Use - for raw PCM16/mono/16k on stdin. Omit │
709+
to use the microphone.
710710
╰──────────────────────────────────────────────────────────────────────────────╯
711711
╭─ Options ────────────────────────────────────────────────────────────────────╮
712712
│ --sample Stream the hosted wildfires.mp3 sample. │
@@ -837,20 +837,19 @@
837837

838838
Usage: aai transcribe [OPTIONS] [SOURCE]
839839

840-
Transcribe an audio file, URL, or YouTube link.
840+
Transcribe an audio file, URL, or YouTube/podcast link.
841841

842842
Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo).
843843

844-
Save with --out FILE, or pipe one field with -o text. A YouTube URL is
845-
downloaded
846-
first, then transcribed.
844+
Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page
845+
URLs (any page yt-dlp can extract) are downloaded first, then transcribed.
847846

848847
Curated flags cover common features; --config KEY=VALUE and --config-file
849848
reach
850849
every other field. Analysis (summary, chapters, ...) renders in human mode.
851850

852851
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
853-
│ source [SOURCE] Audio file path, public URL, or YouTube URL. │
852+
│ source [SOURCE] Audio file, URL, or YouTube/podcast URL.
854853
╰──────────────────────────────────────────────────────────────────────────────╯
855854
╭─ Options ────────────────────────────────────────────────────────────────────╮
856855
│ --sample Use the hosted │
@@ -969,6 +968,8 @@
969968
$ aai transcribe --sample
970969
Transcribe a YouTube video
971970
$ aai transcribe https://youtu.be/dtp6b76pMak
971+
Transcribe a podcast episode page
972+
$ aai transcribe "https://podcasts.apple.com/…"
972973
Label who said what
973974
$ aai transcribe call.mp3 --speaker-labels
974975
Redact PII for compliance

0 commit comments

Comments
 (0)