---
title: "Monitor radio airplay for your music catalog"
description: "Track which of your catalog's songs get played across many radio stations 24/7 with AudD audio streams, storing every recognition and aggregating it into an airplay chart."
slug: "/resources/recipes/radio-airplay-monitor"
section: "recipes"
keywords: [audd, radio airplay, airplay chart, streams, custom catalog, radio monitoring]
---

# Monitor radio airplay for your music catalog

If you're a label, a distributor, or an artist, you want to know which radio
stations play your tracks and how often. This recipe builds an airplay
monitor: AudD recognizes songs off many radio streams around the clock, your
backend records every play, you filter to the tracks you care about, and you
aggregate the rows into a chart.

## What you'll build

- **Many streams registered with AudD**, one per radio station — direct HLS
  or Icecast URLs you add with `streams.add`, each with its own `radio_id`.
- **One callback handler** that receives every recognized song from every
  station (the account has a single callback URL) and writes a row per play.
- **A filter** that keeps only the tracks in your catalog.
- **A chart query** that aggregates the stored plays into spin counts per
  track, per station, per day.

The streams API lives on `https://api.audd.io/`. By default AudD POSTs a
callback *after* each song finishes playing, and that callback includes the
total play length — which is exactly what airplay reporting wants, so unlike a
live overlay you'll keep the default (no `callbacks=before`).

## Prerequisites

- An API token from [dashboard.audd.io](https://dashboard.audd.io). Audio
  streams are a paid add-on billed per stream per month; the `test` token
  does **not** work on streams, so use your real token.
- Python 3.10+ with the official SDK: `pip install audd`
- A database. The examples use SQLite for portability; swap in Postgres for
  production.
- A list of station stream URLs. Many stations publish a direct HLS/Icecast
  URL; e.g. `https://npr-ice.streamguys1.com/live.mp3`.

## Walkthrough

### Step 1: Register the account callback URL (once)

The callback URL is set **per account** — every stream you add reports to the
same URL. Set it once before adding any stations.

```python
from audd import AudD

audd = AudD()  # reads AUDD_API_TOKEN; get a token at dashboard.audd.io

audd.streams.set_callback_url(
    "https://your-app.example.com/audd-callback",
    return_metadata=["apple_music"],  # optional; adds streaming links per result
)
```

### Step 2: Add a stream per station

Give each station an integer `radio_id` that's stable in *your* system — map
it to your station registry so a callback's `radio_id` tells you which station
played the song.

```python
STATIONS = {
    101: "https://npr-ice.streamguys1.com/live.mp3",
    102: "https://ice1.somafm.com/groovesalad-128-mp3",
    103: "https://stream.example-fm.com/live",
    # ... hundreds more
}

for radio_id, url in STATIONS.items():
    audd.streams.add(url=url, radio_id=radio_id)
    # default callbacks: AudD POSTs AFTER each song, with play_length
```

`streams.add` accepts the same shortcuts as any stream — `twitch:<channel>`,
`youtube:<video_id>`, `youtube-ch:<channel_id>` — but for radio you'll usually
pass direct HLS/Icecast URLs. To re-point a station whose URL changed, use
`audd.streams.set_url(radio_id=radio_id, url=new_url)`; `audd.streams.delete(radio_id=...)`
removes one; `audd.streams.list()` enumerates everything on the account.

> **Each stream is billed per month.** Add only stations you're actively
> monitoring, and `delete` ones you drop. `audd.streams.list()` is the source
> of truth for what you're paying for.

### Step 3: Create the database

Two tables: a raw `plays` log (one row per recognized song, every station),
and your `catalog` of tracks you care about. Keeping the raw log means you can
re-run the catalog filter or the chart query later without re-monitoring.

```python
import sqlite3

db = sqlite3.connect("airplay.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS catalog (
    isrc        TEXT PRIMARY KEY,        -- your tracks, by ISRC
    artist      TEXT,
    title       TEXT
);

CREATE TABLE IF NOT EXISTS plays (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    radio_id     INTEGER NOT NULL,       -- which station
    played_at    TEXT NOT NULL,          -- callback timestamp
    artist       TEXT,
    title        TEXT,
    album        TEXT,
    isrc         TEXT,                   -- null unless your plan returns it
    play_length  INTEGER,                -- seconds the song played
    score        INTEGER,
    song_link    TEXT,
    UNIQUE (radio_id, played_at, title)  -- idempotent re-delivery guard
);
""")
db.commit()
```

The `UNIQUE` constraint matters: if your handler returns non-200, AudD queues
the callback and re-sends it later. An upsert against this constraint makes a
redelivered callback a no-op instead of a double-count.

### Step 4: The callback handler

AudD POSTs every recognized song to your one callback URL. The handler parses
the body, writes a play row, and — separately — flags whether it's one of your
catalog tracks.

```python
from fastapi import FastAPI, Request
from audd import AsyncAudD
import sqlite3

app = FastAPI()
audd = AsyncAudD()
db = sqlite3.connect("airplay.db", check_same_thread=False)

def record_play(match) -> None:
    s = match.song
    db.execute(
        """INSERT OR IGNORE INTO plays
           (radio_id, played_at, artist, title, album, isrc, play_length, score, song_link)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (match.radio_id, match.timestamp, s.artist, s.title, s.album,
         s.isrc, match.play_length, s.score, s.song_link),
    )
    db.commit()

@app.post("/audd-callback")
async def audd_callback(request: Request) -> dict[str, str]:
    match, notif = await audd.streams.handle_callback(request)
    if match:
        record_play(match)
    elif notif:
        # 0 = ok, 650 = can't connect, 651 = white noise only
        if notif.notification_code in (650, 651):
            print(f"station {notif.radio_id}: {notif.notification_message}")
    return {"status": "ok"}  # FastAPI sends 200
```

`handle_callback` reads the body off the request and returns a
`(match, notification)` pair — exactly one is set. A `StreamCallbackMatch`
carries `radio_id`, `timestamp`, `play_length`, and `song` (the top match,
with `artist`, `title`, `album`, `score`, `song_link`, and provider blocks
when you set `return_metadata`).

### Step 5: Filter to your catalog

You have two fundamentally different ways to decide "is this one of my
tracks", and the choice shapes the whole pipeline:

**Match against AudD's public database (any song), then filter.** Streams
recognize against AudD's 160-million-track database, so callbacks contain
*every* identifiable song on the station — yours and everyone else's. You
filter down to your catalog after the fact, by ISRC or by artist/title:

```python
def is_mine(isrc: str | None, artist: str | None, title: str | None) -> bool:
    if isrc:
        row = db.execute("SELECT 1 FROM catalog WHERE isrc = ?", (isrc,)).fetchone()
        if row:
            return True
    # fall back to a normalized artist+title match
    row = db.execute(
        "SELECT 1 FROM catalog WHERE lower(artist) = lower(?) AND lower(title) = lower(?)",
        (artist or "", title or ""),
    ).fetchone()
    return row is not None
```

This is the right approach when you also want context — what *else* the
station plays, how your tracks sit in rotation, competitive share.

**Match against your own custom catalog (your tracks only).** Alternatively,
upload your catalog to AudD's custom-catalog feature and have streams match
against *only your* fingerprints. Then every callback is, by definition, one
of your tracks — no post-filter needed. The tradeoff: custom-catalog matches
return the unique track ID you uploaded (on `audio_id`) and not full public
metadata, and you lose the surrounding-rotation context.

Which to use depends on whether you need station context (public DB, filter
after) or only your own spins (custom catalog, no filter). See
[Public database vs. your custom catalog](/resources/concepts/custom-vs-public-db)
for the full comparison and how to request custom-catalog access.

### Step 6: Aggregate into a chart

With plays accumulating, an airplay chart is a `GROUP BY`. Spin counts per
track over the last 7 days:

```python
def weekly_chart(db) -> list[dict]:
    rows = db.execute("""
        SELECT artist, title,
               COUNT(*)                    AS spins,
               COUNT(DISTINCT radio_id)    AS stations,
               SUM(play_length)            AS total_seconds
        FROM plays
        WHERE played_at >= datetime('now', '-7 days')
        GROUP BY lower(artist), lower(title)
        ORDER BY spins DESC
        LIMIT 100
    """).fetchall()
    return [
        {"artist": r[0], "title": r[1], "spins": r[2],
         "stations": r[3], "total_seconds": r[4]}
        for r in rows
    ]
```

`spins` is the headline number, `stations` shows reach (how many stations
played it), and `total_seconds` is airtime. Pre-filter the `plays` table to
your catalog (a `WHERE isrc IN (SELECT isrc FROM catalog)` clause) for a
catalog-only chart.

## Scaling to many stations

A single account can run a large fleet of stations through one callback URL.
A few things to plan for as the count grows:

- **Callback rate limit.** With fewer than 500 streams, AudD sends at most 3
  callbacks per second (with a token-bucket burst of 15). Your handler must
  respond `200 OK` quickly — do the database write asynchronously or push the
  body onto a queue and ack immediately, rather than blocking the response on
  a slow insert.
- **Backlog on downtime.** If your endpoint returns non-200 or is
  unreachable, AudD queues callbacks and replays them when you recover. The
  `UNIQUE` constraint from Step 3 keeps the replay idempotent.
- **Notifications are your health signal.** Codes `650` (can't connect) and
  `651` (white noise only) tell you a station's URL went stale. Track which
  `radio_id`s are flapping and re-point them with `set_url`.
- **One worker, many stations.** Because every station shares one callback
  URL, you scale the *receiver*, not a per-station poller. Put the handler
  behind a load balancer and a queue; the stations themselves are just rows in
  `streams.add`.

## What you get back

A "song finished" callback (the default, no `callbacks=before`) carries the
play length:

```json
{
  "status": "success",
  "result": {
    "radio_id": 101,
    "timestamp": "2020-04-13 10:31:43",
    "play_length": 111,
    "results": [
      {
        "artist": "Alan Walker, A$AP Rocky",
        "title": "Live Fast (PUBGM)",
        "album": "Live Fast (PUBGM)",
        "release_date": "2019-07-25",
        "label": "MER Recordings",
        "score": 100,
        "song_link": "https://lis.tn/LiveFastPUBGM"
      }
    ]
  }
}
```

| Field | Meaning for airplay |
|---|---|
| `radio_id` | Which station played it — your handle from `streams.add`. |
| `timestamp` | When the play was recognized. The play's clock time. |
| `play_length` | Seconds the song played on the station. Sums into airtime. **Present only with the default callbacks**, not `callbacks=before`. |
| `results[0].artist`, `.title`, `.album` | The recognized track. |
| `results[0].label` | Releasing label — useful for label-level rollups. |
| `results[0].song_link` | Universal `lis.tn` link to the track. |
| `results[0].score` | Match confidence. |

ISRC and UPC are returned on a result when your account is on a Startup plan
or higher; read them as the typed `s.isrc` / `s.upc` properties. Any other field
AudD returns that the SDK doesn't surface as a typed property is available on the
model's `model_extra` map. For custom-catalog matches, the result carries the `audio_id` you
uploaded and `artist`/`title` may be null.

## Handling errors

- **Authentication errors** (`AudDAuthenticationError`) — bad/missing token or
  streams add-on not enabled. Fail at startup.
- **Invalid-request errors** (`AudDInvalidRequestError`) — e.g. a `streams.add`
  with a malformed URL, or a missing callback URL. Validate URLs before
  adding stations in bulk.
- **Stream notifications** (`650` / `651`) — delivered as notification
  callbacks, not exceptions. Use them to detect dead station URLs and
  re-point with `set_url`.
- **Connection errors** (`AudDConnectionError`) — transient on the management
  calls (`add`, `set_url`, `delete`); retry with backoff. The SDK already
  retries idempotent reads.

```python
from audd.errors import AudDInvalidRequestError, AudDAPIError

for radio_id, url in STATIONS.items():
    try:
        audd.streams.add(url=url, radio_id=radio_id)
    except AudDInvalidRequestError:
        print(f"skipping station {radio_id}: bad URL {url}")
    except AudDAPIError as e:
        print(f"station {radio_id} failed: {e.error_code}")
```

## Going further

- **Daily and per-station charts.** Add `GROUP BY radio_id` or
  `GROUP BY date(played_at)` to slice spins by station or by day.
- **First-play alerts.** When a `record_play` insert succeeds for a catalog
  track on a station that hasn't played it before, fire a notification — the
  moment a new station picks up your release.
- **Reconcile your stream fleet.** Run `audd.streams.list()` on a schedule and
  diff it against your station registry so you never pay for a stream you
  stopped monitoring.
- For matching against your own tracks only, see
  [Public database vs. your custom catalog](/resources/concepts/custom-vs-public-db).

---

**Related**

- [Build a now-playing widget for a livestream](/resources/recipes/now-playing-widget)
- [Public database vs. your custom catalog](/resources/concepts/custom-vs-public-db)
- [Python SDK docs](https://docs.audd.io/sdks/python)
- [Streams API reference](https://docs.audd.io/streams)