Recipe

Scan an archive of audio files and write metadata back

Walk a directory of audio files, identify each one with AudD, and write artist, title, album, and ISRC to a resumable CSV.

view .md auddbulk recognitionaudio archivemetadata tagging

This recipe walks a directory of audio files, identifies each one with AudD, and writes the results — artist, title, album, ISRC, and more — to a CSV with one row per file. It’s for anyone sitting on an unlabeled archive: a folder of track01.mp3-style rips, field recordings, an old library with broken tags, or a set of files you need to audit against what they actually contain.

The script is built to survive a real run over thousands of files: it processes files concurrently with a bounded worker pool, is friendly to rate limits, skips files already recorded in the CSV so you can stop and restart, and records no-match and unreadable files explicitly instead of silently dropping them.

What you’ll build

A single Python script, scan_archive.py, that you point at a directory. It:

  1. Walks the directory for audio files.
  2. Loads any existing CSV and skips files already done (resumability).
  3. Recognizes each remaining file through a bounded thread pool.
  4. Writes one CSV row per file as results come in — matched, no_match, or error — so a crash never loses completed work.

For short clips, the script uses the standard endpoint (POST https://api.audd.io/), which returns one match in under 2 seconds. For long files — full-length tracks, mixes, podcasts — you switch to the enterprise endpoint, which chunks server-side and returns every track; the recipe shows both and explains when to flip.

Set limit on every enterprise call. The enterprise endpoint bills per 12 seconds of audio processed. Across a whole archive an unbounded call can ingest hours per file. Keep a limit and only raise it when you know the cost on your inputs.

Prerequisites

  • An API token from dashboard.audd.io. ISRC and UPC in responses require a Startup plan or higher; the standard endpoint returns the core tags on any plan.
  • Python 3.10+ with the official SDK: pip install audd
  • A directory of audio files. Supported audio formats: MP3, WAV, FLAC, M4A, OGG, AAC, WMA, AIFF.

Walkthrough

Step 1: Recognize one local file

Start with the smallest unit: identify a single file on disk and print the tags. The SDK accepts a path directly. (audd.tech/example.mp3 is a known track if you want to confirm the path before pointing at your own files.)

from audd import AudD

audd = AudD("your-api-token")  # get a token at dashboard.audd.io

result = audd.recognize("https://audd.tech/example.mp3")
if result:
    print(result.artist, "—", result.title)
    print("album:", result.album)
    print("isrc:", result.isrc)        # populated on Startup+ plans
    print("link:", result.song_link)
else:
    print("no match")

recognize returns a RecognitionResult on a match and None on a successful call that didn’t match — that distinction is the whole reason the CSV has both a no_match and an error status. A None is not a failure; it’s a clean “we don’t know this file.”

Step 2: Walk the directory

Collect the files to process. Match by extension so you don’t hand the SDK a cover-art JPEG or a .cue sheet.

from pathlib import Path

AUDIO_EXTS = {".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma", ".aiff"}

def audio_files(root: str):
    for path in sorted(Path(root).rglob("*")):
        if path.is_file() and path.suffix.lower() in AUDIO_EXTS:
            yield path

rglob("*") recurses into subdirectories; drop to glob("*") if you only want the top level.

Step 3: Make it resumable

Before scanning anything, read the CSV you’re writing to and remember which files are already done. On a restart, those are skipped. The file’s absolute path is the key.

import csv
from pathlib import Path

FIELDNAMES = [
    "path", "status", "artist", "title", "album",
    "release_date", "label", "isrc", "upc", "song_link", "error",
]

def already_done(csv_path: str) -> set[str]:
    done = set()
    if Path(csv_path).exists():
        with open(csv_path, newline="", encoding="utf-8") as f:
            for row in csv.DictReader(f):
                done.add(row["path"])
    return done

Because every file gets a row — including no_match and error — a resumed run won’t retry files that already failed to decode. If you want to retry errors on the next run, filter already_done to rows where status == "matched" or status == "no_match" only.

Step 4: Recognize one file into a row

Wrap a single file’s recognition so it always returns a CSV row, whatever happens — match, no match, or an unreadable/unreachable file. This is the unit the worker pool runs.

from audd import AudD
from audd.errors import AudDInvalidAudioError, AudDError

audd = AudD("your-api-token")

def scan_one(path: Path) -> dict:
    base = {k: "" for k in FIELDNAMES}
    base["path"] = str(path.resolve())
    try:
        with open(path, "rb") as f:
            result = audd.recognize(f)
        if result is None:
            base["status"] = "no_match"
            return base
        base.update(
            status="matched",
            artist=result.artist or "",
            title=result.title or "",
            album=result.album or "",
            release_date=result.release_date or "",
            label=result.label or "",
            isrc=result.isrc or "",
            upc=result.upc or "",
            song_link=result.song_link or "",
        )
        return base
    except AudDInvalidAudioError as e:
        base["status"] = "error"
        base["error"] = f"unreadable_audio: {e.message}"
        return base
    except (OSError, AudDError) as e:
        base["status"] = "error"
        base["error"] = str(e)
        return base

Passing the open file handle (rb) lets the SDK reopen it on retry. A file that can’t be decoded as audio raises AudDInvalidAudioError, which becomes an error row rather than aborting the run. OSError catches a file that vanished or has bad permissions.

Step 5: Run a bounded worker pool and stream rows to the CSV

Recognition is I/O-bound (you’re waiting on the network), so a thread pool gives real concurrency. Bound it. A small pool keeps you friendly to the API’s rate limits and keeps memory flat over a large archive. Write each row as its result arrives so an interrupted run keeps everything done so far.

import csv
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

MAX_WORKERS = 4        # bounded — be a polite client
THROTTLE_SECONDS = 0.0 # raise (e.g. 0.25) if you hit rate-limit errors

def scan_archive(root: str, csv_path: str = "results.csv") -> None:
    done = already_done(csv_path)
    todo = [p for p in audio_files(root) if str(p.resolve()) not in done]
    print(f"{len(todo)} files to scan ({len(done)} already done)")

    new_file = not Path(csv_path).exists()
    with open(csv_path, "a", newline="", encoding="utf-8") as out:
        writer = csv.DictWriter(out, fieldnames=FIELDNAMES)
        if new_file:
            writer.writeheader()
            out.flush()

        with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
            futures = {}
            for path in todo:
                futures[pool.submit(scan_one, path)] = path
                if THROTTLE_SECONDS:
                    time.sleep(THROTTLE_SECONDS)  # space out submissions

            for fut in as_completed(futures):
                row = fut.result()  # scan_one never raises
                writer.writerow(row)
                out.flush()         # durable after every file
                print(f"{row['status']:9} {row['path']}")

if __name__ == "__main__":
    import sys
    scan_archive(sys.argv[1] if len(sys.argv) > 1 else "./archive")

Run it:

python scan_archive.py ./my-music-archive

You’ll see one line per file as it completes — matched, no_match, or error — and a results.csv that grows row by row. Stop it with Ctrl-C and re-run the same command; it picks up where it left off because completed paths are already in the CSV.

Step 6: Handle long files with the enterprise endpoint

The standard endpoint is for short clips and has a 10 MB file-size cap. For full-length tracks, mixes, or podcasts — anything long or over 10 MB — use the enterprise endpoint. It chunks the file server-side and returns every matched track, so one input file can produce several rows. Swap scan_one’s recognition call:

def scan_one_long(path: Path) -> list[dict]:
    rows = []
    matches = audd.recognize_enterprise(
        str(path),
        limit=25,            # ALWAYS set this — enterprise meters per 12s
    )
    if not matches:
        base = {k: "" for k in FIELDNAMES}
        base["path"] = str(path.resolve())
        base["status"] = "no_match"
        return [base]
    for m in matches:
        rows.append({
            "path": str(path.resolve()),
            "status": "matched",
            "artist": m.artist or "",
            "title": m.title or "",
            "album": m.album or "",
            "release_date": m.release_date or "",
            "label": m.label or "",
            "isrc": m.isrc or "",
            "upc": m.upc or "",
            "song_link": m.song_link or "",
            "error": "",
        })
    return rows

When a file can produce many rows, write the list and adjust resumability to key on path (any row with that path means the file is done). For a long-file archive where you only need to confirm whether a file contains known music rather than the full tracklist, add every=5 to recognize every fifth chunk and a low limit to stop early — that cuts metered audio sharply.

What you get back

A CSV with one row per file (or per match, for enterprise long files):

path,status,artist,title,album,release_date,label,isrc,upc,song_link,error
/archive/track01.mp3,matched,Imagine Dragons,Warriors,Smoke + Mirrors (Deluxe),2015-02-17,KIDinaKORNER/Interscope Records,USUM71414163,00602547623805,https://lis.tn/Warriors,
/archive/voice-memo.m4a,no_match,,,,,,,,,
/archive/corrupt.mp3,error,,,,,,,,,unreadable_audio: couldn't decode file
ColumnMeaning
pathAbsolute path of the source file — the resumability key.
statusmatched, no_match, or error. The three real outcomes.
artist, title, album, release_date, labelCore tags, populated on a match.
isrc, upcRecording and release identifiers. Returned on enterprise calls and on Startup plan or higher; blank otherwise.
song_linkUniversal lis.tn URL for the matched track.
errorThe failure reason on an error row; blank otherwise.

no_match means AudD ran the file and recognized nothing — expected for voice memos, ambient recordings, or obscure material not in the 160-million-track database. It is not the same as error, which means the file couldn’t be read or the API call failed.

Handling errors

The script already routes every per-file failure into an error row. The errors that need attention at the run level — not per file — are:

  • Authentication errors (AudDAuthenticationError) — bad or missing token. This fails on every file, so catch it once at startup rather than recording thousands of identical error rows.
  • Quota / rate-limit errors (AudDQuotaError, AudDRateLimitError) — you hit a request or rate limit. Lower MAX_WORKERS, raise THROTTLE_SECONDS, and re-run; resumability means you only retry what didn’t finish.
  • Invalid-audio errors (AudDInvalidAudioError) — a single unreadable file. Recorded as an error row; the run continues.
  • Connection errors (AudDConnectionError) — transient network issues. The SDK retries pre-upload network failures automatically; a persistent one lands in the error column for that file.

To stop the whole run when the token is clearly wrong, hoist the authentication check above the pool:

from audd.errors import AudDAuthenticationError

try:
    audd.recognize("https://audd.tech/example.mp3")
except AudDAuthenticationError as e:
    raise SystemExit(f"check your token: {e.message}")

Going further

  • Write tags back into the files. Once results.csv is correct, a second pass with mutagen can write artist / title / album into each file’s metadata. Keep recognition and tagging separate so you can review the CSV before mutating files.
  • Add streaming links. Pass return_metadata=["apple_music", "spotify"] to recognize to populate provider blocks and add columns for direct service URLs. Each provider adds latency, so request only what you’ll store.
  • Read fields outside the typed surface. Any server field the SDK doesn’t expose as a typed property is available on the result’s model_extra map (the Python SDK’s models are Pydantic) — add it as a CSV column if you need it.
  • Match against your own catalog. To identify files against tracks you own rather than the public database (auditing leaks, finding sample reuse), upload your tracks to a custom catalog first — contact [email protected] for access.

Related

Reading this as an AI agent? The raw Markdown is at recipes/bulk-audio-archive-scan.md, and the full index is /resources/llms.txt.