---
title: "Scan your stream archive (VODs and clips) for copyrighted music"
description: "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."
slug: "/resources/recipes/scan-vods-and-clips-for-copyrighted-music"
section: "recipes"
keywords: [audd, vod, twitch vod, stream archive, clips, copyrighted music, enterprise endpoint, cli, mute music]
---

# Scan your stream archive (VODs and clips) for copyrighted music

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

1. A scan of a folder of VODs and clips, sampled to keep cost down.
2. `archive-music.csv`: one row per music segment per file, with start and
   end times in the file and the track details.
3. A `decision` column 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](https://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](https://docs.audd.io/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`, and `ffmpeg` /
  `ffprobe` on your `PATH`.

## 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:

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

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

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

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

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

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

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

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

```text
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](/resources/recipes/flag-music-in-live-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.

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

- [Flag and remove music in a live-event recording](/resources/recipes/flag-music-in-live-recording)
- [AudD for Twitch streamers](/resources/for/twitch-streamers)
- [Identify music from the command line with the AudD CLI](/resources/recipes/command-line-music-recognition)
- [Controlling enterprise endpoint cost](/resources/concepts/enterprise-cost-control)
- [Enterprise response and match fields](/resources/reference/enterprise-match-fields)
- [AudD CLI reference](https://docs.audd.io/cli)

## 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.
