Static is one shared live timeline. Every station plays the same moment for every listener, whether they're on a phone, the web, a Squeezebox or a Playdate. You don't pick an episode: you tune in and land where everyone else is. Everything on this page is public and free to use, with no key or sign-up. Please read the ground rules before you ship.

Quick start #

  1. Fetch the schedule from https://data.staticradio.app/schedule-wear.json.
  2. Work out what's live on a station: which episode, and how far into it. It's about 30 lines of code.
  3. Stream that episode straight from the show's own host, starting at that point, and move on to the next episode at its scheduled time.

That's a radio. Bulletins come with the schedule; then add art and listener counts.

AI assistants and agents #

Ask an AI assistant what's on Static with our MCP server, or build your own player with a coding agent and the prompt below. Agents can read this page as Markdown, and llms.txt lists every public link in one place.

Use Static from an AI assistant #

Static has a read-only MCP server. Add it to Claude, ChatGPT, Cursor or any assistant that takes a remote MCP server, then ask what's on now, what's next on a station, or whether anything about birds is coming up. Answers name the show, link the episode's page and the show's Support page, and end with a link that tunes in live. No key or sign-in.

MCP server
https://staticradio.app/mcp
Claude Code
claude mcp add --transport http static-radio https://staticradio.app/mcp
MCP config
{
  "mcpServers": {
    "static-radio": { "type": "http", "url": "https://staticradio.app/mcp" }
  }
}

Its tools are list_stations, whats_on, whats_next, find_on_now and show_details. They read only the public data on this page, follow the same timeline rules, and describe community bulletins as bulletins, not shows. Assistants don't play episodes on demand: on Static you don't pick an episode, you tune in. Each address gets 60 requests a minute.

Agent prompt #

Building with a coding agent like Claude Code, Cursor or Copilot? Paste this prompt into it. It has the rules from this page, the pitfalls we've hit, and how to check the result against our reference player.

Agent prompt
You're helping me build an unofficial player for Static Radio (https://staticradio.app): shared
live radio made of independent podcasts, where everyone tuned to a station hears the same moment.
Follow these instructions exactly.

SOURCES (the only public interface; read the guide first)
- Guide: https://staticradio.app/developers.md (Markdown; HTML at https://staticradio.app/developers)
- OpenAPI 3.1 spec: https://staticradio.app/openapi.json
- Schedule JSON Schema: https://staticradio.app/schemas/schedule-wear.v8.json
- Schedule: GET https://data.staticradio.app/schedule-wear.json (schema_version 8)
- Listener counts: GET https://staticradio.app/api/counts and https://staticradio.app/api/counts/{stationId}
- Reference implementation: https://github.com/madewith-bestpractice/static-squeezebox
  (StaticRadio/Schedule.pm and t/live_test.pl)

DO NOT USE
- Any other endpoint, host or file, including ones found in Static's apps or network traffic.
  They're internal and change without notice.
- schedule.json (the apps' internal file). Use schedule-wear.json.
- Any POST, PUT, PATCH or DELETE request. This integration is read-only.

WHAT'S LIVE (implement exactly; all times in milliseconds)
1. Stations are schedule.categories, keyed by station id. Order them by sort_index.
2. episodes = the station's episodes in array order, leaving out any with durationInSeconds <= 0.
3. slot(e) = e.durationInSeconds * 1000 + 1500. Every episode is followed by 1.5 s of silence.
4. loop = station.loop_duration_seconds * 1000 + 1500 * (number of episodes).
5. epoch = daily_epoch_utc parsed as a UTC instant. It's often in the future.
6. pos = ((now - epoch) % loop + loop) % loop. It must never be negative.
7. Walk the episodes in order: if pos < slot(e), e is live and pos is the offset into it;
   otherwise pos = pos - slot(e) and continue.
8. Audio stops at e.durationInSeconds * 1000, or at 30000 if e.isBulletin is true, whichever is
   less. From there to the end of the slot, play silence.
9. The next episode starts at now - offset + slot(e). Switch then, even if the file is still
   playing. If a file ends early, stay silent until then. Never skip ahead.
10. Never use startOffsetSeconds for timing. It leaves out the 1.5 s gaps.
11. Use an accurate clock (NTP), and compute the live point for the moment playback will
    actually start (about 1 s ahead).

PLAYING
- Stream e.audioUrl straight from the show's own host. Follow redirects. Request audioUrl
  itself every time, never the URL it redirects to, so the show's stats count the play.
  Some audioUrls are plain http.
- Start mid-episode by seeking (most players send the HTTP Range request themselves), or send
  "Range: bytes=N-" with N = fileSize * offset / (durationInSeconds * 1000), taking fileSize
  from the Content-Range of a "Range: bytes=0-0" request.

HARD REQUIREMENTS
- Never cache, store, re-host or proxy audio. Buffering ahead for smooth playback is fine.
- Play bulletins (isBulletin: true) where they fall. Never skip, mute or replace them.
- While an episode plays, show a link labeled "Support" to e.supportUrl whenever it's present
  (the show's own support page; never on bulletins). If no episode in the schedule has a
  supportUrl (older files), take the url attribute of the first channel-level
  <podcast:funding> element (namespace https://podcastindex.org/namespace/1.0) in the show's
  RSS feed at e.feedUrl instead, fetching each feed at most once a day.
- Keep it live. Users tune to a station and land on the live point. No choosing episodes,
  seeking, rewinding or skipping.
- Fetch the schedule at most every 15 minutes, sending If-None-Match with the last ETag
  (304 means unchanged). Fetch listener counts at most once a minute.
- Send a User-Agent that names the app and its version, like YourApp-Static/1.0.
- Call the integration unofficial. Credit Static Radio with a link to https://staticradio.app,
  and link each episode to its episodeUrl when it isn't empty.

KNOWN PITFALLS
- Python's default User-Agent gets HTTP 403 from data.staticradio.app. Always set your own.
- In a browser, don't add If-None-Match yourself: it triggers a CORS preflight that
  data.staticradio.app refuses. The browser's cache revalidates on its own.
- Node's fetch sends "Accept-Language: *", and Captivate-hosted audio answers it with 404.
  Send "Accept-Language: en".
- Some imageUrls (SHEILA B's) are relative paths to art bundled in Static's apps. Use a fallback
  image for any imageUrl that doesn't start with https://.
- Ignore fields you don't know. If schema_version isn't 8, log a warning and check the guide.

VERIFY
- Check your what's-live function against the reference: in a clone of static-squeezebox,
  `T=$PWD/t perl t/live_test.pl schedule-wear.json <unix_ms>` prints
  station|audioUrl|offset_seconds|is_bulletin for every station.
- Write a test that runs your function and the published liveNow() (the JavaScript under
  "Working out what's live" in the guide) on one saved schedule-wear.json at several
  timestamps, including one before daily_epoch_utc and one inside a 1.5 s gap, and asserts
  they give the same episode and offset.

The schedule #

One JSON file holds every station's loop for the broadcast day:

GET https://data.staticradio.app/schedule-wear.json

The format is schema_version 8. There's a JSON Schema for it. You may also spot schedule.json next to it: that's the full file our phone apps read, several times bigger, with internal fields that change without notice. Please use schedule-wear.json.

Top level #

FieldTypeWhat it is
schema_versionintegerThe format version: 8. We'll give notice before it changes.
daily_epoch_utcstringWhen the loops started (ISO 8601): midnight US Central at the start of the broadcast day this file was built for. Often in the future; see what's live.
generated_at_utcstringWhen the file was built (ISO 8601).
categoriesobjectThe stations, keyed by station id.

Stations #

Each entry in categories. Today's ids are nature_01, work_01, learn_01, news_01, weird_01, unite_01, make_01, sheila_01 and sleep_01, but read the list from the file rather than hard-coding it.

FieldTypeWhat it is
idstringThe station id, the same as its key.
display_namestringThe station's name, like News or SHEILA B.
u_tagstringThe name as it appears on the dial, in capitals.
is_activebooleanAlways true here; stations off the air are left out.
sort_indexintegerPosition on the dial, from 0.
loop_duration_secondsintegerLength of the loop in seconds, not counting the 1.5 s gaps. It's the sum of the episodes' durations.
paletteobjectThe station's colors: bg, and accents a and b (hex).
episodesarrayThe loop, in airing order.

Episodes #

FieldTypeWhat it is
episodeIdstringA stable id. Bulletins are bulletin-<id> and can appear several times in a loop.
audioUrlstringThe audio, on the show's own host. Stream it from here.
durationInSecondsintegerHow long the episode holds the air. Its slot is this plus 1.5 s.
startOffsetSecondsintegerThe earlier episodes' durations added up, without the gaps. Don't use it for timing.
titlestringThe episode's title (Community Bulletin for bulletins).
feedTitlestringThe show's name.
episodeUrlstringThe episode's web page, or a bulletin's link. Can be empty.
feedUrlstringThe show's RSS feed. Empty for bulletins.
imageUrlstringCover art. See art.
gainDbnumberLoudness correction in dB (EBU R128), so shows sit at a similar level. Apply it if your player can.
isBulletinbooleantrue for a community bulletin.
supportUrlstringOptional. The show's own support page, from its feed's podcast:funding tag. Left out when there isn't one, and on bulletins. See Support links.
placementIdstringBulletins only: an id for the break it fills.
image_analysisobjectColors from the cover art (vibrant_color, darkest_color, lightest_color). On one episode per station only.

New fields can turn up at any time without a version change, so ignore any you don't know. Here's today's Make station, trimmed to two episodes: a show with a Support link, then a bulletin.

JSON
{
  "schema_version": 8,
  "daily_epoch_utc": "2026-11-12T06:00:00+00:00",
  "generated_at_utc": "2026-11-11T13:37:12.499349+00:00",
  "categories": {
    "make_01": {
      "id": "make_01",
      "display_name": "Make",
      "u_tag": "MAKE",
      "is_active": true,
      "sort_index": 6,
      "loop_duration_seconds": 73772,
      "palette": { "bg": "#101820", "a": "#10B981", "b": "#F59E0B" },
      "episodes": [
        {
          "episodeId": "b0b7e2187e5ff80f3993c802d9722795a018dfcf",
          "audioUrl": "https://www.buzzsprout.com/1118069/episodes/19800392-festival-of-quilts-and-exploring-england.mp3",
          "startOffsetSeconds": 51937,
          "durationInSeconds": 2668,
          "imageUrl": "https://data.staticradio.app/art/d92ec187e05adcfdb277da6085f35a3978c15b83.webp",
          "title": "Festival of Quilts and Exploring England",
          "feedTitle": "A Quilting Life Podcast",
          "episodeUrl": "https://www.buzzsprout.com/1118069",
          "feedUrl": "https://rss.buzzsprout.com/1118069.rss",
          "gainDb": 0.87,
          "isBulletin": false,
          "supportUrl": "https://www.buzzsprout.com/1118069/support"
        },
        {
          "episodeId": "bulletin-sample-hq-patreon-thanks",
          "audioUrl": "https://data.staticradio.app/sample-bulletins/hq-patreon-thanks-30s.mp3",
          "startOffsetSeconds": 54605,
          "durationInSeconds": 30,
          "imageUrl": "https://data.staticradio.app/art/1c37afe29bb75d3c3363d36be440ff0738fce3af.webp",
          "title": "Community Bulletin",
          "feedTitle": "Static Radio Bulletin Board",
          "episodeUrl": "https://patreon.com/staticradio",
          "feedUrl": "",
          "gainDb": 0.3,
          "isBulletin": true,
          "placementId": "bulletin-sample-hq-patreon-thanks-block-gap-17"
        }
      ]
    }
  }
}

Fetching it #

Each of these keeps the ETag and asks again with If-None-Match.

curl
# First fetch: keep the file and its ETag
curl -sS --compressed -A "YourApp-Static/1.0" \
  -D headers.txt -o schedule-wear.json \
  https://data.staticradio.app/schedule-wear.json
ETAG=$(grep -i '^etag:' headers.txt | cut -d' ' -f2- | tr -d '\r')

# Later: ask again with the ETag. 304 means your copy is still current.
curl -sS --compressed -A "YourApp-Static/1.0" \
  -H "If-None-Match: $ETAG" -o schedule-new.json \
  -w '%{http_code}\n' \
  https://data.staticradio.app/schedule-wear.json
JavaScript
const URL = "https://data.staticradio.app/schedule-wear.json";
let cached = null;   // { etag, schedule }

async function getSchedule() {
  const headers = { "User-Agent": "YourApp-Static/1.0" };
  if (cached) headers["If-None-Match"] = cached.etag;
  const res = await fetch(URL, { headers });
  if (res.status === 304) return cached.schedule;   // unchanged
  if (!res.ok) throw new Error(`schedule: HTTP ${res.status}`);
  cached = { etag: res.headers.get("ETag"), schedule: await res.json() };
  return cached.schedule;
}

const schedule = await getSchedule();
console.log(schedule.schema_version, Object.keys(schedule.categories));
await getSchedule();   // the second call is a 304
Python
import gzip, json, urllib.error, urllib.request

URL = "https://data.staticradio.app/schedule-wear.json"
# A User-Agent is required: the default Python one gets a 403.
HEADERS = {"User-Agent": "YourApp-Static/1.0", "Accept-Encoding": "gzip"}
cached = None  # (etag, schedule)

def get_schedule():
    global cached
    headers = dict(HEADERS)
    if cached:
        headers["If-None-Match"] = cached[0]
    try:
        req = urllib.request.Request(URL, headers=headers)
        with urllib.request.urlopen(req, timeout=30) as r:
            body = r.read()
            if r.headers.get("Content-Encoding") == "gzip":
                body = gzip.decompress(body)
            cached = (r.headers["ETag"], json.loads(body))
    except urllib.error.HTTPError as e:
        if e.code != 304:  # 304: unchanged, keep what we have
            raise
    return cached[1]

schedule = get_schedule()
print(schedule["schema_version"], list(schedule["categories"]))
get_schedule()  # the second call is a 304

Working out what's live #

Every station is a loop that started at daily_epoch_utc and repeats until the next file arrives. Each episode holds the air for its durationInSeconds, then there's 1.5 seconds of silence before the next one. These are the rules every Static player follows:

Pseudocode
GAP = 1500 ms
episodes = the station's episodes in order, minus any with durationInSeconds 0
slot(e)  = e.durationInSeconds × 1000 + GAP
loop     = loop_duration_seconds × 1000 + GAP × (number of episodes)

position = (now − daily_epoch_utc) mod loop       # in ms, never negative

for e in episodes:
    if position < slot(e):
        play e.audioUrl from position ms
        stop the audio at durationInSeconds (at 30 s for a bulletin)
        and stay quiet until the next episode, at now − position + slot(e)
        done
    position = position − slot(e)

Two more things keep you in step with everyone else:

As a function you can copy:

JavaScript
const GAP_MS = 1500;           // silence after every episode
const BULLETIN_CAP_MS = 30000; // bulletins stop at 30 s

// What's on `stationId` at `nowMs`, and how far into it.
function liveNow(schedule, stationId, nowMs = Date.now()) {
  const station = schedule.categories[stationId];
  const episodes = station.episodes.filter((e) => e.durationInSeconds > 0);
  if (!episodes.length || station.loop_duration_seconds <= 0) return null;

  const epochMs = Date.parse(schedule.daily_epoch_utc);
  const padMs = episodes.length * GAP_MS;
  const loopMs = station.loop_duration_seconds * 1000 + padMs;
  const totalMs = episodes.reduce((t, e) => t + e.durationInSeconds * 1000, 0) + padMs;

  // A modulo that never goes negative: the epoch is often in the future.
  let pos = (((nowMs - epochMs) % loopMs) + loopMs) % loopMs;
  pos %= totalMs;

  for (const episode of episodes) {
    const slotMs = episode.durationInSeconds * 1000 + GAP_MS;
    if (pos < slotMs) {
      let audioMs = episode.durationInSeconds * 1000;
      if (episode.isBulletin) audioMs = Math.min(audioMs, BULLETIN_CAP_MS);
      return {
        episode,
        offsetMs: pos,                  // start the audio here
        inGap: pos >= audioMs,          // past the audio: stay quiet
        audioMs,                        // stop the audio here
        startedAtMs: nowMs - pos,       // when this episode started
        nextAtMs: nowMs - pos + slotMs, // when the next one starts
      };
    }
    pos -= slotMs;
  }
  return null;
}


// With the schedule from above:
const live = liveNow(schedule, "news_01");
console.log(live.episode.title, Math.round(live.offsetMs / 1000), "s in");
Python
import time
from datetime import datetime

GAP_MS = 1500             # silence after every episode
BULLETIN_CAP_MS = 30_000  # bulletins stop at 30 s

def live_now(schedule, station_id, now_ms=None):
    """What's on station_id at now_ms, and how far into it."""
    if now_ms is None:
        now_ms = int(time.time() * 1000)
    station = schedule["categories"][station_id]
    episodes = [e for e in station["episodes"] if e["durationInSeconds"] > 0]
    if not episodes or station["loop_duration_seconds"] <= 0:
        return None

    epoch_ms = int(datetime.fromisoformat(schedule["daily_epoch_utc"]).timestamp() * 1000)
    pad_ms = len(episodes) * GAP_MS
    loop_ms = station["loop_duration_seconds"] * 1000 + pad_ms
    total_ms = sum(e["durationInSeconds"] * 1000 for e in episodes) + pad_ms

    # Python's % never goes negative, which matters: the epoch is often in the future.
    pos = (now_ms - epoch_ms) % loop_ms % total_ms

    for episode in episodes:
        slot_ms = episode["durationInSeconds"] * 1000 + GAP_MS
        if pos < slot_ms:
            audio_ms = episode["durationInSeconds"] * 1000
            if episode["isBulletin"]:
                audio_ms = min(audio_ms, BULLETIN_CAP_MS)
            return {
                "episode": episode,
                "offset_ms": pos,                  # start the audio here
                "in_gap": pos >= audio_ms,         # past the audio: stay quiet
                "audio_ms": audio_ms,              # stop the audio here
                "started_at_ms": now_ms - pos,     # when this episode started
                "next_at_ms": now_ms - pos + slot_ms,  # when the next one starts
            }
        pos -= slot_ms
    return None

# With the schedule from above:
live = live_now(schedule, "news_01")
print(live["episode"]["title"], live["offset_ms"] // 1000, "s in")

The reference implementation is the Squeezebox plugin's Schedule.pm, which follows our phone apps exactly. Its t/live_test.pl checks the math against the published schedule without a server, and the functions above give the same answers it does.

Playing from the middle #

Start the live episode at the live point with an HTTP Range request to the show's own host, then let it play.

curl
URL='https://example.com/episode.mp3'  # the episode's audioUrl
OFFSET_S=1500                          # where the live moment is, in seconds
DURATION_S=3600                        # the episode's durationInSeconds

# 1. The file's size, from Content-Range (-L follows the host's redirects)
SIZE=$(curl -sSL -A "YourApp-Static/1.0" -r 0-0 -D - -o /dev/null "$URL" \
  | grep -i '^content-range:' | tail -1 | cut -d/ -f2 | tr -d '\r')

# 2. The rest of the file, from about OFFSET_S in
curl -sSL -A "YourApp-Static/1.0" -r "$(( SIZE * OFFSET_S / DURATION_S ))-" \
  -o from-live.mp3 "$URL"   # or pipe it into your player
JavaScript
// Node's fetch sends "Accept-Language: *" by default, and at least one big
// podcast host answers that with a 404, so send a real one.
const UA = { "User-Agent": "YourApp-Static/1.0", "Accept-Language": "en" };

// Start an episode offsetMs in, straight from the show's own host.
async function openAt(episode, offsetMs) {
  // 1. The file's size. Always ask for audioUrl itself (not where it
  //    redirects), so the show's own stats see the play.
  const probe = await fetch(episode.audioUrl, { headers: { ...UA, Range: "bytes=0-0" } });
  const size = Number(probe.headers.get("Content-Range")?.split("/")[1]);
  await probe.body?.cancel();
  if (!size) return fetch(episode.audioUrl, { headers: UA }); // no ranges: start at the top

  // 2. A byte position by proportion: right for constant-bitrate MP3,
  //    close for most other files.
  const start = Math.floor((size * offsetMs) / (episode.durationInSeconds * 1000));
  return fetch(episode.audioUrl, { headers: { ...UA, Range: `bytes=${start}-` } });
}


// With the live result from above:
const res = await openAt(live.episode, live.offsetMs);
console.log(res.status); // 206: the audio from about the live moment on
Python
import urllib.request

UA = {"User-Agent": "YourApp-Static/1.0"}

def open_at(episode, offset_ms):
    """Start an episode offset_ms in, straight from the show's own host."""
    # 1. The file's size. Always ask for audioUrl itself (not where it
    #    redirects), so the show's own stats see the play.
    probe = urllib.request.Request(episode["audioUrl"], headers={**UA, "Range": "bytes=0-0"})
    with urllib.request.urlopen(probe, timeout=30) as r:
        content_range = r.headers.get("Content-Range") or ""
    if "/" not in content_range:  # no ranges: start at the top
        plain = urllib.request.Request(episode["audioUrl"], headers=UA)
        return urllib.request.urlopen(plain, timeout=30)
    size = int(content_range.split("/")[1])

    # 2. A byte position by proportion: right for constant-bitrate MP3,
    #    close for most other files.
    start = size * offset_ms // (episode["durationInSeconds"] * 1000)
    req = urllib.request.Request(episode["audioUrl"], headers={**UA, "Range": f"bytes={start}-"})
    return urllib.request.urlopen(req, timeout=30)  # 206, audio from about offset_ms on

# With the live result from above:
with open_at(live["episode"], live["offset_ms"]) as audio:
    print(audio.status)  # 206: the audio from about the live moment on

Things that trip players up #

Community bulletins #

Community bulletins are 30-second spots that anyone can book: a lost cat, a gig, a podcaster plugging their show. They play only between episodes, never inside one, and they're part of the schedule.

In the schedule, a bulletin is an episode like any other, with:

Every player has to play them: where they fall, for up to 30 seconds, without skipping, muting or replacing them. The same bulletin can appear several times in a loop.

Art #

Support links #

Many shows have a page where listeners can support them: a Patreon, a membership, a tip jar. While a show is playing, show a Support link to that page, if it has one, next to the episodeUrl link. Our apps do the same. It's how listeners help the people who make the shows.

curl
# Today's Support links, from the schedule
curl -sS --compressed -A "YourApp-Static/1.0" https://data.staticradio.app/schedule-wear.json \
  | jq -r '[.categories[].episodes[] | select(.supportUrl) | "\(.feedTitle): \(.supportUrl)"] | unique[]'

# For an older schedule file without supportUrl: the first <podcast:funding url="...">
# before the first <item> in the show's feed
FEED='https://example.com/feed.xml'   # the episode's feedUrl
curl -sL -A "YourApp-Static/1.0" -H "Accept-Language: en" "$FEED" \
  | sed '/<item[ >]/q' \
  | grep -o '<podcast:funding[^>]*url="[^"]*"' | head -1 \
  | sed 's/.*url="//; s/"$//; s/&amp;/\&/g'
JavaScript
const FEED_HEADERS = { "User-Agent": "YourApp-Static/1.0", "Accept-Language": "en" };
const DAY_MS = 24 * 3600 * 1000;
const feedCache = new Map(); // feedUrl -> { url, at }

// Older schedule files predate supportUrl. In those, read it from the feed.
const hasSupportUrls = (schedule) =>
  Object.values(schedule.categories).some((s) => s.episodes.some((e) => "supportUrl" in e));

// The first channel-level <podcast:funding url="..."> in a show's RSS feed,
// or null. Cached for a day per feed.
async function supportUrlFromFeed(feedUrl) {
  if (!feedUrl) return null; // bulletins have no feed
  const hit = feedCache.get(feedUrl);
  if (hit && Date.now() - hit.at < DAY_MS) return hit.url;
  let url = null;
  try {
    const res = await fetch(feedUrl, { headers: FEED_HEADERS });
    if (res.ok) {
      const channel = (await res.text()).split(/<item[\s>]/)[0]; // tags before the first episode
      const m = channel.match(/<podcast:funding\b[^>]*\burl\s*=\s*["']([^"']+)["']/);
      if (m) url = m[1].trim().replace(/&amp;/g, "&");
    }
  } catch { /* feed unreachable: no link for now */ }
  feedCache.set(feedUrl, { url, at: Date.now() });
  return url;
}

// With the schedule and live result from above:
const support = live.episode.supportUrl
  ?? (hasSupportUrls(schedule) ? null : await supportUrlFromFeed(live.episode.feedUrl));
console.log(support ? `Support: ${support}` : "This show has no Support link");
Python
import html, re, time, urllib.request

FEED_HEADERS = {"User-Agent": "YourApp-Static/1.0", "Accept-Language": "en"}
FUNDING = re.compile(r"""<podcast:funding\b[^>]*\burl\s*=\s*["']([^"']+)["']""")
DAY_S = 24 * 3600
feed_cache = {}  # feed_url -> (url, fetched_at)

def has_support_urls(schedule):
    """Older schedule files predate supportUrl. In those, read it from the feed."""
    return any("supportUrl" in e for s in schedule["categories"].values() for e in s["episodes"])

def support_url_from_feed(feed_url):
    """The first channel-level <podcast:funding url="..."> in a show's RSS feed,
    or None. Cached for a day per feed."""
    if not feed_url:  # bulletins have no feed
        return None
    hit = feed_cache.get(feed_url)
    if hit and time.time() - hit[1] < DAY_S:
        return hit[0]
    url = None
    try:
        req = urllib.request.Request(feed_url, headers=FEED_HEADERS)
        with urllib.request.urlopen(req, timeout=20) as r:
            text = r.read().decode("utf-8", "replace")
        channel = re.split(r"<item[\s>]", text, maxsplit=1)[0]  # tags before the first episode
        m = FUNDING.search(channel)
        if m:
            url = html.unescape(m.group(1).strip())
    except Exception:  # feed unreachable: no link for now
        pass
    feed_cache[feed_url] = (url, time.time())
    return url

# With the schedule and live result from above:
episode = live["episode"]
support = episode.get("supportUrl") or (
    None if has_support_urls(schedule) else support_url_from_feed(episode["feedUrl"]))
print(f"Support: {support}" if support else "This show has no Support link")

Listener counts #

GET https://staticradio.app/api/counts
GET https://staticradio.app/api/counts/{stationId}

How many people are listening to each station right now. The first gives every station as {"counts": {"news_01": 3, …}}; the second gives one station's bare number. A count is null when it isn't known. Both are sent with Cache-Control: public, max-age=5 and Access-Control-Allow-Origin: *; asking once a minute is plenty.

Listeners are counted through our official apps and ports.

curl
curl -sS -A "YourApp-Static/1.0" https://staticradio.app/api/counts
# {"counts":{"nature_01":0,"work_01":2,...,"news_01":3,...}}

curl -sS -A "YourApp-Static/1.0" https://staticradio.app/api/counts/news_01
# 3
JavaScript
const res = await fetch("https://staticradio.app/api/counts", {
  headers: { "User-Agent": "YourApp-Static/1.0" },
});
const { counts } = await res.json();
console.log(counts.news_01); // listeners on News right now (null if unknown)
Python
import json, urllib.request

req = urllib.request.Request("https://staticradio.app/api/counts",
                             headers={"User-Agent": "YourApp-Static/1.0"})
with urllib.request.urlopen(req, timeout=15) as r:
    counts = json.load(r)["counts"]
print(counts["news_01"])  # listeners on News right now (None if unknown)

Ground rules #

OpenAPI spec and JSON Schema #

For tools and code generators:

Both are served with Access-Control-Allow-Origin: *, so you can load them straight into an API viewer or validator. For AI tools there's also this page as Markdown and llms.txt, an index of these links.

Changes #

We'll give notice here before schema_version changes. New fields can be added at any time without a version change, so ignore any you don't know.

Podcasting 2.0 #

Static finds shows through the Podcast Index, using its open database and its search API. From each show's feed we read:

We don't read chapters, transcripts or value tags yet.

Example players #

Our own ports are open source (MIT):

Questions #

hello@staticradio.app

Building something? Tell us about it. Questions, bugs in this page and ideas are all welcome, and we usually reply within a couple of days.