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.
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:
- Walks the directory for audio files.
- Loads any existing CSV and skips files already done (resumability).
- Recognizes each remaining file through a bounded thread pool.
- Writes one CSV row per file as results come in —
matched,no_match, orerror— 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
limiton 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 alimitand 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
| 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. LowerMAX_WORKERS, raiseTHROTTLE_SECONDS, and re-run; resumability means you only retry what didn’t finish. - Invalid-audio errors (
AudDInvalidAudioError) — a single unreadable file. Recorded as anerrorrow; the run continues. - Connection errors (
AudDConnectionError) — transient network issues. The SDK retries pre-upload network failures automatically; a persistent one lands in theerrorcolumn 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.csvis correct, a second pass with mutagen can writeartist/title/albuminto 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"]torecognizeto 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_extramap (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.
