Skip to content

Core API

This is the primary public API for podcast_scraper. Use these functions for programmatic access.

Quick Start

import podcast_scraper

# Create configuration
cfg = podcast_scraper.Config(
    rss="https://example.com/feed.xml",
    output_dir="./transcripts",
    max_episodes=10
)

# Run the pipeline
count, summary = podcast_scraper.run_pipeline(cfg)
print(f"Downloaded {count} transcripts: {summary}")

API Reference

run_pipeline

run_pipeline(cfg: Config) -> Tuple[int, str]

Execute the main podcast scraping pipeline.

This is the primary entry point for programmatic use of podcast_scraper. It orchestrates the complete workflow from RSS feed fetching to transcript generation and optional metadata/summarization.

The pipeline executes the following stages:

  1. Setup output directory (with optional run ID subdirectory)
  2. Fetch and parse RSS feed
  3. Detect speakers (if auto-detection enabled)
  4. Process episodes concurrently:
  5. Download published transcripts
  6. Or queue media for Whisper transcription
  7. Transcribe queued media files sequentially (if Whisper enabled)
  8. Generate metadata documents (if enabled)
  9. Generate episode summaries (if enabled)
  10. Clean up temporary files

Parameters:

Name Type Description Default
cfg Config

Configuration object with all pipeline settings. See Config for available options. Download resilience (HTTP/RSS urllib3 retries, optional episode-level retries, and optional Issue #522 fair-HTTP fields) is controlled via http_retry_*, rss_retry_*, episode_retry_*, host_*, circuit_breaker_*, rss_conditional_get, and rss_cache_dir (defaults and CLI flags are documented in CONFIGURATION.md / CLI.md).

required

Returns:

Type Description
Tuple[int, str]

Tuple[int, str]: A tuple containing:

  • count (int): Number of episodes processed (transcripts saved or planned)
  • summary (str): Human-readable summary message describing the run

Raises:

Type Description
RuntimeError

If output directory cleanup fails when clean_output=True

ValueError

If RSS URL is invalid or feed cannot be parsed

FileNotFoundError

If configuration file references missing files

OSError

If file system operations fail

Example

from podcast_scraper import Config, run_pipeline

cfg = Config( ... rss="https://example.com/feed.xml", ... output_dir="./transcripts", ... max_episodes=10 ... ) count, summary = run_pipeline(cfg) print(f"Downloaded {count} transcripts: {summary}") Downloaded 10 transcripts: Processed 10/50 episodes

Example with Whisper transcription

cfg = Config( ... rss="https://example.com/feed.xml", ... transcribe_missing=True, ... whisper_model="base", ... screenplay=True, ... num_speakers=2 ... ) count, summary = run_pipeline(cfg)

Note

For non-interactive use (daemons, services), consider using the service.run() function instead, which provides structured error handling and return values.

See Also
  • Config: Configuration model with all available options
  • service.run(): Service API with structured error handling
  • load_config_file(): Load configuration from JSON/YAML file
Source code in src/podcast_scraper/workflow/orchestration.py
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
def run_pipeline(cfg: config.Config) -> Tuple[int, str]:
    """Execute the main podcast scraping pipeline.

    This is the primary entry point for programmatic use of podcast_scraper. It orchestrates
    the complete workflow from RSS feed fetching to transcript generation and optional
    metadata/summarization.

    The pipeline executes the following stages:

    1. Setup output directory (with optional run ID subdirectory)
    2. Fetch and parse RSS feed
    3. Detect speakers (if auto-detection enabled)
    4. Process episodes concurrently:
       - Download published transcripts
       - Or queue media for Whisper transcription
    5. Transcribe queued media files sequentially (if Whisper enabled)
    6. Generate metadata documents (if enabled)
    7. Generate episode summaries (if enabled)
    8. Clean up temporary files

    Args:
        cfg: Configuration object with all pipeline settings. See `Config` for available options.
            Download resilience (HTTP/RSS urllib3 retries, optional episode-level retries,
            and optional Issue #522 fair-HTTP fields) is controlled via ``http_retry_*``,
            ``rss_retry_*``, ``episode_retry_*``, ``host_*``, ``circuit_breaker_*``,
            ``rss_conditional_get``, and ``rss_cache_dir`` (defaults and CLI flags are
            documented in CONFIGURATION.md / CLI.md).

    Returns:
        Tuple[int, str]: A tuple containing:

            - count (int): Number of episodes processed (transcripts saved or planned)
            - summary (str): Human-readable summary message describing the run

    Raises:
        RuntimeError: If output directory cleanup fails when `clean_output=True`
        ValueError: If RSS URL is invalid or feed cannot be parsed
        FileNotFoundError: If configuration file references missing files
        OSError: If file system operations fail

    Example:
        >>> from podcast_scraper import Config, run_pipeline
        >>>
        >>> cfg = Config(
        ...     rss="https://example.com/feed.xml",
        ...     output_dir="./transcripts",
        ...     max_episodes=10
        ... )
        >>> count, summary = run_pipeline(cfg)
        >>> print(f"Downloaded {count} transcripts: {summary}")
        Downloaded 10 transcripts: Processed 10/50 episodes

    Example with Whisper transcription:
        >>> cfg = Config(
        ...     rss="https://example.com/feed.xml",
        ...     transcribe_missing=True,
        ...     whisper_model="base",
        ...     screenplay=True,
        ...     num_speakers=2
        ... )
        >>> count, summary = run_pipeline(cfg)

    Example with metadata and summaries:
        >>> cfg = Config(
        ...     rss="https://example.com/feed.xml",
        ...     generate_metadata=True,
        ...     generate_summaries=True
        ... )
        >>> count, summary = run_pipeline(cfg)

    Note:
        For non-interactive use (daemons, services), consider using the `service.run()`
        function instead, which provides structured error handling and return values.

    See Also:
        - `Config`: Configuration model with all available options
        - `service.run()`: Service API with structured error handling
        - `load_config_file()`: Load configuration from JSON/YAML file
    """
    # Install the run-level LLM call fuse for the WHOLE production run, in the main thread, before
    # any stage or worker starts. This is the hard ceiling on total spend: retry_with_metrics ticks
    # it on every attempt, and the fuse is process-global so it is enforced inside the
    # summarization/processing ThreadPoolExecutor workers too (a ContextVar would not reach them).
    # The finer per-episode fuse — which catches a single-episode storm below the run ceiling (the
    # ~3,500-call incident) — is installed per episode in generate_episode_metadata, where that
    # storm (bundled GI evidence → per-pair fallback) actually runs.
    from ..utils import llm_call_fuse

    llm_call_fuse.install_run(getattr(cfg, "llm_max_calls_per_run", 0))

    # GitHub #562: reset gates before setup (setup is outside try/finally below).
    try:
        config.reset_screenplay_issue_562_gates()
    except Exception:  # pragma: no cover - defensive import/cleanup
        logger.debug("reset_screenplay_issue_562_gates (startup) failed", exc_info=True)

    # #1053: resolve this run's correlation id ONCE, up front, so every o11y signal
    # (Loki cost event + logs, Sentry scope, Langfuse trace) for the run stamps the same
    # join key. Process-global because the pipeline is a per-run subprocess.
    from podcast_scraper.utils import correlation

    correlation.set_run_id(correlation.resolve_run_id(cfg.run_id))
    # Mirror the join key onto the Sentry scope so errors correlate too (no-op without Sentry).
    try:
        from podcast_scraper.utils.sentry_init import set_run_tag

        set_run_tag(correlation.get_run_id())
    except Exception:  # pragma: no cover - never block a run on o11y tagging
        logger.debug("sentry run-tag skipped", exc_info=True)

    # Step 1: Setup pipeline environment
    effective_output_dir, run_suffix, full_config_string, pipeline_metrics = (
        _setup_pipeline_environment(cfg)
    )

    # GitHub #557: structured incident log (episode/feed scope); default beside run artifacts.
    if not (cfg.incident_log_path or "").strip():
        cfg = cfg.model_copy(
            update={
                "incident_log_path": str(Path(effective_output_dir) / "corpus_incidents.jsonl"),
            }
        )

    monitor_proc: Optional[Any] = None
    py_spy_stop: Optional[Callable[[], None]] = None
    if cfg.monitor:
        from ..monitor.py_spy_listener import start_py_spy_stdin_listener
        from ..monitor.runner import start_monitor_subprocess

        monitor_proc = start_monitor_subprocess(
            pipeline_pid=os.getpid(),
            output_dir=effective_output_dir,
        )
        py_spy_stop = start_py_spy_stdin_listener(
            output_dir=effective_output_dir,
            enabled=True,
        )

    try:
        from ..gi.deps import validate_gil_grounding_dependencies

        validate_gil_grounding_dependencies(cfg)

        # Initialize JSONL emitter if enabled
        jsonl_emitter = _setup_jsonl_emitter(cfg, effective_output_dir, pipeline_metrics)

        # Step 1.5: Preload ML models if configured
        wf_stages.setup.preload_ml_models_if_needed(cfg)

        # Step 1.6: Create all providers once (singleton pattern per run)
        # Providers are created here and passed to stages to avoid redundant initialization
        transcription_provider, speaker_detector, summary_provider = _create_all_providers(cfg)

        # Step 1.7-1.8: Setup logging and device tracking
        _setup_logging_and_devices(
            cfg, transcription_provider, speaker_detector, summary_provider, pipeline_metrics
        )

        # Step 1.5: Create run manifest
        run_manifest = _create_run_manifest(cfg, effective_output_dir)

        # Step 2-4: Fetch and prepare episodes
        maybe_update_pipeline_status(cfg, effective_output_dir, stage="rss_feed_fetch")
        feed, rss_bytes, feed_metadata, episodes = _fetch_and_prepare_episodes(
            cfg, pipeline_metrics
        )

        # Step 5-6.5: Setup pipeline resources
        normalizing_start, host_detection_result, transcription_resources, processing_resources = (
            _setup_pipeline_resources(
                cfg,
                feed,
                episodes,
                effective_output_dir,
                transcription_provider,
                speaker_detector,
                pipeline_metrics,
            )
        )

        # Wrap processing + finalize: JSONL must stay open until _finalize_pipeline
        # calls emit_run_finished (see _finalize_emit_and_save). Closing the emitter in
        # the inner finally was too early and broke run_finished emission.
        interim_checkpoint_manager = _InterimCheckpointManager(
            cfg=cfg,
            output_dir=effective_output_dir,
            pipeline_metrics=pipeline_metrics,
        )
        try:
            try:
                saved = _process_episodes_with_threading(
                    cfg=cfg,
                    episodes=episodes,
                    feed=feed,
                    effective_output_dir=effective_output_dir,
                    run_suffix=run_suffix,
                    feed_metadata=feed_metadata,
                    host_detection_result=host_detection_result,
                    transcription_resources=transcription_resources,
                    processing_resources=processing_resources,
                    pipeline_metrics=pipeline_metrics,
                    summary_provider=summary_provider,
                    transcription_provider=transcription_provider,
                    normalizing_start=normalizing_start,
                    interim_checkpoint_manager=interim_checkpoint_manager,
                )

            finally:
                interim_checkpoint_manager.stop()
                # Step 9.5: Unload models to free memory
                _cleanup_providers(transcription_resources, summary_provider)

            # Step 10-15: Finalize pipeline (metrics, JSONL run_finished, index, …)
            result = _finalize_pipeline(
                cfg=cfg,
                saved=saved,
                transcription_resources=transcription_resources,
                effective_output_dir=effective_output_dir,
                run_suffix=run_suffix,
                pipeline_metrics=pipeline_metrics,
                episodes=episodes,
                jsonl_emitter=jsonl_emitter,
                run_manifest=run_manifest,
                summary_provider=summary_provider,
                transcription_provider=transcription_provider,
            )
        except BaseException:
            if jsonl_emitter is not None:
                try:
                    jsonl_emitter.__exit__(None, None, None)
                except Exception:
                    pass
            raise

        # Step 16: #1058 chunk 3 — corpus-level Topic clustering.
        # Runs AFTER per-episode finalize so every kg.json is on disk
        # before we collect Topic labels across the corpus. Gated on
        # cfg.kg_topic_corpus_clustering (default off; airgapped*
        # overlays flip it on). Non-fatal — a failure here doesn't
        # bring down a successful run.
        if getattr(cfg, "kg_topic_corpus_clustering", False) and not cfg.dry_run:
            try:
                from pathlib import Path as _Path

                from podcast_scraper.kg.topic_clustering import (
                    cluster_and_apply_corpus_topics,
                )

                summary = cluster_and_apply_corpus_topics(_Path(effective_output_dir))
                logger.info(
                    "corpus topic clustering: clusters=%d concept_topics_added=%d "
                    "related_to_edges_added=%d artifacts_mutated=%d",
                    summary.clusters_found,
                    summary.concept_topics_added,
                    summary.related_to_edges_added,
                    summary.artifacts_mutated,
                )
            except Exception as cluster_exc:
                logger.warning(
                    "corpus topic clustering failed (non-fatal): %s",
                    cluster_exc,
                    exc_info=True,
                )

        maybe_update_pipeline_status(cfg, effective_output_dir, stage="done")
        return result
    finally:
        # GitHub #562: allow coercion INFO + screenplay warnings on the next Config / run.
        try:
            config.reset_screenplay_issue_562_gates()
        except Exception:  # pragma: no cover - defensive import/cleanup
            logger.debug("reset_screenplay_issue_562_gates failed", exc_info=True)
        if py_spy_stop is not None:
            py_spy_stop()
        if monitor_proc is not None:
            monitor_proc.join(timeout=30)
            if monitor_proc.is_alive():
                monitor_proc.terminate()
                monitor_proc.join(timeout=5)

load_config_file

load_config_file(path: str) -> Dict[str, Any]

Load configuration from a JSON or YAML file.

This function reads a configuration file and returns a dictionary of configuration values. The file format is auto-detected from the file extension (.json, .yaml, or .yml).

The returned dictionary can be unpacked into the Config constructor to create a configuration object.

Parameters:

Name Type Description Default
path str

Path to configuration file (JSON or YAML). Supports tilde expansion for home directory (e.g., "~/config.yaml").

required

Returns:

Type Description
Dict[str, Any]

Dict[str, Any]: Dictionary containing configuration values from the file. Keys correspond to Config field names (using aliases where applicable).

Raises:

Type Description
ValueError

If any of the following occur:

  • Config path is empty
  • Config file does not exist
  • File format is invalid (not JSON or YAML)
  • JSON parsing fails
  • YAML parsing fails
OSError

If file cannot be read due to permissions or I/O errors

Example

from podcast_scraper import Config, load_config_file, run_pipeline

Load from YAML file

config_dict = load_config_file("config.yaml") cfg = Config(**config_dict) count, summary = run_pipeline(cfg)

Example with JSON

config_dict = load_config_file("config.json") cfg = Config(**config_dict)

Example with direct usage

from podcast_scraper import load_config_file, service

Service API provides load_config_file convenience

result = service.run_from_config_file("config.yaml")

Supported Formats

JSON (.json):

{
  "rss": "https://example.com/feed.xml",
  "output_dir": "./transcripts",
  "max_episodes": 50
}

YAML (.yaml, .yml):

rss: https://example.com/feed.xml
output_dir: ./transcripts
max_episodes: 50
Note
  • Field aliases are supported (e.g., both "rss" and "rss_url" work)
  • See Config documentation for all available configuration options
  • Configuration files should not contain sensitive data (API keys, passwords)
See Also
  • Config: Configuration model and field documentation
  • service.run_from_config_file(): Direct service API from config file
  • Configuration examples: config/examples/config.example.json, config/examples/config.example.yaml
Source code in src/podcast_scraper/config.py
6662
6663
6664
6665
6666
6667
6668
6669
6670
6671
6672
6673
6674
6675
6676
6677
6678
6679
6680
6681
6682
6683
6684
6685
6686
6687
6688
6689
6690
6691
6692
6693
6694
6695
6696
6697
6698
6699
6700
6701
6702
6703
6704
6705
6706
6707
6708
6709
6710
6711
6712
6713
6714
6715
6716
6717
6718
6719
6720
6721
6722
6723
6724
6725
6726
6727
6728
6729
6730
6731
6732
6733
6734
6735
6736
6737
6738
6739
6740
6741
6742
6743
6744
6745
6746
6747
6748
6749
6750
6751
6752
6753
6754
6755
6756
6757
6758
6759
6760
6761
6762
6763
6764
6765
6766
6767
6768
6769
6770
6771
def load_config_file(
    path: str,
) -> Dict[str, Any]:  # noqa: C901 - file parsing handles multiple formats
    """Load configuration from a JSON or YAML file.

    This function reads a configuration file and returns a dictionary of configuration values.
    The file format is auto-detected from the file extension (`.json`, `.yaml`, or `.yml`).

    The returned dictionary can be unpacked into the `Config` constructor to create a
    configuration object.

    Args:
        path: Path to configuration file (JSON or YAML). Supports tilde expansion for
              home directory (e.g., "~/config.yaml").

    Returns:
        Dict[str, Any]: Dictionary containing configuration values from the file.
            Keys correspond to `Config` field names (using aliases where applicable).

    Raises:
        ValueError: If any of the following occur:

            - Config path is empty
            - Config file does not exist
            - File format is invalid (not JSON or YAML)
            - JSON parsing fails
            - YAML parsing fails

        OSError: If file cannot be read due to permissions or I/O errors

    Example:
        >>> from podcast_scraper import Config, load_config_file, run_pipeline
        >>>
        >>> # Load from YAML file
        >>> config_dict = load_config_file("config.yaml")
        >>> cfg = Config(**config_dict)
        >>> count, summary = run_pipeline(cfg)

    Example with JSON:
        >>> config_dict = load_config_file("config.json")
        >>> cfg = Config(**config_dict)

    Example with direct usage:
        >>> from podcast_scraper import load_config_file, service
        >>>
        >>> # Service API provides load_config_file convenience
        >>> result = service.run_from_config_file("config.yaml")

    Supported Formats:
        **JSON** (`.json`):

            {
              "rss": "https://example.com/feed.xml",
              "output_dir": "./transcripts",
              "max_episodes": 50
            }

        **YAML** (`.yaml`, `.yml`):

            rss: https://example.com/feed.xml
            output_dir: ./transcripts
            max_episodes: 50

    Note:
        - Field aliases are supported (e.g., both "rss" and "rss_url" work)
        - See `Config` documentation for all available configuration options
        - Configuration files should not contain sensitive data (API keys, passwords)

    See Also:
        - `Config`: Configuration model and field documentation
        - `service.run_from_config_file()`: Direct service API from config file
        - Configuration examples: `config/examples/config.example.json`,
          `config/examples/config.example.yaml`
    """
    if not path:
        raise ValueError("Config path cannot be empty")

    cfg_path = Path(path).expanduser()
    try:
        resolved = cfg_path.resolve()
    except (OSError, RuntimeError) as exc:
        raise ValueError(f"Invalid config path: {path} ({exc})") from exc

    if not resolved.exists():
        raise ValueError(f"Config file not found: {resolved}")

    suffix = resolved.suffix.lower()
    try:
        text = resolved.read_text(encoding="utf-8")
    except OSError as exc:
        raise ValueError(f"Failed to read config file {resolved}: {exc}") from exc

    if suffix == ".json":
        try:
            data = json.loads(text)
        except json.JSONDecodeError as exc:
            raise ValueError(f"Invalid JSON config file {resolved}: {exc}") from exc
    elif suffix in (".yaml", ".yml"):
        try:
            data = yaml.safe_load(text)
        except yaml.YAMLError as exc:  # type: ignore[attr-defined]
            raise ValueError(f"Invalid YAML config file {resolved}: {exc}") from exc
    else:
        raise ValueError(f"Unsupported config file type: {resolved.suffix}")

    if not isinstance(data, dict):
        raise ValueError("Config file must contain a mapping/object at the top level")

    expanded = _expand_env_vars(data)
    return cast(Dict[str, Any], expanded)

Package Information

Versioning

__version__ module-attribute

__version__ = '2.7.0.dev0'

__api_version__ module-attribute

__api_version__ = '2.7.0'