---
title: "Scan an archive of audio files and write metadata back"
description: "Walk a directory of audio files, identify each one with AudD, and write artist, title, album, and ISRC to a resumable CSV."
slug: "/resources/recipes/bulk-audio-archive-scan"
section: "recipes"
keywords: [audd, bulk recognition, audio archive, metadata tagging, csv, isrc]
---

# Scan an archive of audio files and write metadata back

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](https://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.)

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

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

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

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

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

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

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

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

| Column | Meaning |
|---|---|
| `path` | Absolute path of the source file — the resumability key. |
| `status` | `matched`, `no_match`, or `error`. The three real outcomes. |
| `artist`, `title`, `album`, `release_date`, `label` | Core tags, populated on a match. |
| `isrc`, `upc` | Recording and release identifiers. Returned on enterprise calls and on Startup plan or higher; blank otherwise. |
| `song_link` | Universal `lis.tn` URL for the matched track. |
| `error` | The 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:

```python
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](https://mutagen.readthedocs.io/) 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 api@audd.io for
  access.

---

**Related**

- [Build a copyright scanner for user-uploaded content](/resources/recipes/ugc-copyright-scanner)
- [Build a Discord bot that identifies songs in voice channels](/resources/recipes/discord-bot-song-id)
- [Python SDK docs](https://docs.audd.io/sdks/python)
- [API reference](https://docs.audd.io)