diff --git a/README.md b/README.md index e45cc2b8..0a6ce432 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `-- | --- | --- | | `aai login` / `logout` / `whoami` | Manage the stored API key. | | `aai doctor` | Check your environment (API key, network, ffmpeg, microphone, agent tooling). | -| `aai transcribe ` | Transcribe a file, URL, or YouTube URL (`--sample`, `--llm`, `--show-code`). | +| `aai transcribe ` | Transcribe a file, URL, or YouTube/podcast page URL (`--sample`, `--llm`, `--show-code`). | | `aai transcripts list` / `get ` | Browse and fetch past transcripts. | | `aai stream [file]` | Real-time transcription from a file or the microphone. | | `aai agent` | *Run* a live two-way voice conversation (to **build** a voice agent app, use `aai init voice-agent`). | diff --git a/aai_cli/code_gen/transcribe.py b/aai_cli/code_gen/transcribe.py index e191fe95..fe90a953 100644 --- a/aai_cli/code_gen/transcribe.py +++ b/aai_cli/code_gen/transcribe.py @@ -40,10 +40,10 @@ def render( """ if output is not None: llm_gateway = None # `-o` returns before the chain runs in the real command - is_youtube = youtube.is_youtube_url(source) + needs_download = youtube.is_downloadable_url(source) parts = ( - _header_block(llm_gateway, output, is_youtube=is_youtube) - + _transcribe_block(merged, source, is_youtube=is_youtube) + _header_block(llm_gateway, output, needs_download=needs_download) + + _transcribe_block(merged, source, needs_download=needs_download) + _result_block(merged, llm_gateway, output) ) parts.append("") @@ -51,17 +51,17 @@ def render( def _header_block( - llm_gateway: dict[str, object] | None, output: str | None, *, is_youtube: bool + llm_gateway: dict[str, object] | None, output: str | None, *, needs_download: bool ) -> list[str]: """Imports plus the api-key (and non-default environment) settings lines.""" stdlib_imports = ["import os"] - if is_youtube: - # The YouTube path downloads audio to a temp dir before uploading. + if needs_download: + # The download path fetches audio to a temp dir before uploading. stdlib_imports += ["import tempfile"] if output == "json": stdlib_imports.insert(0, "import json") imports = ["import assemblyai as aai"] - if is_youtube: + if needs_download: imports.append("import yt_dlp") if llm_gateway: imports.append("from openai import OpenAI") @@ -81,7 +81,7 @@ def _header_block( return parts -def _transcribe_block(merged: dict[str, object], source: str, *, is_youtube: bool) -> list[str]: +def _transcribe_block(merged: dict[str, object], source: str, *, needs_download: bool) -> list[str]: """The transcriber setup, optional config, the transcribe call, and error check.""" parts = ["", "transcriber = aai.Transcriber()"] config_arg = "" @@ -89,12 +89,12 @@ def _transcribe_block(merged: dict[str, object], source: str, *, is_youtube: boo kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4)) parts += ["", f"config = aai.TranscriptionConfig(\n{kwargs}\n)"] config_arg = ", config=config" - if is_youtube: - # AssemblyAI can't read a YouTube watch URL itself, so download the audio - # with yt-dlp into a temp dir and upload the local file — what the CLI does. + if needs_download: + # AssemblyAI can't read a YouTube/podcast page URL itself, so download the + # audio with yt-dlp into a temp dir and upload the local file — what the CLI does. parts += [ "", - "# AssemblyAI can't fetch a YouTube URL itself; download the audio first.", + "# AssemblyAI can't fetch this page URL itself; download the audio first.", "with tempfile.TemporaryDirectory() as _tmp:", " with yt_dlp.YoutubeDL(", ' {"format": "bestaudio/best", "outtmpl": f"{_tmp}/%(id)s.%(ext)s"}', diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index d650bf15..cbbeee94 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -58,7 +58,7 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None: # Raw PCM16 mono piped on stdin (e.g. `ffmpeg … -f s16le - | aai stream -`). stdin_src = StdinSource(sample_rate=opts.sample_rate or TARGET_RATE) session.run(stdin_src, stdin_src.sample_rate) - elif opts.source and youtube.is_youtube_url(opts.source): + elif opts.source and youtube.is_downloadable_url(opts.source): # Fetch the audio first, then stream the local file in real time. with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: local = youtube.download_audio(opts.source, Path(td)) @@ -101,8 +101,8 @@ def stream( ctx: typer.Context, source: str | None = typer.Argument( None, - help="Audio file path, URL, or YouTube URL to stream. Use - for raw PCM16/mono/16k " - "on stdin. Omit to use the microphone.", + help="Audio file path, URL, or YouTube/podcast page URL to stream. Use - for raw " + "PCM16/mono/16k on stdin. Omit to use the microphone.", ), sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), # audio capture @@ -395,9 +395,9 @@ def body(state: AppState, json_mode: bool) -> None: validate_sources(opts, has_llm=bool(llm_prompt), text_mode=text_mode) if opts.from_system_audio: raise UsageError("--show-code does not support macOS system audio capture yet.") - if opts.source and youtube.is_youtube_url(opts.source): + if opts.source and youtube.is_downloadable_url(opts.source): raise UsageError( - "--show-code does not support YouTube sources yet.", + "--show-code does not support downloaded sources (YouTube, podcast pages) yet.", suggestion="Download the audio first (e.g. yt-dlp) and pass the local file.", ) code_source: str | None = None diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 92c38b4f..977f6efb 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -66,6 +66,7 @@ def _validate_speakers_expected(merged: dict[str, object]) -> None: ("Transcribe a local file", "aai transcribe call.mp3"), ("Try it with the hosted sample", "aai transcribe --sample"), ("Transcribe a YouTube video", "aai transcribe https://youtu.be/dtp6b76pMak"), + ("Transcribe a podcast episode page", 'aai transcribe "https://podcasts.apple.com/…"'), ("Label who said what", "aai transcribe call.mp3 --speaker-labels"), ("Redact PII for compliance", "aai transcribe call.mp3 --redact-pii"), ("Summarize a recording", "aai transcribe call.mp3 --summarization"), @@ -75,7 +76,7 @@ def _validate_speakers_expected(merged: dict[str, object]) -> None: ) def transcribe( ctx: typer.Context, - source: str | None = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."), + source: str | None = typer.Argument(None, help="Audio file, URL, or YouTube/podcast URL."), sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), # model & language speech_model: aai.SpeechModel | None = typer.Option( @@ -357,12 +358,12 @@ def transcribe( help="Print the equivalent Python SDK code and exit (does not transcribe).", ), ) -> None: - """Transcribe an audio file, URL, or YouTube link. + """Transcribe an audio file, URL, or YouTube/podcast link. Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo). - Save with --out FILE, or pipe one field with -o text. A YouTube URL is downloaded - first, then transcribed. + Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page + URLs (any page yt-dlp can extract) are downloaded first, then transcribed. Curated flags cover common features; --config KEY=VALUE and --config-file reach every other field. Analysis (summary, chapters, ...) renders in human mode. diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 45b906b1..3e4a78a7 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -64,7 +64,7 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.section("Your first transcription") api_key = config.resolve_api_key(profile=ctx.profile) source = prompter.text( - "Audio file path or YouTube URL (or press Enter to transcribe a sample clip)", + "Audio file path or YouTube/podcast URL (or press Enter to transcribe a sample clip)", default="", ).strip() label = source or "the sample clip" diff --git a/aai_cli/skills/aai-cli/SKILL.md b/aai_cli/skills/aai-cli/SKILL.md index 1012d985..d8339c87 100644 --- a/aai_cli/skills/aai-cli/SKILL.md +++ b/aai_cli/skills/aai-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: aai-cli -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. +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. --- # AssemblyAI CLI (`aai`) @@ -72,8 +72,8 @@ agent," reach for `aai init voice-agent`, not `aai agent`. - **Build/scaffold an app (transcription, live captions, or a voice agent app)** → `aai init` — see `references/setup.md` -- **Transcribe a file/URL/YouTube, stream live audio, run a live voice agent, or - query the LLM Gateway** → `references/transcription.md` +- **Transcribe a file/URL/YouTube/podcast page, stream live audio, run a live + voice agent, or query the LLM Gateway** → `references/transcription.md` - **Browse past transcripts or streaming sessions** → `references/history.md` - **Sign in/out, identity, balance, usage, rate limits, API keys, audit log** → `references/account.md` diff --git a/aai_cli/skills/aai-cli/references/transcription.md b/aai_cli/skills/aai-cli/references/transcription.md index 03d11468..59ae6bba 100644 --- a/aai_cli/skills/aai-cli/references/transcription.md +++ b/aai_cli/skills/aai-cli/references/transcription.md @@ -4,9 +4,10 @@ Four commands. All accept `--json` (auto-enabled when piped) and `-o/--output` to print a single field. `transcribe`, `stream`, and `agent` accept `--show-code` to print equivalent Python SDK code without calling the API. -## `aai transcribe [SOURCE]` — file / URL / YouTube +## `aai transcribe [SOURCE]` — file / URL / YouTube / podcast page -`SOURCE` is a local file path, public URL, or YouTube URL (downloaded first). +`SOURCE` is a local file path, public URL, or a media-page URL yt-dlp can extract +(YouTube, Apple Podcasts, Spreaker, SoundCloud, …) — those are downloaded first. Use `--sample` for the hosted `wildfires.mp3`. Analysis results (summary, chapters, sentiment, …) render automatically in human mode. @@ -38,7 +39,7 @@ aai transcribe call.mp3 --show-code ## `aai stream [SOURCE]` — live real-time transcription -Omit `SOURCE` to use the microphone; pass a file/URL/YouTube to stream that, or +Omit `SOURCE` to use the microphone; pass a file/URL/media page to stream that, or `--sample`. macOS can capture system audio with `--system-audio` (mic + system) or `--system-audio-only`. diff --git a/aai_cli/transcribe_exec.py b/aai_cli/transcribe_exec.py index 5ab0a4e8..9a0bf859 100644 --- a/aai_cli/transcribe_exec.py +++ b/aai_cli/transcribe_exec.py @@ -69,8 +69,8 @@ def run_transcription( return client.transcribe(api_key, str(local), config=transcription_config) audio = client.resolve_audio_source(source, sample=sample) - if youtube.is_youtube_url(audio): - # Fetch first; AssemblyAI can't read a YouTube watch URL itself. + if youtube.is_downloadable_url(audio): + # Fetch first; AssemblyAI can't read a YouTube/podcast page URL itself. with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: local = youtube.download_audio(audio, Path(td)) return client.transcribe(api_key, str(local), config=transcription_config) diff --git a/aai_cli/youtube.py b/aai_cli/youtube.py index 9c109941..f1ace3b3 100644 --- a/aai_cli/youtube.py +++ b/aai_cli/youtube.py @@ -1,3 +1,9 @@ +"""Downloading audio from media-page URLs (YouTube, podcast pages, …) via yt-dlp. + +The AssemblyAI API fetches direct audio URLs itself; this module handles the URLs it +can't — HTML pages whose audio yt-dlp knows how to extract. +""" + from __future__ import annotations import logging @@ -27,6 +33,34 @@ def is_youtube_url(source: str | None) -> bool: return bool(_YOUTUBE_RE.match(source.strip())) +def is_downloadable_url(source: str | None) -> bool: + """True if `source` is a media-page URL whose audio must be downloaded first. + + YouTube is matched by shape alone — no yt-dlp import needed, so a missing yt-dlp + still routes to ``download_audio``'s install hint. Other http(s) URLs match when + a dedicated yt-dlp extractor claims them (Apple Podcasts, Spreaker, SoundCloud, + …). Direct audio URLs and unknown pages match only yt-dlp's catch-all ``Generic`` + extractor, which is excluded: those pass through untouched for the API to fetch. + """ + if is_youtube_url(source): + return True + url = (source or "").strip() + if not url.startswith(("http://", "https://")): + # Local paths (and other non-URL sources) never need a download; skipping the + # extractor sweep also avoids importing yt-dlp on the common local-file path. + return False + return _ytdlp_extractor_claims(url) + + +def _ytdlp_extractor_claims(url: str) -> bool: + """True if a dedicated (non-``Generic``) yt-dlp extractor matches `url`.""" + try: + from yt_dlp.extractor import gen_extractor_classes + except ImportError: + return False + return any(ie.suitable(url) and ie.ie_key() != "Generic" for ie in gen_extractor_classes()) + + def download_audio(url: str, dest_dir: Path) -> Path: """Download the best audio track of `url` into `dest_dir` and return its path. diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 89aa3487..021da810 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -704,9 +704,9 @@ instead, pipe the text out with -o text | aai llm -f "…". ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ - │ source [SOURCE] Audio file path, URL, or YouTube URL to stream. Use │ - │ - for raw PCM16/mono/16k on stdin. Omit to use the │ - │ microphone. │ + │ source [SOURCE] Audio file path, URL, or YouTube/podcast page URL to │ + │ stream. Use - for raw PCM16/mono/16k on stdin. Omit │ + │ to use the microphone. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --sample Stream the hosted wildfires.mp3 sample. │ @@ -837,20 +837,19 @@ Usage: aai transcribe [OPTIONS] [SOURCE] - Transcribe an audio file, URL, or YouTube link. + Transcribe an audio file, URL, or YouTube/podcast link. Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo). - Save with --out FILE, or pipe one field with -o text. A YouTube URL is - downloaded - first, then transcribed. + Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page + URLs (any page yt-dlp can extract) are downloaded first, then transcribed. Curated flags cover common features; --config KEY=VALUE and --config-file reach every other field. Analysis (summary, chapters, ...) renders in human mode. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ - │ source [SOURCE] Audio file path, public URL, or YouTube URL. │ + │ source [SOURCE] Audio file, URL, or YouTube/podcast URL. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --sample Use the hosted │ @@ -969,6 +968,8 @@ $ aai transcribe --sample Transcribe a YouTube video $ aai transcribe https://youtu.be/dtp6b76pMak + Transcribe a podcast episode page + $ aai transcribe "https://podcasts.apple.com/…" Label who said what $ aai transcribe call.mp3 --speaker-labels Redact PII for compliance diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 70e2bb34..f202efd5 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -118,6 +118,18 @@ def test_transcribe_render_youtube_downloads_before_upload(): assert 'transcribe("https://www.youtube.com' not in code +def test_transcribe_render_podcast_page_downloads_before_upload(): + # Podcast episode pages (extractor-matched, like YouTube) generate the same + # download-first script; the page URL never reaches transcribe(). + url = "https://podcasts.apple.com/us/podcast/some-show/id1535809341?i=1000123456789" + code = code_gen.transcribe({}, source=url) + ast.parse(code) + assert "import yt_dlp" in code + assert f"extract_info({url!r}, download=True)" in code + assert "transcriber.transcribe(_audio)" in code + assert "transcribe('https://podcasts.apple.com" not in code + + def test_transcribe_render_youtube_passes_config_to_local_upload(): # With a config object the download still wraps the upload, and config flows through. code = code_gen.transcribe({"speaker_labels": True}, source="https://youtu.be/abc123") diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index b96be639..8f0643f9 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -34,6 +34,7 @@ def __init__(self, *, select: str = "skip", confirm: bool = True, text: str = "k self._confirm = confirm self._text = text self.confirm_defaults: list[bool] = [] + self.text_titles: list[str] = [] def section(self, title: str) -> None: pass @@ -51,6 +52,7 @@ def select( return self._select def text(self, title: str, *, default: str | None = None) -> str: + self.text_titles.append(title) return self._text @@ -118,10 +120,13 @@ def _fake( monkeypatch.setattr(transcribe_exec, "run_transcription", _fake) monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) status_messages = _capture_status(monkeypatch) - assert sections.first_request(_ScriptedPrompter(text="meeting.mp3"), ctx) is SectionResult.DONE + prompter = _ScriptedPrompter(text="meeting.mp3") + assert sections.first_request(prompter, ctx) is SectionResult.DONE assert seen["source"] == "meeting.mp3" assert seen["sample"] is False assert status_messages == ["Transcribing meeting.mp3…"] + # The prompt advertises every accepted source kind, including podcast pages. + assert any("YouTube/podcast URL" in t for t in prompter.text_titles) def test_first_request_handles_failure(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 1a51c9fa..2e542e19 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -284,6 +284,30 @@ def fake_stream(api_key, source, *, params, **kwargs): assert seen["src"] == str(fake) +def test_stream_podcast_page_url_downloads_then_streams(monkeypatch, tmp_path): + import wave + + config.set_api_key("default", "sk_live") + fake = tmp_path / "episode.wav" + with wave.open(str(fake), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + monkeypatch.setattr("aai_cli.commands.stream.youtube.download_audio", lambda url, d: fake) + seen = {} + + def fake_stream(api_key, source, *, params, **kwargs): + seen["source_type"] = type(source).__name__ + seen["src"] = getattr(source, "source", None) + + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream) + result = runner.invoke(app, ["stream", "https://www.spreaker.com/episode/12345"]) + assert result.exit_code == 0 + assert seen["source_type"] == "FileSource" # streamed the downloaded local file + assert seen["src"] == str(fake) + + def test_stream_show_code_prints_without_streaming(monkeypatch): # Print-only: emits the mic-streaming script, never opens audio or streams, no auth. called = [] @@ -359,10 +383,14 @@ def test_stream_show_code_file_with_mic_flags_rejected(): assert "--sample-rate" in result.output -def test_stream_show_code_rejects_youtube_sources(): - result = runner.invoke(app, ["stream", "https://youtu.be/abc", "--show-code"]) - assert result.exit_code == 2 - assert "YouTube" in result.output +def test_stream_show_code_rejects_downloaded_sources(): + # YouTube and podcast-page URLs both route through the download path, which the + # generated streaming script doesn't model yet. + for url in ("https://youtu.be/abc", "https://www.spreaker.com/episode/12345"): + result = runner.invoke(app, ["stream", url, "--show-code"]) + assert result.exit_code == 2 + assert "downloaded sources" in result.output + assert "YouTube" in result.output def test_stream_show_code_ignores_json_flag(monkeypatch): diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 95314f55..5bb4b893 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -295,6 +295,42 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, mocker, assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file +def test_transcribe_podcast_page_url_downloads_then_transcribes(monkeypatch, mocker, tmp_path): + # A podcast episode page (claimed by a dedicated yt-dlp extractor) routes through + # the same download-first path as YouTube. + _auth() + fake = tmp_path / "episode.m4a" + fake.write_bytes(b"x") + monkeypatch.setattr("aai_cli.transcribe_exec.youtube.download_audio", lambda url, d: fake) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + url = "https://podcasts.apple.com/us/podcast/some-show/id1535809341?i=1000123456789" + result = runner.invoke(app, ["transcribe", url, "--json"]) + assert result.exit_code == 0 + assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file + + +def test_transcribe_direct_audio_url_passes_through_without_download(monkeypatch, mocker): + # The API fetches direct audio URLs itself; no yt-dlp download must happen. + _auth() + + def _no_download(url, d): + raise AssertionError("direct audio URLs must not be downloaded") + + monkeypatch.setattr("aai_cli.transcribe_exec.youtube.download_audio", _no_download) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "https://example.com/episode.mp3", "--json"]) + assert result.exit_code == 0 + assert tx.call_args.args[1] == "https://example.com/episode.mp3" + + def test_transcribe_show_code_prints_without_transcribing(monkeypatch): # Print-only: emits code, never calls the API, needs no auth. called = [] diff --git a/tests/test_youtube.py b/tests/test_youtube.py index 37a10ba8..ad3eda75 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -19,6 +19,37 @@ def test_is_youtube_url_variants(): assert not youtube.is_youtube_url("") +def test_is_downloadable_url_matches_media_pages(): + # YouTube by shape; podcast pages because a dedicated yt-dlp extractor claims them. + assert youtube.is_downloadable_url("https://youtu.be/abc123") + assert youtube.is_downloadable_url( + "https://podcasts.apple.com/us/podcast/some-show/id1535809341?i=1000123456789" + ) + assert youtube.is_downloadable_url("https://www.spreaker.com/episode/12345") + assert youtube.is_downloadable_url("http://www.spreaker.com/episode/12345") + assert youtube.is_downloadable_url(" https://www.spreaker.com/episode/12345 ") + + +def test_is_downloadable_url_passes_direct_and_local_sources_through(): + # Direct audio URLs and unknown pages match only yt-dlp's catch-all Generic + # extractor — the API fetches those itself, so they must not route to a download. + assert not youtube.is_downloadable_url("https://example.com/episode.mp3") + assert not youtube.is_downloadable_url("https://example.com/blog/post") + assert not youtube.is_downloadable_url("/local/file.wav") + assert not youtube.is_downloadable_url("podcasts.apple.com/no-scheme") + assert not youtube.is_downloadable_url(None) + assert not youtube.is_downloadable_url("") + + +def test_is_downloadable_url_without_ytdlp_still_matches_youtube(monkeypatch): + # With yt-dlp unimportable, YouTube still matches by URL shape (so download_audio + # can raise its install hint); extractor-matched hosts degrade to API pass-through. + monkeypatch.setitem(sys.modules, "yt_dlp", None) # force ImportError + monkeypatch.setitem(sys.modules, "yt_dlp.extractor", None) + assert youtube.is_downloadable_url("https://youtu.be/abc123") + assert not youtube.is_downloadable_url("https://www.spreaker.com/episode/12345") + + def _fake_ytdlp(monkeypatch, ydl_cls): monkeypatch.setitem(sys.modules, "yt_dlp", types.SimpleNamespace(YoutubeDL=ydl_cls))