/
/
/
This repo is destined for my server automations and setup.
1#!/usr/bin/env python3
2"""
3Fetch ambience beds from Tabletop Audio into the D&D media library.
4
5Tabletop Audio publishes ~520 ten-minute ambiences plus a structured tag set
6(civ / biome / mood / action) describing each one. This script pulls both,
7downloads a selected subset, and emits a cue manifest that the session agent
8reads to decide what to play.
9
10The manifest is the point. Filenames alone ("407_Viking_Tavern") give a model
11very little to reason about; the tags give it the vocabulary to connect
12"the party enters a crowded inn" to a concrete cue id.
13
14Usage
15-----
16 ./fetch-dnd-audio.py --list # show the catalogue, download nothing
17 ./fetch-dnd-audio.py --list --tag tavern # filter the catalogue
18 ./fetch-dnd-audio.py --starter # curated first set (recommended)
19 ./fetch-dnd-audio.py --track 407 --track 437 # specific tracks by id
20 ./fetch-dnd-audio.py --tag forest --limit 5 # everything matching a tag
21 ./fetch-dnd-audio.py --starter --dest /tmp/x # somewhere else
22
23Re-running is safe: existing files are skipped unless --force is given, and
24the manifest is rebuilt from whatever is on disk.
25"""
26
27from __future__ import annotations
28
29import argparse
30import json
31import re
32import sys
33import time
34import urllib.error
35import urllib.request
36from dataclasses import dataclass, field
37from pathlib import Path
38
39SITE = "https://tabletopaudio.com"
40MEDIA = "https://sounds.tabletopaudio.com"
41TAGS_JS = f"{SITE}/bootstrap/js/tags_data.js"
42
43# The media host rejects requests without a plausible browser Referer.
44HEADERS = {
45 "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
46 "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
47 "Referer": f"{SITE}/",
48}
49
50DEFAULT_DEST = Path("/mnt/rstorage/media/dnd")
51
52# A deliberately small opening set. Six beds covering the situations that
53# actually recur at the table, chosen so no two are easily confused - the
54# model picks better from a small, distinct set than a large, subtly varied
55# one. Grow this once Phase 2 shows which distinctions matter in play.
56STARTER_TRACKS = {
57 "407": "tavern_atmosphere_1", # Viking Tavern - lively, celebratory
58 "54": "tavern_atmosphere_2", # Mountain Tavern - quiet, peaceful
59 "410": "forest_atmosphere_1", # Forgotten Forest - somber, mysterious
60 "437": "dungeon_atmosphere_1", # Dungeon Asylum - underground, monster
61 "56": "town_atmosphere_1", # Medieval Town - peaceful daytime
62 "79": "battle_atmosphere_1", # Medieval Battle - epic, war
63}
64
65
66@dataclass
67class Track:
68 id: str
69 slug: str
70 tags: dict[str, list[str]] = field(default_factory=dict)
71
72 @property
73 def title(self) -> str:
74 return self.slug.replace("_", " ")
75
76 @property
77 def url(self) -> str:
78 return f"{MEDIA}/{self.id}_{self.slug}.mp3"
79
80 def all_tags(self) -> list[str]:
81 seen: list[str] = []
82 for group in ("civ", "biome", "mood", "action"):
83 for t in self.tags.get(group, []):
84 if t not in seen:
85 seen.append(t)
86 return seen
87
88 def description(self) -> str:
89 """One line of prose for the agent's prompt, built from the tags."""
90 parts = []
91 for group in ("civ", "biome", "action", "mood"):
92 if vals := self.tags.get(group):
93 parts.extend(vals)
94 return f"{self.title}. {', '.join(parts)}." if parts else self.title
95
96
97def fetch(url: str, timeout: int = 60) -> bytes:
98 req = urllib.request.Request(url, headers=HEADERS)
99 with urllib.request.urlopen(req, timeout=timeout) as resp:
100 return resp.read()
101
102
103def load_catalogue() -> dict[str, Track]:
104 """Scrape track ids/names from the homepage and merge in the tag data."""
105 try:
106 html = fetch(SITE).decode("utf-8", "replace")
107 tags_js = fetch(TAGS_JS).decode("utf-8", "replace")
108 except urllib.error.URLError as exc:
109 sys.exit(f"error: could not reach tabletopaudio.com ({exc.reason})")
110
111 tracks: dict[str, Track] = {}
112 for tid, slug in re.findall(r"saveAs\('(\d+)_([^']*)'\)", html):
113 tracks[tid] = Track(id=tid, slug=slug)
114
115 if not tracks:
116 sys.exit("error: no tracks found - the site markup has probably changed")
117
118 # tags_data.js is a JS object literal, not JSON, so it is parsed by regex.
119 # Each entry looks like: "521": { civ: ["temples"], biome: [...], ... }
120 for tid, body in re.findall(r'"(\d+)":\s*\{(.*?)\n\s*\}', tags_js, re.S):
121 if tid not in tracks:
122 continue
123 for group in ("civ", "biome", "mood", "action"):
124 m = re.search(group + r':\s*\[(.*?)\]', body, re.S)
125 tracks[tid].tags[group] = re.findall(r'"([^"]+)"', m.group(1)) if m else []
126
127 return tracks
128
129
130def download(track: Track, dest: Path, name: str, force: bool) -> str:
131 """Fetch one track. Returns 'downloaded', 'skipped' or 'failed'."""
132 target = dest / f"{name}.mp3"
133 if target.exists() and not force:
134 print(f" skip {name} (exists)")
135 return "skipped"
136
137 # Download to a temp name so an interrupted run never leaves a truncated
138 # file that a later run would treat as complete.
139 tmp = target.with_suffix(".mp3.part")
140 try:
141 data = fetch(track.url, timeout=180)
142 except (urllib.error.URLError, urllib.error.HTTPError) as exc:
143 print(f" FAILED {name} ({exc})")
144 return "failed"
145
146 if len(data) < 100_000:
147 print(f" FAILED {name} (suspiciously small: {len(data)} bytes)")
148 return "failed"
149
150 tmp.write_bytes(data)
151 tmp.rename(target)
152 print(f" ok {name} ({len(data) / 1_048_576:.1f} MB) <- {track.title}")
153 return "downloaded"
154
155
156def write_manifest(dest: Path, entries: list[dict]) -> Path:
157 """Emit cues.yml describing what is on disk.
158
159 Hand-written YAML rather than PyYAML so the script has no dependencies -
160 it runs anywhere python3 does. Values are quoted, which is sufficient
161 for the tag vocabulary and slugified titles used here.
162 """
163 path = dest / "cues.yml"
164 lines = [
165 "# D&D ambience cue manifest - generated by scripts/fetch-dnd-audio.py",
166 "# Consumed by the session agent to map narration to a concrete cue.",
167 "#",
168 "# scene: the Home Assistant scene to activate alongside the track.",
169 "# Fill these in by hand - the agent will not guess them.",
170 "#",
171 "# NOTE: these tracks fade in and out; they are not seamless loops.",
172 "# The mixer must crossfade a track into itself to sustain a bed.",
173 "",
174 "ambience:",
175 ]
176 for e in sorted(entries, key=lambda x: x["cue"]):
177 lines += [
178 f" - cue: \"{e['cue']}\"",
179 f" file: \"ambience/{e['cue']}.mp3\"",
180 f" description: \"{e['description']}\"",
181 f" tags: [{', '.join(chr(34) + t + chr(34) for t in e['tags'])}]",
182 f" source_id: \"{e['id']}\"",
183 " scene: \"\"",
184 "",
185 ]
186 path.write_text("\n".join(lines))
187 return path
188
189
190def main() -> int:
191 ap = argparse.ArgumentParser(
192 description="Fetch Tabletop Audio ambiences into the D&D media library.",
193 formatter_class=argparse.RawDescriptionHelpFormatter,
194 epilog=__doc__.split("Usage\n-----")[1] if "Usage" in __doc__ else None,
195 )
196 ap.add_argument("--dest", type=Path, default=DEFAULT_DEST,
197 help=f"library root (default: {DEFAULT_DEST})")
198 ap.add_argument("--starter", action="store_true",
199 help="download the curated starter set")
200 ap.add_argument("--track", action="append", default=[], metavar="ID",
201 help="download a specific track id (repeatable)")
202 ap.add_argument("--tag", action="append", default=[], metavar="TAG",
203 help="select tracks by tag or title substring (repeatable)")
204 ap.add_argument("--limit", type=int, default=0,
205 help="cap the number of tracks selected by --tag")
206 ap.add_argument("--list", action="store_true",
207 help="print matching tracks and exit without downloading")
208 ap.add_argument("--force", action="store_true",
209 help="re-download files that already exist")
210 args = ap.parse_args()
211
212 print("Loading Tabletop Audio catalogue...")
213 catalogue = load_catalogue()
214 tagged = sum(1 for t in catalogue.values() if t.all_tags())
215 print(f" {len(catalogue)} tracks, {tagged} with tags\n")
216
217 # ---- selection -------------------------------------------------------
218 selection: list[tuple[Track, str]] = []
219
220 if args.starter:
221 for tid, cue in STARTER_TRACKS.items():
222 if tid in catalogue:
223 selection.append((catalogue[tid], cue))
224 else:
225 print(f" warning: starter track {tid} is no longer in the catalogue")
226
227 for raw in args.track:
228 # Track ids appear both padded and unpadded upstream ("54" and "407"),
229 # so accept either spelling from the user.
230 tid = next((c for c in (raw, raw.lstrip("0"), raw.zfill(3))
231 if c in catalogue), None)
232 if tid:
233 t = catalogue[tid]
234 selection.append((t, t.slug.lower()))
235 else:
236 print(f" warning: track {raw} not found")
237
238 if args.tag:
239 wanted = {t.lower() for t in args.tag}
240 matches = [
241 t for t in catalogue.values()
242 if wanted & set(t.all_tags()) or any(w in t.slug.lower() for w in wanted)
243 ]
244 matches.sort(key=lambda t: t.id)
245 if args.limit:
246 matches = matches[: args.limit]
247 selection += [(t, t.slug.lower()) for t in matches]
248
249 if not selection and not args.list:
250 ap.error("nothing selected - use --starter, --track, --tag, or --list")
251
252 # ---- list mode -------------------------------------------------------
253 if args.list:
254 rows = selection or [(t, t.slug.lower()) for t in
255 sorted(catalogue.values(), key=lambda x: x.id)]
256 for t, _ in rows:
257 print(f" {t.id} {t.title:<38} {' '.join(t.all_tags())}")
258 print(f"\n{len(rows)} track(s).")
259 return 0
260
261 # ---- download --------------------------------------------------------
262 ambience = args.dest / "ambience"
263 for sub in ("ambience", "effects", "themes"):
264 (args.dest / sub).mkdir(parents=True, exist_ok=True)
265
266 print(f"Downloading {len(selection)} track(s) to {ambience}\n")
267 stats = {"downloaded": 0, "skipped": 0, "failed": 0}
268 entries: list[dict] = []
269
270 for i, (track, cue) in enumerate(selection, 1):
271 print(f"[{i}/{len(selection)}]", end=" ")
272 result = download(track, ambience, cue, args.force)
273 stats[result] += 1
274 if result != "failed":
275 entries.append({
276 "cue": cue,
277 "id": track.id,
278 "description": track.description(),
279 "tags": track.all_tags(),
280 })
281 # Be a polite guest: these are 14 MB files from a donation-funded site.
282 if result == "downloaded" and i < len(selection):
283 time.sleep(1)
284
285 # Include anything already present that this run did not touch, so the
286 # manifest always reflects the full library rather than the last command.
287 known = {e["cue"] for e in entries}
288 for f in sorted(ambience.glob("*.mp3")):
289 if f.stem not in known:
290 entries.append({
291 "cue": f.stem, "id": "", "tags": [],
292 "description": f.stem.replace("_", " "),
293 })
294
295 manifest = write_manifest(args.dest, entries)
296
297 print(f"\n {stats['downloaded']} downloaded, {stats['skipped']} skipped, "
298 f"{stats['failed']} failed")
299 print(f" manifest: {manifest} ({len(entries)} cues)")
300 if stats["failed"]:
301 print("\n Some downloads failed. Re-run to retry - completed files are skipped.")
302 print("\nNext: fill in the `scene:` fields in cues.yml with your HA scene ids.")
303 print("Sound effects are not on Tabletop Audio - source those from freesound.org")
304 print(f"into {args.dest / 'effects'}.")
305
306 return 1 if stats["failed"] else 0
307
308
309if __name__ == "__main__":
310 try:
311 sys.exit(main())
312 except KeyboardInterrupt:
313 print("\ninterrupted")
314 sys.exit(130)
315