Screen releases before you deliver them to streaming services
For distributors and labels: recognize each submitted track, compare the matched artist, label, and ISRC to what the uploader claims, catch duplicates against your own delivered masters, and write a review queue CSV with the evidence.
A distributor receives tracks from artists and passes them on to streaming services. Most submissions are what they claim to be. Some are not: a re-upload of a commercial recording under a new artist name, a track that already went out through you under a different title, a mash-up built on someone else’s master. This recipe checks every submission with AudD before delivery and writes a review queue CSV that tells a person which tracks to look at and why.
There are two checks:
- Against the public database. Recognize the submission and compare the matched recording (artist, label, ISRC) to what the uploader claims.
- Against your own catalog. Catch the same recording delivered twice under different titles, artists, or ISRCs, using a custom catalog of the masters you have already delivered.
What a match means, and what it doesn’t. AudD tells you which recording in its database the audio matches, with the metadata that recording was released with. It can’t verify ownership or tell you who holds the rights. The
labelfield is the releasing label in the release metadata, which is not necessarily the rights owner: catalogs get sold, licensed, and moved between distributors. A match against a different artist is a reason for a person to look, not proof of infringement. The review decision is yours. This page is not legal advice.
What you’ll build
A Python script that takes a batch of submissions and produces
review_queue.csv, one row per submission per matched recording, with an
action column:
| Category | What happened | Action |
|---|---|---|
no_match | AudD recognized nothing. Normal for a new release that isn’t out anywhere yet, or for material outside the public database. | continue |
own_release | Matches a recording this account already delivered through you (ISRC in your delivery records, or a master in your custom catalog). Usually a re-delivery or a duplicate. | dedupe |
metadata_conflict | Matches a master you already delivered, but the title, artist, or ISRC on the new submission differs. | review |
same_artist_no_history | Matches a release by the artist the uploader claims to be, but you have no delivery record for it. Could be a catalog moving over from another distributor; could be impersonation. | review |
different_artist | Matches a release by a different artist. Possible infringement or impersonation, or a licensed use you don’t know about. | review (hold) |
partial | Part of the track matches an existing recording (a sample, a mash-up, an intro lifted from another track). Enterprise scans only. | review |
no_match does not mean “cleared”. AudD matches recordings, so it does not
detect covers, re-recordings, interpolations, or AI-generated imitations of
an artist’s voice, and it can only match what is in its database of about
160 million commercially released songs.
Prerequisites
- An API token. Get your own token at
dashboard.audd.io. ISRC, UPC, and
scorein results require a Startup plan or higher. Without ISRCs theown_releasecheck falls back to artist and title, which is much weaker. - Python 3.10+ with the SDK:
pip install audd ffmpegandffprobeon yourPATH, to cut excerpts and read durations.- Your own delivery records: for each account, the ISRCs you have delivered and the artist names you have verified for it. The script reads them from whatever store you already have.
- For the catalog check (Part 2): custom catalog access. It isn’t enabled by default; email [email protected] to request it.
Standard excerpt or enterprise scan
You can check a submission two ways, and the choice is a cost and coverage trade-off.
Standard endpoint on a mid-track excerpt. The standard endpoint
(https://api.audd.io/) analyzes the first 12 seconds of what you send and
returns one best match. Many tracks open with silence, a count-in, or an
ambient intro, so cut a 12-second excerpt from the middle of the track and
send that. One request per track.
Enterprise endpoint over the whole track. The enterprise endpoint
(https://enterprise.audd.io/) splits the file into 12-second chunks and
returns every match with its position. It catches a sample at 2:40 that the
excerpt would miss, and it tells you how much of the track matched. It is
billed one request per 12 seconds of audio processed.
For a 3:30 track (210 seconds, 18 chunks):
| Method | Requests per track | 10,000 tracks | Catches |
|---|---|---|---|
| Standard, one mid-track excerpt | 1 | 10,000 | A full re-upload of an existing recording |
Enterprise, every=1, skip=2 (one chunk in three) | 6 | 60,000 | Most sustained reuse; can miss a short sample |
| Enterprise, full coverage | 18 | 180,000 | Anything long enough to match in a chunk |
A common setup is to run the excerpt check on every submission and the full enterprise scan on a subset: new accounts, accounts with earlier flags, genres where sampling is common, or anything the excerpt check flagged.
Part 1: Check submissions against the public database
Step 1: Describe a submission and your records
from dataclasses import dataclass, field
@dataclass
class Submission:
submission_id: str
account_id: str
path: str
claimed_artist: str
claimed_title: str
claimed_label: str = ""
claimed_isrc: str = "" # set when the uploader supplies an existing ISRC
@dataclass
class AccountHistory:
delivered_isrcs: set[str] = field(default_factory=set)
verified_artists: set[str] = field(default_factory=set) # normalized names
Load AccountHistory per account from your own database. The ISRCs are the
ones you delivered for that account; the artist names are the ones you have
verified the account is allowed to deliver as.
Step 2: Cut an excerpt and recognize it
import subprocess
from audd import AudD
audd = AudD("your-api-token") # get your own token at dashboard.audd.io
def duration_seconds(path: str) -> float:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", path],
capture_output=True, text=True, check=True,
).stdout
return float(out.strip())
def excerpt(path: str, seconds: int = 12) -> bytes:
"""A 12-second MP3 excerpt from about 40% into the track."""
dur = duration_seconds(path)
start = max(0.0, min(dur * 0.4, dur - seconds))
return subprocess.run(
["ffmpeg", "-v", "error", "-ss", f"{start:.2f}", "-t", str(seconds),
"-i", path, "-vn", "-ac", "1", "-c:a", "libmp3lame", "-q:a", "4",
"-f", "mp3", "pipe:1"],
capture_output=True, check=True,
).stdout
result = audd.recognize(excerpt("submissions/0042.wav"))
if result:
print(result.artist, "-", result.title, "|", result.label, "|", result.isrc)
else:
print("no match")
recognize returns None when nothing matched. That is a normal outcome,
not an error. To try the same check by hand, the CLI sends a clip from any
point in a file: audd recognize track.wav --at 1:20.
Step 3: Classify a match against the claim
import re
def norm(s: str | None) -> str:
s = (s or "").casefold()
s = re.sub(r"[^\w\s]", " ", s)
return " ".join(s.split())
def same_artist(a: str | None, b: str | None) -> bool:
na, nb = norm(a), norm(b)
return bool(na and nb) and (na == nb or na in nb or nb in na)
def classify_match(sub: Submission, artist, isrc, history: AccountHistory):
isrc = (isrc or "").upper()
if isrc and isrc in history.delivered_isrcs:
return "own_release", "matched ISRC was delivered by this account"
if same_artist(artist, sub.claimed_artist):
if norm(sub.claimed_artist) in history.verified_artists:
return "same_artist_no_history", (
"matches the account's verified artist; release not in your records")
return "same_artist_no_history", (
"matches the claimed artist; account not verified for that artist")
return "different_artist", "matched recording is by a different artist"
same_artist is deliberately loose ("Artist feat. Guest" contains
"Artist"), because the result only routes a track to a person. Two
different artists can share a name, so an artist-name match is never enough
to clear a track on its own; that is why only an ISRC from your own delivery
records produces own_release.
Step 4: Full enterprise scan, with partial matches
For tracks that get the full scan, group the matches by recording and work out how much of the track each recording covers.
import math
FULL_SHARE = 0.6 # starting point; tune on your own submissions
def enterprise_findings(sub: Submission, history: AccountHistory):
dur = duration_seconds(sub.path)
chunks = math.ceil(dur / 12)
matches = audd.recognize_enterprise(
sub.path,
limit=min(chunks, 60), # the whole track, capped at 12 minutes
)
by_recording: dict[str, list] = {}
for m in matches:
key = (m.isrc or "").upper() or f"{norm(m.artist)}|{norm(m.title)}"
by_recording.setdefault(key, []).append(m)
findings = []
for group in by_recording.values():
first = group[0]
covered = sum(
(m.end_seconds - m.start_seconds)
for m in group
if m.start_seconds is not None and m.end_seconds is not None
)
share = covered / dur if dur else 0.0
if share >= FULL_SHARE and len(by_recording) == 1:
category, reason = classify_match(sub, first.artist, first.isrc, history)
else:
category = "partial"
reason = f"{share:.0%} of the track matches this recording"
findings.append({
"category": category, "reason": reason, "match": first,
"first_seen": min((m.start_seconds or 0) for m in group),
"last_seen": max((m.end_seconds or 0) for m in group),
"share": round(share, 2),
})
return findings
start_seconds and end_seconds are positions in the submitted file, in
seconds. The SDK requests accurate offsets by default and computes them from
the chunk position plus the offset inside the 12-second chunk, so you don’t
do that math yourself (see
Enterprise response and match fields
if you call the HTTP API directly).
The limit is set to the number of chunks in the track, with a ceiling. A
submission that turns out to be a two-hour DJ mix uploaded as a “single”
stops at 60 chunks instead of metering the whole file.
A file that matches several recordings, or one recording over a small part
of its length, lands in partial. Sampling, a DJ edit, a remix built on a
released stem, and a track that quotes a famous intro all look like this.
Some are cleared, some are not; AudD can’t tell which.
Part 2: Duplicates and metadata conflicts across your own catalog
The public database only knows what has been released. A track you delivered
last week may not be in it yet, and the public database won’t tell you that
two of your own deliveries are the same audio. For that, fingerprint the
masters you deliver into a custom catalog: your account’s private
fingerprint database. Once a master is in it, ordinary recognition calls can
return that master’s audio_id.
Step 5: Add masters to the catalog, without retrying
Use your internal master ID (an integer) as the audio_id, and keep the
metadata on your side. A custom match returns the audio_id; its artist
and title may be null.
Uploads are metered, and a retried upload that had in fact succeeded the
first time leaves you paying twice. The SDK does not retry
custom_catalog.add; don’t wrap it in your own retry loop. Record the state
before and after each upload, and send anything left in an unknown state to
a person.
import json
from pathlib import Path
from audd.errors import AudDError
REGISTRY = Path("catalog_registry.json") # master_id -> "uploading" | "uploaded" | "needs_check"
def load_registry() -> dict[str, str]:
return json.loads(REGISTRY.read_text()) if REGISTRY.exists() else {}
def save_registry(reg: dict[str, str]) -> None:
REGISTRY.write_text(json.dumps(reg, indent=1))
def add_master(master_id: int, path: str, reg: dict[str, str]) -> None:
key = str(master_id)
if key in reg:
return # uploaded, or waiting for a person to check
reg[key] = "uploading"
save_registry(reg)
try:
audd.custom_catalog.add(master_id, path)
except AudDError as e:
reg[key] = "needs_check" # may or may not have landed; never auto-resend
save_registry(reg)
print(f"master {master_id}: {e}")
return
reg[key] = "uploaded"
save_registry(reg)
By uploading audio to the catalog you confirm you have the rights to do so;
the terms are on the
upload endpoint page. The CLI
equivalent for a one-off is audd catalog add master.wav --id 81234.
Step 6: Check before you add
To find duplicates, recognize each new submission before its master goes into the catalog. If it matches a master that is already there, you have the same recording twice. Doing it in this order also works for a backfill: walk your existing masters oldest first, check each one, then add it.
A standard call returns one best match, which can come from your catalog
(audio_id set) or from the public database. A master you delivered months
ago is likely in the public database too, so check both: the audio_id
against your catalog, and the ISRC against your delivery records.
def catalog_check(sub: Submission, masters: dict[int, dict], isrc_index: dict[str, int]):
"""masters: master_id -> {"account_id", "artist", "title", "isrc"}.
isrc_index: ISRC -> master_id for everything you've delivered."""
r = audd.recognize(excerpt(sub.path))
if r is None:
return None
if r.audio_id is not None:
master_id = r.audio_id
elif r.isrc and r.isrc.upper() in isrc_index:
master_id = isrc_index[r.isrc.upper()]
else:
return None # a public match you didn't deliver: Part 1 handles it
prior = masters.get(master_id)
if prior is None:
return ("metadata_conflict", f"audio_id {master_id} not in your master table", r)
conflicts = []
if not same_artist(prior["artist"], sub.claimed_artist):
conflicts.append(f"artist was {prior['artist']!r}")
if norm(prior["title"]) != norm(sub.claimed_title):
conflicts.append(f"title was {prior['title']!r}")
if (sub.claimed_isrc or "").upper() != (prior["isrc"] or "").upper():
conflicts.append(f"ISRC was {prior['isrc']!r}")
if prior["account_id"] != sub.account_id:
conflicts.append(f"delivered by account {prior['account_id']}")
if not conflicts:
return ("own_release", f"same as master {master_id}", r)
return ("metadata_conflict", f"master {master_id}: " + "; ".join(conflicts), r)
A blank claimed_isrc against a master that already has one shows up as a
conflict on purpose: it means someone is about to assign a second ISRC to a
recording that already has one.
Step 7: Write the review queue
Run both checks per submission and write one CSV row per finding, with enough evidence that the reviewer doesn’t have to re-run anything.
import csv
from datetime import datetime, timezone
FIELDS = [
"submission_id", "account_id", "claimed_artist", "claimed_title",
"claimed_label", "claimed_isrc", "category", "action", "reason",
"matched_artist", "matched_title", "matched_label", "matched_isrc",
"matched_upc", "score", "audio_id", "first_seen_s", "last_seen_s",
"share", "song_link", "method", "checked_at",
]
ACTIONS = {"no_match": "continue", "own_release": "dedupe"}
def row(sub, category, reason, m=None, method="standard", **extra):
return {
"submission_id": sub.submission_id, "account_id": sub.account_id,
"claimed_artist": sub.claimed_artist, "claimed_title": sub.claimed_title,
"claimed_label": sub.claimed_label, "claimed_isrc": sub.claimed_isrc,
"category": category, "action": ACTIONS.get(category, "review"),
"reason": reason,
"matched_artist": getattr(m, "artist", "") or "",
"matched_title": getattr(m, "title", "") or "",
"matched_label": getattr(m, "label", "") or "",
"matched_isrc": getattr(m, "isrc", "") or "",
"matched_upc": getattr(m, "upc", "") or "",
"score": getattr(m, "score", "") or "",
"audio_id": getattr(m, "audio_id", "") or "",
"song_link": getattr(m, "song_link", "") or "",
"method": method,
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
**extra,
}
def screen(sub, history, masters, isrc_index, full_scan: bool) -> list[dict]:
hit = catalog_check(sub, masters, isrc_index)
if hit:
category, reason, r = hit
return [row(sub, category, reason, r)]
if full_scan:
found = enterprise_findings(sub, history)
if not found:
return [row(sub, "no_match", "nothing recognized", method="enterprise")]
return [row(sub, f["category"], f["reason"], f["match"], method="enterprise",
first_seen_s=f["first_seen"], last_seen_s=f["last_seen"],
share=f["share"]) for f in found]
r = audd.recognize(excerpt(sub.path))
if r is None:
return [row(sub, "no_match", "nothing recognized in excerpt")]
category, reason = classify_match(sub, r.artist, r.isrc, history)
return [row(sub, category, reason, r)]
catalog_check already made the excerpt call, so on the standard path you
can reuse its result instead of recognizing twice; it is kept separate here
for readability. Write the rows with csv.DictWriter(f, fieldnames=FIELDS, restval="").
A finished queue looks like this:
submission_id,account_id,claimed_artist,claimed_title,claimed_label,claimed_isrc,category,action,reason,matched_artist,matched_title,matched_label,matched_isrc,matched_upc,score,audio_id,first_seen_s,last_seen_s,share,song_link,method,checked_at
s-1001,acc-77,Northbound,Glass,,,no_match,continue,nothing recognized in excerpt,,,,,,,,,,,,standard,2026-10-12T09:14:03+00:00
s-1002,acc-77,Northbound,Glass (Radio Edit),,,metadata_conflict,review,"master 81234: title was 'Glass'; ISRC was 'QZES72612345'",,,,,,,81234,,,,,standard,2026-10-12T09:14:05+00:00
s-1003,acc-91,DJ Novak,Rule The World,,,different_artist,review,matched recording is by a different artist,Tears For Fears,Everybody Wants To Rule The World,UMC (Universal Music Catalogue),GBUM71403885,00602547037169,,,,,,https://lis.tn/NbkVb,standard,2026-10-12T09:14:06+00:00
Reviewing the queue
The reviewer’s job is to decide what the evidence means. Some patterns that come up:
different_artistwith a major-labellabel. Ask the uploader for the licence or the contract that covers it. A remix contest or a licensed sample is legitimate; an unexplained full re-upload usually isn’t.same_artist_no_history. Common when an artist moves their catalog from another distributor; the ISRCs normally travel with the recordings. Confirm the account really belongs to that artist before you deliver.partial. Listen to thefirst_seen_s–last_seen_srange in the submission next to the matched recording’ssong_link.metadata_conflict. Decide which metadata is right, and whether the submission is a new release of an existing recording or a mistake.
Keep the CSV rows and the reviewer’s decision together. If a decision is challenged later, you have what was matched, when, and why it was held.
Handling errors
AudDAuthenticationError: bad token. Stop the batch.AudDQuotaError,AudDSubscriptionError: out of requests, or a feature (enterprise, ISRC) not available on the plan. Stop and fix the account; retrying won’t help.AudDCustomCatalogAccessError: catalog access isn’t enabled yet. Email [email protected].AudDInvalidAudioError: the submission isn’t decodable audio. Return it to the uploader.AudDConnectionErroron a recognition call: safe to retry with backoff. On a catalog upload: don’t retry; the registry marks itneeds_check.
Cancelling a recognition request mid-flight might still consume requests.
Going further
- Store every result keyed by submission ID and file hash, so a re-submitted identical file doesn’t get re-scanned.
- Re-check
no_matchsubmissions after delivery if you want to catch material that reached the public database later. - Score a match before routing it: see Interpreting the recognition score.
Related
Reading this as an AI agent? The raw Markdown is at recipes/screen-releases-before-distribution.md, and the full index is /resources/llms.txt.
