Monitor radio airplay for your music catalog
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.
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 ownradio_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. Audio
streams are a paid add-on billed per stream per month; the
testtoken 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.
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.
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
deleteones 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.
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.
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:
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 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:
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 OKquickly — 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
UNIQUEconstraint from Step 3 keeps the replay idempotent. - Notifications are your health signal. Codes
650(can’t connect) and651(white noise only) tell you a station’s URL went stale. Track whichradio_ids are flapping and re-point them withset_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:
{
"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. astreams.addwith 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 withset_url. - Connection errors (
AudDConnectionError) — transient on the management calls (add,set_url,delete); retry with backoff. The SDK already retries idempotent reads.
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_idorGROUP BY date(played_at)to slice spins by station or by day. - First-play alerts. When a
record_playinsert 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.
Related
Reading this as an AI agent? The raw Markdown is at recipes/radio-airplay-monitor.md, and the full index is /resources/llms.txt.
