# TuneIn Scraper: Radio Stations, Podcasts & Stream URLs (`abotapi/tunein-radio-podcast-scraper`) Actor

Scrape the TuneIn directory by keyword, category, genre, location or pasted link. Returns stations and podcasts with artwork, genre, bitrate, call sign, frequency, location, language, website, contact, playable stream URLs and episode lists. Incremental mode tracks changes.

- **URL**: https://apify.com/abotapi/tunein-radio-podcast-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:**
- **Stats:** 1 total users, 0 monthly users, 81.3% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.20 / 1,000 directory results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## TuneIn Radio and Podcast Directory Scraper

Turn the TuneIn directory into a structured dataset: live radio stations, podcasts and
podcast episodes, with artwork, genre, stream quality, dial position, location, language,
the broadcaster's own website and published contact details, playable stream addresses and
full episode lists.

Works worldwide, on every Apify plan, with no account on the site and no setup.

***

### What you can do with it

| You want | Use |
|---|---|
| Every station matching a keyword or call sign | **Search** mode, `queries: ["jazz"]` |
| The popular stations near a city | **Browse** mode, `browseCategories: ["local"]` + `localeLatLon: "51.5074,-0.1278"` |
| A whole genre or category chart | **Browse** mode, `browseCategories: ["music"]` or `browseIds: ["g11"]` |
| Every station in a country or region | **Browse** mode, `browseCategories: ["location"]` |
| Podcasts in a topic, with their episodes | **Browse** mode, `browseCategories: ["podcast"]` + `fetchEpisodes: true` |
| One station or podcast you already know | **URL** mode, paste the link or the `guideId` |
| Only what changed since yesterday | Any mode + `incrementalMode: true` |

***

### Modes

#### Search

One lookup per keyword. Returns the stations, podcasts and episodes TuneIn matches to the
term. A keyword lookup is **complete in a single request**: the directory returns its whole
match set at once and publishes no further pages for it, so `maxPages` does not apply here.

```json
{ "mode": "search", "queries": ["jazz", "BBC Radio 4", "true crime"], "maxItems": 50 }
```

#### Browse

Walks TuneIn's own verticals and any genre, category or region address. Most top level
categories are pages of **sub-categories** rather than pages of stations, so the walk opens
sub-categories down to `maxBrowseDepth` (default 2). A category that lists stations directly,
such as Local radio, is never expanded further.

```json
{
  "mode": "browse",
  "browseCategories": ["local"],
  "localeLatLon": "40.7128,-74.0060",
  "minReliabilityPercent": 90,
  "maxItems": 100
}
```

`localeLatLon` applies to **Local radio only**, because that is the one category that is
about a place; the others are worldwide and would silently ignore it. Leave it empty and the
location of the connection the run goes out on is used instead.

#### URL

Paste TuneIn station, podcast and episode addresses, `tun.in` share links, or the bare
`guideId` values this actor returns. Category and genre addresses are walked like a browse
scope. In URL mode the full profile is **always** read, because a pasted link has nothing
else to read, and that lookup is **not** charged.

```json
{
  "mode": "url",
  "urls": [
    "https://tunein.com/station/?stationId=34682",
    "https://tunein.com/radio/Some-Station-s24777/",
    "s67855",
    "c57943"
  ]
}
```

***

### Filters

Every filter applies in all three modes. TuneIn publishes no filter for any of them on the
routes this actor reads, so all five are applied to the results that come back.

| Filter | What it does |
|---|---|
| `itemTypes` | Keep only stations, podcasts and/or episodes |
| `minBitrateKbps` | Keep stations streaming at or above a bitrate |
| `minReliabilityPercent` | Keep stations whose uptime score is at or above a percentage |
| `streamFormats` | Keep stations offering a codec, for example `mp3` or `aac` |
| `genreIds` | Keep results filed under a genre id, for example `g11` |

The three stream-quality filters apply to **stations only**. A podcast publishes no bitrate,
no reliability score and no codec, so "podcasts at 128 kbps or better" would otherwise return
nothing at all; instead podcasts and episodes pass those three filters untouched.

`genreIds` excludes episodes while `fetchDetails` is off, because an episode's listing row
publishes no genre. Its profile does, and the filter is re-applied after enrichment, so
switching `fetchDetails` on makes episodes filterable by genre like everything else.

***

### Extra lookups and what they cost

Everything TuneIn's own listings carry is returned on **every** result at no extra cost:
name, artwork, genre id, bitrate, reliability score, stream formats, and what is playing
right now.

Three toggles each cost one extra request per result:

| Toggle | Adds | Applies to |
|---|---|---|
| `fetchDetails` | Description, slogan, call sign, dial frequency and band, location and coordinates, language, timezone, website, published contact details, on-air status | All results |
| `fetchStreams` | Playable stream addresses with bitrate, codec and reliability | Stations only |
| `fetchEpisodes` | Published episode list with titles, dates and running times | Podcasts only |

A result that receives **at least one** of them is billed a **single** Detail enrichment
event, whichever combination it received. A listing-only row is never billed one.

Two lookups are never even sent, because they cannot produce real data:

- A **podcast is never asked for a stream.** TuneIn answers that request with its own spoken
  "this content is not compatible" clip, at a healthy bitrate and reliability score, which
  would make every podcast look like a working radio station.
- A **station is never asked for an episode list.**

***

### Output

One flat record per station, podcast or episode. Fields an entity kind does not have are
present and `null` rather than missing, so the dataset opens cleanly as a table.

```json
{
  "itemType": "station",
  "guideId": "s000000",
  "name": "Example Jazz Radio",
  "subtitle": "The city's jazz station",
  "description": "An illustrative record. Values below are made up, not scraped.",
  "slogan": "All jazz, all day",
  "image": "https://cdn-profiles.tunein.com/s000000/images/logoq.png",
  "url": "https://tunein.com/station/?stationId=000000",
  "websiteUrl": "https://www.example-radio.org/",
  "callSign": "KXMP-HD2",
  "frequency": "88",
  "band": "FM",
  "genreId": "g11",
  "genreName": "Jazz",
  "location": "Springfield, US",
  "latitude": null,
  "longitude": null,
  "regionId": "r100014",
  "timezone": "Pacific",
  "timezoneOffsetMinutes": -420,
  "language": "English",
  "bitrateKbps": 128,
  "reliabilityPercent": 100,
  "streamFormats": ["mp3"],
  "isAvailable": true,
  "isMusic": true,
  "contentClassification": "music",
  "nowPlaying": "Blue in Green",
  "currentArtist": "Miles Davis",
  "email": "studio@example-radio.org",
  "phone": "+1-202-555-0100",
  "mailingAddress": "1 Example Street, Springfield",
  "twitterId": "ExampleJazz",
  "streams": [
    { "url": "https://stream.example-radio.org/live_128k", "bitrateKbps": 128,
      "mediaType": "mp3", "reliabilityPercent": 100, "isDirect": true, "position": 0 }
  ],
  "streamCount": 1,
  "episodes": [],
  "episodeCount": 0,
  "sourceMode": "search",
  "sourceScope": "jazz",
  "scrapedAt": "2026-01-01T00:00:00Z"
}
```

> The sample above is **illustrative**. The station, its address, its e-mail and its phone
> number are invented for the documentation and are not values this actor returned.

A podcast record carries `episodes[]`, `episodeCount`, `hosts` and no bitrate. An episode
record carries `durationSeconds`, `publishedLabel` and `parentShowId`.

#### Broadcaster contact details

`email`, `phone` and `mailingAddress` are the **broadcaster's own published business
contact**, exactly as TuneIn publishes it on the station's public profile page. They are
returned only when `fetchDetails` is on, and they are never written to the run log.

***

### Resume and recurring updates

Two different things, in one input section.

**`resumeFromRunId`** continues ONE interrupted run. Paste a previous run or dataset id and
results already collected there are skipped, not re-returned and not re-billed.

**`incrementalMode`** is for scheduling. The actor remembers the previous run of the same
scope itself, keyed on a hash of the mode, scope, filters and lookup settings (or on your own
`stateKey`), and classifies each result:

| `changeType` | Meaning |
|---|---|
| `NEW` | First time this scope has seen it |
| `UPDATED` | A tracked field changed; `changedFields` names them |
| `REAPPEARED` | It was gone, and is listed again |
| `UNCHANGED` | Returned only with `emitUnchanged: true` |
| `EXPIRED` | Returned only with `emitExpired: true` |

#### What deliberately does NOT count as a change

These fields are returned on every row but are excluded from change detection, so they never
appear in `changedFields` and never re-bill a row on their own:

- `nowPlaying`, `nowPlayingImage`, `currentSong`, `currentArtist`, `currentAlbum`: what is on
  air at the instant of the request. On a live station this turns over every few minutes.
- `reliabilityPercent`: a rolling uptime score TuneIn recomputes continuously; it drifts by a
  point or two with no event behind it. The same score inside each `streams[]` row is excluded
  for the same reason. **A station going off air is still reported**, through `isAvailable`
  flipping to `false`, which is tracked.
- `scrapedAt`, `sourceMode`, `sourceScope`: these describe the run, not the entity.

Everything else, including a podcast's `episodes` list, is tracked: a new episode marks its
podcast `UPDATED` and names `episodes` in `changedFields`.

`EXPIRED` rows are only produced after a run has **fully scanned** the tracked scope. A run
that hit `maxItems`, used Resume, had a request refused, or collected nothing at all never
tombstones anything.

***

### Limits

| Input | Default | Meaning |
|---|---|---|
| `maxItems` | 20 | The run's cap, and the only limit on how much it returns or costs. `0` for unlimited. |
| `maxPages` | 0 (no limit) | Listing pages per browsed or pasted category. Does not apply to keyword search. |
| `maxBrowseDepth` | 2 | How many levels of sub-categories a browse may open. A scope choice, not a cost cap. |
| `maxEpisodesPerPodcast` | 20 | Episodes returned inside each podcast record. `0` for all. |

`maxItems` is shared fairly across scopes: picking two categories returns results from both,
and a scope that comes back short hands its unused share to the ones after it.

***

### Reliability

- Every request retries at most **4 times** with a short timeout, then fails fast. A run that
  cannot get an answer stops and says so rather than grinding.
- A run that emits nothing **because its requests were refused** fails loudly with an
  explanatory status message. It never renders as "succeeded, 0 results".
- TuneIn answers a rejected keyword or an unknown category with a **200 and an in-band error**
  over an empty body. This actor reads that as a rejection and says which value was rejected,
  rather than reporting it as an empty directory.
- If per-result lookups start failing systematically mid-run, they are switched off for the
  rest of the run, the remaining results are still returned with their listing fields, and
  they are **not** billed the enrichment surcharge.

### Connection

The standard connection, included on every Apify plan, is the default and is enough for this
site. A residential option is available if you specifically want one, and the actor never
selects a paid one on its own.

### Local development

```bash
python3.12 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python -m pytest tests/ -q
apify run --purge
```

# Actor input Schema

## `mode` (type: `string`):

Choose 'search' to look results up by keyword, 'browse' to walk the directory's own categories, genres and locations, or 'url' to scrape pasted links and directory ids.

## `queries` (type: `array`):

Only used when mode = search, ignored in the other modes. One or more keywords, for example 'jazz', 'BBC Radio 4', 'true crime' or a call sign such as 'KEXP'.

## `browseCategories` (type: `array`):

Only used when mode = browse, ignored in the other modes. Local radio is the directory's own locale popular chart and is the one category that responds to Locale coordinates below. Selecting several returns results from all of them.

## `browseIds` (type: `array`):

Optional, browse mode only. Directory ids for a specific genre, category or region, for example g11 (Jazz), c57943 (Top 40 and Pop) or r101216 (Asia). Every result this actor returns carries its own guideId, and the ids shown in the By location and category listings can be pasted straight back in here.

## `localeLatLon` (type: `string`):

Optional, browse mode only, and applied to the Local radio category alone because the other categories are worldwide. Latitude and longitude separated by a comma, for example 51.5074,-0.1278 for London or 40.7128,-74.0060 for New York. Leave empty to use the location of the connection the run goes out on.

## `urls` (type: `array`):

Only used when mode = url, ignored in the other modes. Mix freely: tunein.com station and podcast addresses, tun.in share links, and bare directory ids such as s34682, p1008289 or c57943. In this mode the full profile is always read, so a pasted link never returns an empty shell.

## `itemTypes` (type: `array`):

Optional. Keep only these kinds of result. Leave empty for all three. Stations are live radio, podcasts are shows with an episode archive, and episodes are the individual instalments of a podcast.

## `minBitrateKbps` (type: `integer`):

Optional. Keep only stations streaming at or above this bitrate, for example 128 for CD-like quality. Applies to stations only: podcasts and episodes publish no bitrate and are never dropped by this filter. Use 0 for no bitrate requirement.

## `minReliabilityPercent` (type: `integer`):

Optional. Keep only stations whose uptime score is at or above this percentage. The directory scores every station on how often its stream actually answers, so 90 and above is a working station. Applies to stations only. Use 0 for no reliability requirement.

## `streamFormats` (type: `array`):

Optional. Keep only stations offering at least one of these audio formats, written as the directory writes them, for example mp3 or aac. Applies to stations only. Leave empty for any format.

## `genreIds` (type: `array`):

Optional. Keep only results the directory files under one of these genre ids, for example g11 for Jazz. Every result returns its own genreId, so one run's output tells you which ids to filter on next. An episode listing publishes no genre, so with Fetch full profiles off this filter excludes episodes; switch profiles on and episodes carry their own genre and are filtered on it like everything else.

## `fetchDetails` (type: `boolean`):

Read each result's published profile: description, slogan, call sign, dial frequency and band, location and coordinates, language, timezone, the broadcaster's own website, its published contact details, and whether the stream is currently available. One extra request per result. Ignored in URL mode, where the profile is always read because a pasted link has nothing else to read, and is not charged there.

## `fetchStreams` (type: `boolean`):

Add each station's playable stream addresses with their bitrate, codec and reliability. One extra request per station. Only stations have a stream, so podcasts and episodes are never asked for one and are never charged for it.

## `fetchEpisodes` (type: `boolean`):

Add each podcast's published episode list with titles, publication labels, running times and per episode links. One extra request per podcast. Only podcasts have an episode list, so stations and episodes are never asked for one and are never charged for it.

## `maxEpisodesPerPodcast` (type: `integer`):

Cap on how many episodes are returned inside each podcast record, newest first. Use 0 for every episode the directory publishes for that podcast.

## `maxItems` (type: `integer`):

Maximum number of results to return across the whole run. This is the run's cap, and the only setting that limits how much the run returns or costs. Use 0 for unlimited.

## `maxPages` (type: `integer`):

Maximum number of listing pages read per browsed category or pasted category link. 0 means no limit, which is the default: the walk then stops only at Max results or at the last page the directory publishes. Does not apply to keyword search, which the directory answers in a single page.

## `maxBrowseDepth` (type: `integer`):

How many levels of sub-categories the walk may open when a category page holds only sub-categories and no results of its own, which is how most top level categories are published. 0 keeps to the exact addresses you named and returns nothing for those categories. This is a scope choice, not a cost cap: Max results above is what limits the size of the run.

## `resumeFromRunId` (type: `string`):

Paste a previous run ID or dataset ID to continue a large directory pull without returning or charging for results already collected there. Use this after an interrupted run. For recurring daily monitoring of the same scope, use Incremental mode below instead.

## `incrementalMode` (type: `boolean`):

Turn this on for daily or recurring monitoring. The first run returns every matching result as NEW. Later runs normally return only NEW, UPDATED and REAPPEARED results. What is playing right now and the rolling reliability score drift constantly, so they never count as a change on their own; a station going off air is reported through isAvailable instead. Turn on Emit unchanged or Emit expired only when you also want those rows returned (and billed). State is kept separately for each scope, filter and lookup setup; use State key to name or deliberately share a monitoring campaign.

## `stateKey` (type: `string`):

Optional. Name this monitoring campaign to keep its state stable, or to deliberately share state across differently configured runs. Leave empty to let the actor derive a key automatically from the mode, scope, filters and lookup settings, so different scopes never mix state with each other.

## `emitUnchanged` (type: `boolean`):

Off by default. Turn on to also return results that have not changed since the last run, marked UNCHANGED. This returns, and bills, extra rows you already have, so leave it off unless you want the full snapshot every run.

## `emitExpired` (type: `boolean`):

Off by default. Turn on to also return results that were listed in a previous run but are no longer found, marked EXPIRED. Only produced once a run has fully scanned the tracked scope, so never when Max results capped it or when Resume was used. This returns, and bills, extra synthetic rows, so leave it off unless you need expiry tracking.

## `proxy` (type: `object`):

Apify Proxy is recommended. The standard pool is the default and is sufficient for this site. The connection's own location decides which stations the Local radio category returns when you leave Locale coordinates empty.

## Actor input object example

```json
{
  "mode": "search",
  "queries": [
    "jazz"
  ],
  "browseCategories": [
    "local"
  ],
  "urls": [
    "https://tunein.com/station/?stationId=34682",
    "s24777"
  ],
  "minBitrateKbps": 0,
  "minReliabilityPercent": 0,
  "fetchDetails": false,
  "fetchStreams": false,
  "fetchEpisodes": false,
  "maxEpisodesPerPodcast": 20,
  "maxItems": 20,
  "maxPages": 0,
  "maxBrowseDepth": 2,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "proxy": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "mode": "search",
    "queries": [
        "jazz"
    ],
    "browseCategories": [
        "local"
    ],
    "urls": [
        "https://tunein.com/station/?stationId=34682",
        "s24777"
    ],
    "incrementalMode": false,
    "emitUnchanged": false,
    "emitExpired": false,
    "proxy": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/tunein-radio-podcast-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "mode": "search",
    "queries": ["jazz"],
    "browseCategories": ["local"],
    "urls": [
        "https://tunein.com/station/?stationId=34682",
        "s24777",
    ],
    "incrementalMode": False,
    "emitUnchanged": False,
    "emitExpired": False,
    "proxy": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/tunein-radio-podcast-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "mode": "search",
  "queries": [
    "jazz"
  ],
  "browseCategories": [
    "local"
  ],
  "urls": [
    "https://tunein.com/station/?stationId=34682",
    "s24777"
  ],
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "proxy": {
    "useApifyProxy": true
  }
}' |
apify call abotapi/tunein-radio-podcast-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/tunein-radio-podcast-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/0QPRvuqWmhyFjS8Eo/builds/a8bd1BkGNxRla9lSw/openapi.json
