Recipe

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.

view .md auddradio airplayairplay chartstreams

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. 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.

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 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.

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 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_ids 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:

{
  "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"
      }
    ]
  }
}
FieldMeaning for airplay
radio_idWhich station played it — your handle from streams.add.
timestampWhen the play was recognized. The play’s clock time.
play_lengthSeconds the song played on the station. Sums into airtime. Present only with the default callbacks, not callbacks=before.
results[0].artist, .title, .albumThe recognized track.
results[0].labelReleasing label — useful for label-level rollups.
results[0].song_linkUniversal lis.tn link to the track.
results[0].scoreMatch 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.
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.

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.