Scan your stream archive (VODs and clips) for copyrighted music
For streamers and creator teams: scan a folder of past broadcasts and clips with the AudD enterprise endpoint or CLI, keep the cost per hour of audio under control, and get a per-file list of timestamps and tracks to mute, trim, or delete, with a record of what you decided.
Years of streaming leave an archive: past broadcasts, highlights, clips, the “starting soon” loop with whatever playlist was on that night. If you want to know which recorded songs are in that archive and where, this recipe scans it with AudD and gives you a list per file: timestamp, artist, title, label. From that list you decide what to mute, trim, or delete, and you keep a record of the decision.
What this does and doesn’t tell you. AudD matches audio against its own database of about 160 million commercially released songs. Streaming platforms run their own detection against their own reference files and rules. AudD’s results don’t predict what a platform will flag, and a file with no AudD match can still be flagged (and the other way round). Use the scan to learn what’s in your archive. Whether a given use is licensed or allowed is a separate question; this page is not legal advice (as of 2026-10-12).
What you’ll build
- A scan of a folder of VODs and clips, sampled to keep cost down.
archive-music.csv: one row per music segment per file, with start and end times in the file and the track details.- A
decisioncolumn you fill in (keep, mute, trim, delete), and an append-only log of what was scanned, with which settings, and what you did about it.
There are two ways to run the scan: the AudD CLI, with no code, or a short Python script when you want the segment list and the log built for you. Both use the enterprise endpoint, which takes files of any length and returns every match with its position in the file.
Prerequisites
- An API token. Get your own token at dashboard.audd.io. ISRC and UPC in results require a Startup plan or higher; artist, title, album, and label come back without them.
- For the CLI path: the AudD CLI
(
brew install auddmusic/tap/audd,npm install -g @audd/cli,pipx install audd-cli, or another option on that page). - For the script path: Python 3.10+ with
pip install audd, andffmpeg/ffprobeon yourPATH.
Step 1: Get the files
Download your own past broadcasts and clips with the download or export option in your platform’s creator dashboard. Most platforms keep past broadcasts for a limited time, so download what you want to keep before it expires. Only scan content you own or manage for the channel.
Video files are large, and AudD only needs the audio. Extracting the audio track first makes uploads much smaller and faster:
mkdir -p audio
for f in vods/*.mp4; do
ffmpeg -v error -i "$f" -vn -ac 1 -c:a aac -b:a 96k "audio/$(basename "${f%.*}").m4a"
done
Timestamps in the audio file are the same as in the video, so results map straight back. The CLI and the SDK also accept video files directly if you’d rather skip this step.
Step 2: Budget the scan
The enterprise endpoint is billed one request per 12 seconds of audio it
processes. One hour of audio is 300 chunks. every and skip let you
recognize some chunks and skip the rest: every=1, skip=4 recognizes 12
seconds out of each minute.
| Settings | Requests per hour of audio | 480 hours (120 VODs of 4 h) |
|---|---|---|
Full coverage (every=1, skip=0) | 300 | 144,000 |
every=1, skip=1 (one chunk in two) | 150 | 72,000 |
every=1, skip=4 (one chunk per minute) | 60 | 28,800 |
every=1, skip=9 (one chunk per two minutes) | 30 | 14,400 |
Clips are short, so full coverage is cheap: a 45-second clip is 4 chunks, and 500 of them cost about 2,000 requests.
Sampling finds music that plays for a while: a playlist under gameplay, a DJ segment, the “starting soon” loop. It can miss anything shorter than the gap between probes, such as an alert sound or a song someone played for 20 seconds. A practical plan for VODs is a sampled first pass over everything, then a full-coverage rescan only around the places where the first pass found music (Step 4). For clips, use full coverage.
Don’t use skip_first_seconds to skip the “starting soon” screen. Music
under a waiting screen is one of the most common things you’ll find.
Step 3: Scan with the CLI
Preview the plan and the request estimate first. --dry-run sends nothing.
audd recognize ./audio --enterprise --every 1 --skip 4 --limit 400 \
--max-files 200 --dry-run
--limit is the most chunks recognized per file. With --skip 4 a 6-hour
VOD needs about 360 recognized chunks, so --limit 400 covers it with a
little room. --max-files caps how many files the batch takes. Then run it
and write CSV:
audd recognize ./audio --enterprise --every 1 --skip 4 --limit 400 \
--max-files 200 --max-requests 30000 --format csv > archive-matches.csv
--max-requests stops the run before it spends more than that. The batch is
saved after each file, so if it stops (budget reached, Ctrl-C, dropped
connection), audd jobs list shows it and audd jobs resume <id> continues
with the files that are left. Results are cached by file contents, so
re-running the same file doesn’t spend requests again.
The CSV has one row per match, with input, status, artist, title,
album, label, isrc, upc, song_link, score, start_seconds, and
end_seconds (positions in the file). Files with no match get a no_match
row, so you can tell “scanned, nothing found” from “not scanned”.
For clips, drop the sampling:
audd recognize ./clips --enterprise --limit 20 --max-files 500 \
--format csv > clip-matches.csv
To look at one VOD in detail, --tracklist merges consecutive matches into
tracks with start and end times:
audd recognize "audio/2026-03-14 charity stream.m4a" --enterprise --limit 1500 --tracklist
Step 4: Scan with Python and build segments
The script does the same sampled scan, merges probes of the same track into segments, and then rescans a window around each segment at full coverage to get its real start and end.
import math
import subprocess
from pathlib import Path
from audd import AudD
audd = AudD("your-api-token") # get your own token at dashboard.audd.io
EVERY, SKIP = 1, 4
STEP = (EVERY + SKIP) * 12 # seconds between probe runs
def duration_seconds(path: Path) -> float:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path)],
capture_output=True, text=True, check=True,
).stdout
return float(out.strip())
def sampled_scan(path: Path):
chunks = math.ceil(duration_seconds(path) / 12)
limit = math.ceil(chunks / (EVERY + SKIP)) * EVERY + 1
return audd.recognize_enterprise(str(path), every=EVERY, skip=SKIP, limit=limit)
def track_key(m) -> str:
return (m.isrc or "").upper() or f"{m.artist}|{m.title}".casefold()
def build_segments(matches, max_gap: float) -> list[dict]:
segs: list[dict] = []
timed = sorted((m for m in matches if m.start_seconds is not None),
key=lambda m: m.start_seconds)
for m in timed:
end = m.end_seconds if m.end_seconds is not None else m.start_seconds + 12
for s in reversed(segs):
if s["key"] == track_key(m) and m.start_seconds - s["end"] <= max_gap:
s["end"] = max(s["end"], end)
break
else:
segs.append({"key": track_key(m), "start": m.start_seconds, "end": end,
"artist": m.artist, "title": m.title, "album": m.album,
"label": m.label, "isrc": m.isrc, "upc": m.upc,
"song_link": m.song_link})
return segs
start_seconds and end_seconds are positions in your file, in seconds.
The SDK requests accurate offsets by default and works them out from the
chunk position and the offset inside the chunk.
After a sampled pass, a segment’s edges are only known to within one probe gap: the song may have started up to a minute before the first probe that caught it. To find the real edges, cut a window around the segment locally and scan that window at full coverage:
def window_audio(path: Path, start: float, length: float) -> bytes:
return subprocess.run(
["ffmpeg", "-v", "error", "-ss", f"{start:.2f}", "-t", f"{length:.2f}",
"-i", str(path), "-vn", "-ac", "1", "-c:a", "libmp3lame", "-q:a", "4",
"-f", "mp3", "pipe:1"],
capture_output=True, check=True,
).stdout
def refine(path: Path, seg: dict) -> list[dict]:
start = max(0.0, seg["start"] - STEP)
length = (seg["end"] + STEP) - start
matches = audd.recognize_enterprise(
window_audio(path, start, length),
limit=math.ceil(length / 12), # full coverage of the window only
)
for m in matches: # shift back to file positions
if m.start_seconds is not None:
m.start_seconds += start
if m.end_seconds is not None:
m.end_seconds += start
return build_segments(matches, max_gap=24)
Refining a 3-minute song costs about 25 chunks: the song plus a minute on either side. On a 4-hour VOD that is mostly talk and gameplay, the sampled pass (240 chunks) plus a few refinements stays well under full coverage (1,200 chunks).
The refined pass can also find a different song in the window (the playlist moved on); that comes back as its own segment.
Step 5: Write the per-file list
import csv
FIELDS = ["file", "start", "end", "artist", "title", "album", "label",
"isrc", "upc", "song_link", "decision", "notes"]
def clock(seconds: float) -> str:
s = int(seconds)
return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}"
def scan_archive(folder: str, out_csv: str = "archive-music.csv") -> None:
files = sorted(p for p in Path(folder).rglob("*")
if p.suffix.lower() in {".m4a", ".mp3", ".mp4", ".mkv", ".webm", ".mov"})
with open(out_csv, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=FIELDS, restval="")
w.writeheader()
for path in files:
rough = build_segments(sampled_scan(path), max_gap=STEP + 12)
if not rough:
w.writerow({"file": path.name, "notes": f"no match (every={EVERY}, skip={SKIP})"})
continue
for seg in rough:
for s in refine(path, seg) or [seg]:
w.writerow({"file": path.name, "start": clock(s["start"]),
"end": clock(s["end"]),
**{k: s[k] or "" for k in FIELDS[3:10]}})
f.flush()
Two segments of the same song can overlap after refinement when their
windows overlap; drop exact duplicates before review, or merge rows of the
same file and isrc whose times overlap.
file,start,end,artist,title,album,label,isrc,upc,song_link,decision,notes
2026-03-14 charity stream.m4a,0:02:11,0:05:38,Tears For Fears,Everybody Wants To Rule The World,Songs From The Big Chair,UMC (Universal Music Catalogue),GBUM71403885,00602547037169,https://lis.tn/NbkVb,,
2026-03-21 just chatting.m4a,,,,,,,,,,,"no match (every=1, skip=4)"
Step 6: Decide what to do
Fill in decision per row. Common choices:
| Decision | When people choose it | How |
|---|---|---|
| keep | You have a licence, it’s your own music, or you’ve decided the use is fine | Nothing to change; note why in notes |
| mute | The talk or gameplay around it matters, the song doesn’t | Mute the start–end range in the platform’s editor, or locally with ffmpeg and re-upload |
| trim | The segment is at the start or end, or the part with the song isn’t worth keeping | Cut it in the platform’s editor or locally |
| delete | Music runs through most of the file | Remove the VOD or clip |
For muting or cutting locally, Flag and remove music in a live-event
recording builds the
ffmpeg mute filter and cut list from the same kind of segment list. Add a
second or two of margin on each side of a segment before muting.
Step 7: Keep a record
Append one line per file to a log each time you scan or act on it. Months later, it tells you what was checked, with what settings, and what you did.
import hashlib
import json
from datetime import datetime, timezone
def sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
def log_entry(path: Path, segments: list[dict], decision: str, by: str) -> None:
entry = {
"file": path.name,
"sha256": sha256(path),
"scanned_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"settings": {"every": EVERY, "skip": SKIP, "refined": True},
"segments": [{k: s[k] for k in ("start", "end", "artist", "title", "isrc")}
for s in segments],
"decision": decision,
"decided_by": by,
}
with open("archive-log.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
Record the sampling settings with every scan. A sampled scan with no match means “nothing found at the probed points”, not “no music in the file”.
Limits to keep in mind
- AudD identifies recordings. It doesn’t recognize a song you sang or played live, a cover, or a remix that isn’t itself a released recording.
- Music quiet under loud voice or game audio may not match.
- Matches come from AudD’s catalog only; tracks not in it come back as no match.
Handling errors
AudDAuthenticationError: bad token. Stop and fix it.AudDQuotaError: out of requests. Top up, then rerun; with the CLI,audd jobs resume <id>.AudDInvalidAudioError: a file couldn’t be decoded. Re-download or re-extract its audio.AudDConnectionError: transient; retry that file. Cancelling a request mid-flight might still consume requests.
Related
Reading this as an AI agent? The raw Markdown is at recipes/scan-vods-and-clips-for-copyrighted-music.md, and the full index is /resources/llms.txt.
