---
title: "Screen releases before you deliver them to streaming services"
description: "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."
slug: "/resources/recipes/screen-releases-before-distribution"
section: "recipes"
keywords: [audd, music distribution, release screening, isrc, duplicate detection, custom catalog, metadata conflict, review queue]
---

# Screen releases before you deliver them to streaming services

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:

1. **Against the public database.** Recognize the submission and compare the
   matched recording (artist, label, ISRC) to what the uploader claims.
2. **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 `label` field 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](https://dashboard.audd.io). ISRC, UPC, and `score` in
  results require a Startup plan or higher. Without ISRCs the `own_release`
  check falls back to artist and title, which is much weaker.
- Python 3.10+ with the SDK: `pip install audd`
- `ffmpeg` and `ffprobe` on your `PATH`, 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 api@audd.io 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

```python
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

```python
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

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

```python
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](/resources/reference/enterprise-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.

```python
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](https://docs.audd.io/upload_audio_endpoint). 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.

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

```python
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:

```text
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_artist` with a major-label `label`.** 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 the `first_seen_s`–`last_seen_s` range in the
  submission next to the matched recording's `song_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
  api@audd.io.
- `AudDInvalidAudioError`: the submission isn't decodable audio. Return it
  to the uploader.
- `AudDConnectionError` on a recognition call: safe to retry with backoff.
  On a catalog upload: don't retry; the registry marks it `needs_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_match` submissions 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](/resources/concepts/score-thresholds).

---

**Related**

- [AudD for music distributors](/resources/for/music-distributors)
- [Detect samples and reuse of your own tracks](/resources/recipes/sample-detection)
- [Public database vs your custom catalog](/resources/concepts/custom-vs-public-db)
- [Controlling enterprise endpoint cost](/resources/concepts/enterprise-cost-control)
- [Enterprise response and match fields](/resources/reference/enterprise-match-fields)
- [Python SDK docs](https://docs.audd.io/sdks/python)

## Sitemap

See the [resources sitemap](https://audd.io/resources/sitemap.md) for every guide and the [glossary](https://audd.io/resources/reference/glossary.md) for AudD terms.
