Initial commit: podcast-mindmap tool

Generic tool for building interactive mindmap visualizations from podcast transcripts.
Includes: audio download, SRT conversion, quote-timestamp matching, D3.js mindmap webapp.
Configurable via project.yaml — no podcast-specific content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-20 01:25:42 +02:00
commit e6164e6696
10 changed files with 1610 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store

22
project.example.yaml Normal file
View File

@ -0,0 +1,22 @@
name: "Mein Podcast"
host: "Name der Moderator*in"
description: "Kurzbeschreibung des Podcasts"
staffeln:
- id: 1
name: "Staffel 1"
color: "#457b9d"
- id: 2
name: "Staffel 2"
color: "#e63946"
episodes:
- id: S1E1
title: "Thema der Folge"
guest: "Name des Gastes"
staffel: 1
youtube: "YouTube-Video-ID"
# Dateien relativ zum Projektverzeichnis
quotes_file: quotes.md
themes_file: themes.md

42
scripts/config.py Normal file
View File

@ -0,0 +1,42 @@
"""Shared configuration loader for podcast-mindmap pipeline."""
import os
import yaml
def load_project(project_dir):
"""Load project.yaml from a project directory."""
config_path = os.path.join(project_dir, "project.yaml")
if not os.path.exists(config_path):
raise FileNotFoundError(f"No project.yaml found in {project_dir}")
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
# Build derived mappings
config["_project_dir"] = os.path.abspath(project_dir)
config["_audio_dir"] = os.path.join(project_dir, "audio")
config["_transcripts_dir"] = os.path.join(project_dir, "transcripts")
config["_data_dir"] = os.path.join(project_dir, "data")
config["_webapp_dir"] = os.path.join(project_dir, "webapp")
# Episode lookup
config["_episodes_by_id"] = {ep["id"]: ep for ep in config["episodes"]}
# Audio file mapping: S1E1 → S1E1-Thema.m4a
config["_audio_files"] = {}
config["_srt_keys"] = {}
for ep in config["episodes"]:
slug = ep["id"] + "-" + ep["title"].replace(" ", "-").replace("ö", "oe").replace("ü", "ue").replace("ä", "ae")
config["_audio_files"][ep["id"]] = slug + ".m4a"
config["_srt_keys"][ep["id"]] = slug
return config
def get_staffel(config, staffel_id):
"""Get staffel metadata by ID."""
for s in config["staffeln"]:
if s["id"] == staffel_id:
return s
return None

131
scripts/convert_srt.py Normal file
View File

@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Convert MacWhisper SRT files to clean transcripts + SRT index JSON."""
import json
import os
import re
import sys
from config import load_project
def parse_srt(filepath):
"""Parse SRT into list of (start_sec, end_sec, text)."""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
blocks = re.split(r'\n\n+', content.strip())
entries = []
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 2:
continue
try:
int(lines[0].strip())
except ValueError:
continue
ts_match = re.match(
r'(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})',
lines[1]
)
if not ts_match:
continue
start = _ts_to_sec(ts_match.group(1))
end = _ts_to_sec(ts_match.group(2))
text = ' '.join(lines[2:]).strip()
text = re.sub(r'^Speaker \d+:\s*', '', text)
if text:
entries.append((start, end, text))
return entries
def _ts_to_sec(ts):
ts = ts.replace(',', '.')
parts = ts.split(':')
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
def _fmt_ts(sec):
m = int(sec) // 60
s = int(sec) % 60
return f"{m:02d}:{s:02d}"
def merge_to_paragraphs(entries, pause_threshold=2.0, max_para_duration=120):
"""Merge entries into paragraphs based on pauses."""
if not entries:
return []
paragraphs = []
para_start = entries[0][0]
para_end = entries[0][1]
para_texts = [entries[0][2]]
for i in range(1, len(entries)):
start, end, text = entries[i]
gap = start - para_end
duration = start - para_start
if gap > pause_threshold or duration > max_para_duration:
paragraphs.append((para_start, para_end, ' '.join(para_texts)))
para_start = start
para_end = end
para_texts = [text]
else:
para_end = end
para_texts.append(text)
paragraphs.append((para_start, para_end, ' '.join(para_texts)))
return paragraphs
def main():
project_dir = sys.argv[1] if len(sys.argv) > 1 else "."
config = load_project(project_dir)
audio_dir = config["_audio_dir"]
transcripts_dir = config["_transcripts_dir"]
data_dir = config["_data_dir"]
os.makedirs(transcripts_dir, exist_ok=True)
os.makedirs(data_dir, exist_ok=True)
all_indices = {}
for ep in config["episodes"]:
srt_key = config["_srt_keys"][ep["id"]]
srt_path = os.path.join(audio_dir, f"{srt_key}.srt")
if not os.path.exists(srt_path):
print(f"SKIP: {ep['id']} — no SRT file")
continue
entries = parse_srt(srt_path)
paragraphs = merge_to_paragraphs(entries)
# Write transcript
output_path = os.path.join(transcripts_dir, f"{srt_key}-Transcript.txt")
with open(output_path, "w", encoding="utf-8") as f:
f.write(f"{ep['title']}{config['host']} mit {ep['guest']}\n")
f.write(f"{'=' * 60}\n\n")
for start, end, text in paragraphs:
f.write(f"[{_fmt_ts(start)}]\n{text}\n\n")
# Store index
all_indices[srt_key] = {
"meta": {"host": config["host"], "guest": ep["guest"],
"theme": ep["title"], "staffel": ep["staffel"]},
"paragraphs": [{"start": s, "end": e, "text": t} for s, e, t in paragraphs],
}
print(f"OK: {ep['id']}{len(paragraphs)} Absätze → {output_path}")
# Save index
index_path = os.path.join(data_dir, "srt_index.json")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(all_indices, f, ensure_ascii=False, indent=2)
print(f"\nIndex: {index_path} ({len(all_indices)} Episoden)")
if __name__ == "__main__":
main()

41
scripts/download_audio.py Normal file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Download audio from YouTube for all episodes in a project."""
import os
import subprocess
import sys
from config import load_project
def main():
project_dir = sys.argv[1] if len(sys.argv) > 1 else "."
config = load_project(project_dir)
audio_dir = config["_audio_dir"]
os.makedirs(audio_dir, exist_ok=True)
for ep in config["episodes"]:
if "youtube" not in ep:
print(f"SKIP: {ep['id']} — no YouTube ID")
continue
audio_file = config["_audio_files"][ep["id"]]
output_path = os.path.join(audio_dir, audio_file)
if os.path.exists(output_path):
print(f"EXISTS: {audio_file}")
continue
print(f"DOWNLOAD: {ep['id']} ({ep['youtube']}) → {audio_file}")
slug = config["_srt_keys"][ep["id"]]
subprocess.run([
"yt-dlp", "--force-overwrites",
"-x", "--audio-format", "m4a", "--audio-quality", "0",
"-o", os.path.join(audio_dir, slug + ".%(ext)s"),
f"https://www.youtube.com/watch?v={ep['youtube']}"
], check=True)
print(f"\nDone. Audio files in {audio_dir}")
if __name__ == "__main__":
main()

293
scripts/match_quotes.py Normal file
View File

@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Match quotes from a markdown file to SRT timestamps and build mindmap_data.json."""
import json
import os
import re
import sys
from difflib import SequenceMatcher
from config import load_project
def parse_quotes_md(filepath):
"""Parse quotes markdown file. Expected format:
### Section Title
> "Quote text" -- Speaker (Episode-ID)
Returns list of {text, speaker, episode, section}.
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
quotes = []
current_section = ""
for line in content.split('\n'):
if line.startswith('### '):
current_section = line[4:].strip()
elif line.startswith('> '):
# Extract quote text
text_match = re.match(r'>\s*"?(.+?)(?:"|$)', line)
if not text_match:
continue
text = text_match.group(1).strip().rstrip('"')
# Extract speaker and episode
attr_match = re.search(r'--\s*(.+?)\s*\((\w+)\)', line)
if attr_match:
speaker = attr_match.group(1).strip()
episode = attr_match.group(2).strip()
else:
# Try simpler pattern
attr_match = re.search(r'--\s*(.+?)$', line)
speaker = attr_match.group(1).strip() if attr_match else "Unknown"
ep_match = re.search(r'\((S\d+E\d+)\)', line)
episode = ep_match.group(1) if ep_match else ""
quotes.append({
"text": text,
"speaker": speaker,
"episode": episode,
"section": current_section
})
return quotes
def parse_themes_md(filepath):
"""Parse themes markdown file. Expected format:
### 1. Theme Title -- Description
Text mentioning episodes like S1E1, S2E3...
Returns list of {id, label, description, episodes}.
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
themes = []
# Default colors
colors = ["#e63946", "#457b9d", "#f4a261", "#2a9d8f", "#264653", "#e9c46a", "#9b5de5",
"#ef476f", "#06d6a0", "#118ab2"]
sections = re.split(r'###\s+\d+\.\s+', content)[1:]
for i, section in enumerate(sections):
lines = section.strip().split('\n')
title_line = lines[0]
# Parse title and description
if ' -- ' in title_line:
label, description = title_line.split(' -- ', 1)
elif '' in title_line:
label, description = title_line.split('', 1)
else:
label = title_line.rstrip()
description = ""
label = label.strip()
description = description.strip()
# Extract episode references
full_text = '\n'.join(lines)
episodes = sorted(set(re.findall(r'S\d+E\d+', full_text)))
theme_id = re.sub(r'[^a-z0-9]', '', label.lower())[:20]
themes.append({
"id": theme_id,
"label": label,
"description": description,
"episodes": episodes,
"color": colors[i % len(colors)]
})
return themes
def normalize(text):
"""Normalize text for comparison."""
text = text.lower()
text = re.sub(r'\[.*?\]', '', text)
text = re.sub(r'--', ' ', text)
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def find_best_window(quote_text, entries, max_window=6):
"""Find best matching window of SRT entries for a quote."""
norm_quote = normalize(quote_text)
keywords = [w for w in norm_quote.split() if len(w) > 4][:8]
best_score = 0
best_start = None
best_end = None
for window_size in range(1, min(max_window + 1, len(entries) + 1)):
for i in range(len(entries) - window_size + 1):
window = entries[i:i + window_size]
window_text = ' '.join(e[2] for e in window)
norm_window = normalize(window_text)
# Quick keyword filter
if keywords:
hits = sum(1 for kw in keywords if kw in norm_window)
if hits < len(keywords) * 0.4:
continue
score = SequenceMatcher(None, norm_quote, norm_window).ratio()
# Prefer tighter matches
duration = window[-1][1] - window[0][0]
if duration < 30:
score *= 1.05
if score > best_score:
best_score = score
best_start = window[0][0]
best_end = window[-1][1]
return best_start, best_end, best_score
def parse_srt(filepath):
"""Parse SRT file into entries."""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
blocks = re.split(r'\n\n+', content.strip())
entries = []
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 2:
continue
try:
int(lines[0].strip())
except ValueError:
continue
ts_match = re.match(
r'(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})',
lines[1]
)
if not ts_match:
continue
def to_sec(ts):
ts = ts.replace(',', '.')
p = ts.split(':')
return float(p[0]) * 3600 + float(p[1]) * 60 + float(p[2])
start = to_sec(ts_match.group(1))
end = to_sec(ts_match.group(2))
text = ' '.join(lines[2:]).strip()
text = re.sub(r'^Speaker \d+:\s*', '', text)
if text:
entries.append((start, end, text))
return entries
def main():
project_dir = sys.argv[1] if len(sys.argv) > 1 else "."
config = load_project(project_dir)
audio_dir = config["_audio_dir"]
data_dir = config["_data_dir"]
os.makedirs(data_dir, exist_ok=True)
# Parse quotes
quotes_path = os.path.join(project_dir, config.get("quotes_file", "quotes.md"))
quotes = parse_quotes_md(quotes_path)
print(f"Parsed {len(quotes)} quotes from {quotes_path}")
# Parse themes
themes_path = os.path.join(project_dir, config.get("themes_file", "themes.md"))
themes = []
if os.path.exists(themes_path):
themes = parse_themes_md(themes_path)
print(f"Parsed {len(themes)} themes from {themes_path}")
# Load SRT data
srt_data = {}
for ep in config["episodes"]:
srt_key = config["_srt_keys"][ep["id"]]
srt_path = os.path.join(audio_dir, f"{srt_key}.srt")
if os.path.exists(srt_path):
srt_data[ep["id"]] = parse_srt(srt_path)
# Build episodes list
episodes_out = []
for ep in config["episodes"]:
audio_file = config["_audio_files"].get(ep["id"])
audio_path = os.path.join(audio_dir, audio_file) if audio_file else None
episodes_out.append({
"id": ep["id"],
"title": ep["title"],
"guest": ep["guest"],
"staffel": ep["staffel"],
"audioFile": audio_file if audio_path and os.path.exists(audio_path) else None
})
# Match quotes to timestamps
quotes_out = []
matched = 0
for i, q in enumerate(quotes):
ep_id = q["episode"]
quote_data = {
"id": f"q{i+1}",
"text": q["text"],
"speaker": q["speaker"],
"episode": ep_id,
"themes": [t["id"] for t in themes if ep_id in t["episodes"]],
"startTime": None,
"endTime": None,
"audioFile": config["_audio_files"].get(ep_id),
"isTopQuote": False,
"verbatim": None,
}
if ep_id in srt_data:
entries = srt_data[ep_id]
start, end, score = find_best_window(q["text"], entries)
if start is not None and score > 0.3:
quote_data["startTime"] = round(start - 1.5, 1)
quote_data["endTime"] = round(end + 1.0, 1)
matched += 1
# Get verbatim text
nearby = [e for e in entries if e[0] >= start - 1 and e[1] <= end + 1]
if nearby:
verbatim = ' '.join(e[2] for e in nearby).strip()
# Capitalize first letter
if verbatim and verbatim[0].islower():
verbatim = verbatim[0].upper() + verbatim[1:]
# Ensure ends with punctuation
if verbatim and verbatim[-1] not in '.!?':
verbatim += '.'
quote_data["verbatim"] = verbatim
quotes_out.append(quote_data)
# Build staffeln
staffeln_out = config["staffeln"]
# Output
output = {
"name": config.get("name", "Podcast"),
"description": config.get("description", ""),
"host": config.get("host", ""),
"themes": themes,
"episodes": episodes_out,
"quotes": quotes_out,
"staffeln": staffeln_out,
}
output_path = os.path.join(data_dir, "mindmap_data.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"\nMatched: {matched}/{len(quotes_out)} quotes with timestamps")
print(f"Output: {output_path}")
if __name__ == "__main__":
main()

114
scripts/pipeline.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Master pipeline: runs all processing steps for a podcast-mindmap project.
Usage:
python pipeline.py /path/to/project
python pipeline.py /path/to/project --step download
python pipeline.py /path/to/project --step convert
python pipeline.py /path/to/project --step match
python pipeline.py /path/to/project --step serve
"""
import argparse
import os
import shutil
import subprocess
import sys
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
WEBAPP_DIR = os.path.join(os.path.dirname(SCRIPTS_DIR), "webapp")
def run_step(script_name, project_dir):
"""Run a pipeline script."""
script = os.path.join(SCRIPTS_DIR, script_name)
print(f"\n{'='*60}")
print(f"Running: {script_name}")
print(f"{'='*60}")
subprocess.run([sys.executable, script, project_dir], check=True)
def setup_webapp(project_dir):
"""Copy webapp files and link data."""
data_dir = os.path.join(project_dir, "data")
webapp_dest = os.path.join(project_dir, "webapp")
os.makedirs(webapp_dest, exist_ok=True)
# Copy webapp files
for f in os.listdir(WEBAPP_DIR):
src = os.path.join(WEBAPP_DIR, f)
dst = os.path.join(webapp_dest, f)
if os.path.isfile(src):
shutil.copy2(src, dst)
# Copy data
data_src = os.path.join(data_dir, "mindmap_data.json")
if os.path.exists(data_src):
shutil.copy2(data_src, os.path.join(webapp_dest, "mindmap_data.json"))
# Symlink audio
audio_link = os.path.join(webapp_dest, "audio")
audio_src = os.path.join(project_dir, "audio")
if os.path.exists(audio_src) and not os.path.exists(audio_link):
os.symlink(audio_src, audio_link)
print(f"\nWebapp ready in {webapp_dest}")
def serve(project_dir, port=9123):
"""Start the web server."""
webapp_dir = os.path.join(project_dir, "webapp")
server_script = os.path.join(webapp_dir, "server.py")
if not os.path.exists(server_script):
setup_webapp(project_dir)
# Get LAN IP
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
except Exception:
ip = "localhost"
finally:
s.close()
print(f"\nServer: http://localhost:{port}")
print(f"LAN: http://{ip}:{port}")
subprocess.run([sys.executable, server_script], cwd=webapp_dir)
def main():
parser = argparse.ArgumentParser(description="Podcast Mindmap Pipeline")
parser.add_argument("project_dir", help="Path to the project directory")
parser.add_argument("--step", choices=["download", "convert", "match", "webapp", "serve", "all"],
default="all", help="Which step to run")
parser.add_argument("--port", type=int, default=9123, help="Server port")
args = parser.parse_args()
project_dir = os.path.abspath(args.project_dir)
if args.step == "download":
run_step("download_audio.py", project_dir)
elif args.step == "convert":
run_step("convert_srt.py", project_dir)
elif args.step == "match":
run_step("match_quotes.py", project_dir)
elif args.step == "webapp":
setup_webapp(project_dir)
elif args.step == "serve":
setup_webapp(project_dir)
serve(project_dir, args.port)
elif args.step == "all":
run_step("download_audio.py", project_dir)
print("\n*** Transkribiere die Audio-Dateien jetzt mit MacWhisper (SRT-Export). ***")
print("*** Drücke Enter wenn die SRT-Dateien im audio/ Ordner liegen. ***")
input()
run_step("convert_srt.py", project_dir)
run_step("match_quotes.py", project_dir)
setup_webapp(project_dir)
serve(project_dir, args.port)
if __name__ == "__main__":
main()

2
webapp/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

889
webapp/index.html Normal file
View File

@ -0,0 +1,889 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Podcast Mindmap</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #252836;
--text: #e8e6e3;
--text-muted: #9ca3af;
--accent: #60a5fa;
--border: #374151;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
height: 100vh;
}
#app {
display: grid;
grid-template-columns: 1fr 380px;
grid-template-rows: 56px 1fr;
height: 100vh;
}
@media (max-width: 800px) {
#app {
grid-template-columns: 1fr;
grid-template-rows: 48px 50vh 1fr;
}
header { padding: 0 12px; }
header h1 { font-size: 14px; }
.filter-bar { gap: 4px; }
.filter-btn { padding: 4px 10px; font-size: 11px; }
#panel { border-left: none; border-top: 1px solid var(--border); }
}
/* Header */
header {
grid-column: 1 / -1;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 24px;
gap: 16px;
}
header h1 {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
}
header h1 span { color: var(--accent); }
.filter-bar {
display: flex;
gap: 8px;
margin-left: auto;
}
.filter-btn {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover, .filter-btn.active {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
/* Mindmap Canvas */
#mindmap {
position: relative;
overflow: hidden;
background: var(--bg);
}
#mindmap svg {
width: 100%;
height: 100%;
}
/* Nodes */
.node-theme {
cursor: pointer;
}
.node-theme circle {
stroke-width: 2;
transition: r 0.3s, stroke-width 0.3s;
}
.node-theme:hover circle {
stroke-width: 4;
}
.node-theme text {
fill: var(--text);
font-size: 12px;
font-weight: 600;
text-anchor: middle;
pointer-events: none;
}
.node-episode circle {
stroke-width: 1.5;
cursor: pointer;
transition: r 0.3s;
}
.node-episode:hover circle {
r: 22;
}
.node-episode text {
fill: var(--text-muted);
font-size: 9px;
text-anchor: middle;
pointer-events: none;
}
.node-quote {
cursor: pointer;
}
.node-quote circle {
transition: r 0.3s, opacity 0.3s;
}
.node-quote:hover circle {
r: 8;
opacity: 1;
}
.link {
stroke: var(--border);
stroke-opacity: 0.3;
fill: none;
}
.link-theme-episode {
stroke-opacity: 0.15;
stroke-width: 1;
}
.link-episode-quote {
stroke-opacity: 0.08;
stroke-width: 0.5;
}
/* Side Panel */
#panel {
background: var(--surface);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
#panel h2 {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
#panel .subtitle {
font-size: 13px;
color: var(--text-muted);
margin-top: 4px;
}
.quote-card {
background: var(--surface2);
border-radius: 8px;
padding: 14px;
border-left: 3px solid var(--accent);
cursor: pointer;
transition: transform 0.15s, background 0.15s;
}
.quote-card:hover {
transform: translateX(4px);
background: #2d3142;
}
.quote-card.playing {
border-left-color: #22c55e;
background: #1a2e1a;
}
.quote-card .quote-text {
font-size: 13px;
line-height: 1.5;
color: var(--text);
font-style: italic;
}
.quote-card .quote-meta {
margin-top: 8px;
font-size: 11px;
color: var(--text-muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.quote-card .play-icon {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.2s;
}
.quote-card:hover .play-icon { opacity: 1; }
.quote-card .play-icon svg {
width: 12px;
height: 12px;
fill: var(--bg);
}
.no-audio .play-icon { display: none; }
.top-badge {
display: inline-block;
background: #f59e0b;
color: #000;
font-size: 9px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
/* Audio Player Bar */
#audio-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: var(--surface);
border-top: 1px solid var(--border);
display: none;
align-items: center;
padding: 0 24px;
gap: 16px;
z-index: 100;
}
#audio-bar.visible { display: flex; }
#audio-bar .now-playing {
font-size: 12px;
color: var(--text-muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#audio-bar .now-playing strong {
color: var(--text);
}
#audio-bar button {
background: var(--accent);
border: none;
color: var(--bg);
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
#audio-bar .time {
font-size: 11px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* Welcome state */
.welcome {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.welcome h2 {
color: var(--text);
margin-bottom: 8px;
}
.welcome p {
font-size: 13px;
line-height: 1.6;
}
.theme-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
margin: 2px;
}
/* Scrollbar */
#panel::-webkit-scrollbar { width: 6px; }
#panel::-webkit-scrollbar-track { background: transparent; }
#panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<div id="app">
<header>
<h1><span id="app-title">Podcast</span> Themen-Mindmap</h1>
<div class="filter-bar" id="staffel-filters"></div>
</header>
<div id="mindmap">
<svg id="svg"></svg>
</div>
<div id="panel">
<div class="welcome" id="welcome-panel">
</div>
</div>
</div>
<div id="audio-bar">
<button id="play-pause-btn">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path id="play-pause-icon" d="M8 5v14l11-7z"/>
</svg>
</button>
<div class="now-playing" id="now-playing"></div>
<span class="time" id="audio-time">00:00</span>
</div>
<audio id="main-audio" preload="none"></audio>
<script src="d3.v7.min.js"></script>
<script>
// ============================================================
// Podcast Mindmap App
// ============================================================
let DATA = null;
let simulation = null;
let currentAudio = null;
let currentQuoteId = null;
const PLAY_SVG = 'M8 5v14l11-7z';
const PAUSE_SVG = 'M6 4h4v16H6zM14 4h4v16h-4z';
// Load data
fetch('mindmap_data.json')
.then(r => r.json())
.then(data => { DATA = data; init(); })
.catch(e => console.error('Failed to load data:', e));
function init() {
// Set dynamic titles from data
const name = DATA.name || 'Podcast';
document.title = name + ' — Themen-Mindmap';
document.getElementById('app-title').textContent = name;
document.getElementById('welcome-panel').innerHTML = `
<h2>${name}</h2>
<p>${DATA.description || ''}<br>
${DATA.episodes.length} Folgen, ${DATA.staffeln.length} Staffeln, ${DATA.quotes.length} Zitate</p>
<p style="margin-top:16px">Klicke auf einen Themenknoten oder eine Episode in der Mindmap.</p>`;
buildFilters();
buildGraph();
}
// ---- Staffel Filters ----
function buildFilters() {
const bar = document.getElementById('staffel-filters');
const allBtn = document.createElement('button');
allBtn.className = 'filter-btn active';
allBtn.textContent = 'Alle';
allBtn.dataset.staffel = '0';
allBtn.onclick = () => filterStaffel(0);
bar.appendChild(allBtn);
DATA.staffeln.forEach(s => {
const btn = document.createElement('button');
btn.className = 'filter-btn';
btn.textContent = `S${s.id}: ${s.name}`;
btn.style.setProperty('--color', s.color);
btn.dataset.staffel = s.id;
btn.onclick = () => filterStaffel(s.id);
bar.appendChild(btn);
});
}
let activeStaffel = 0;
function filterStaffel(id) {
activeStaffel = id;
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', parseInt(b.dataset.staffel) === id);
});
updateVisibility();
}
// ---- Graph ----
function buildGraph() {
const svg = d3.select('#svg');
const container = document.getElementById('mindmap');
const W = container.clientWidth || window.innerWidth;
const H = container.clientHeight || window.innerHeight * 0.5;
const isMobile = W < 600;
const scale = isMobile ? 0.6 : 1;
// Set SVG viewBox for proper scaling
svg.attr('viewBox', `0 0 ${W} ${H}`).attr('preserveAspectRatio', 'xMidYMid meet');
// Build nodes and links
const nodes = [];
const links = [];
const themeMap = {};
const episodeMap = {};
// Center node
nodes.push({
id: 'center',
type: 'center',
label: (DATA.name || 'PODCAST').replace(/\s+/g, '\n'),
r: 40 * scale,
fx: W / 2,
fy: H / 2,
color: '#60a5fa'
});
// Theme nodes
DATA.themes.forEach(t => {
const maxLen = isMobile ? 18 : 25;
const node = {
id: t.id,
type: 'theme',
label: t.label.length > maxLen ? t.label.substring(0, maxLen - 3) + '…' : t.label,
fullLabel: t.label,
description: t.description,
r: 28 * scale,
color: t.color,
episodes: t.episodes
};
nodes.push(node);
themeMap[t.id] = node;
links.push({ source: 'center', target: t.id, type: 'center-theme' });
});
// Episode nodes
DATA.episodes.forEach(ep => {
const staffel = DATA.staffeln.find(s => s.id === ep.staffel);
const node = {
id: ep.id,
type: 'episode',
label: ep.id,
title: ep.title,
guest: ep.guest,
staffel: ep.staffel,
audioFile: ep.audioFile,
r: 16 * scale,
color: staffel ? staffel.color : '#666'
};
nodes.push(node);
episodeMap[ep.id] = node;
});
// Links: theme → episode
DATA.themes.forEach(t => {
t.episodes.forEach(epId => {
if (episodeMap[epId]) {
links.push({ source: t.id, target: epId, type: 'theme-episode' });
}
});
});
// Quote nodes (only top quotes and quotes with audio get visible nodes)
const visibleQuotes = DATA.quotes.filter(q => q.isTopQuote || q.startTime !== null);
visibleQuotes.forEach(q => {
const ep = episodeMap[q.episode];
nodes.push({
id: q.id,
type: 'quote',
text: q.text,
speaker: q.speaker,
episode: q.episode,
themes: q.themes,
startTime: q.startTime,
endTime: q.endTime,
audioFile: q.audioFile,
isTopQuote: q.isTopQuote,
r: (q.isTopQuote ? 6 : 4) * scale,
color: ep ? ep.color : '#666',
staffel: ep ? ep.staffel : 0
});
links.push({ source: q.episode, target: q.id, type: 'episode-quote' });
});
// D3 Force Simulation
simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(d => {
if (d.type === 'center-theme') return 160 * scale;
if (d.type === 'theme-episode') return 100 * scale;
return 50 * scale;
}).strength(d => {
if (d.type === 'center-theme') return 0.8;
if (d.type === 'theme-episode') return 0.3;
return 0.2;
}))
.force('charge', d3.forceManyBody().strength(d => {
const s = scale;
if (d.type === 'center') return -800 * s;
if (d.type === 'theme') return -400 * s;
if (d.type === 'episode') return -150 * s;
return -30 * s;
}))
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.05))
.force('collision', d3.forceCollide().radius(d => d.r + 4))
.alphaDecay(0.02);
// Zoom
const zoom = d3.zoom()
.scaleExtent([0.3, 3])
.on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
const g = svg.append('g');
// Links
const linkG = g.append('g');
const linkEls = linkG.selectAll('line')
.data(links)
.join('line')
.attr('class', d => `link link-${d.type}`)
.attr('stroke', d => {
const src = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
return src ? src.color : '#374151';
});
// Node groups
const nodeG = g.append('g');
// Quote nodes
const quoteNodes = nodeG.selectAll('.node-quote')
.data(nodes.filter(n => n.type === 'quote'))
.join('g')
.attr('class', 'node-quote')
.on('click', (e, d) => playQuote(d))
.call(drag(simulation));
quoteNodes.append('circle')
.attr('r', d => d.r)
.attr('fill', d => d.color)
.attr('opacity', d => d.isTopQuote ? 0.9 : 0.4)
.attr('stroke', d => d.isTopQuote ? '#f59e0b' : 'none')
.attr('stroke-width', d => d.isTopQuote ? 2 : 0);
// Episode nodes
const epNodes = nodeG.selectAll('.node-episode')
.data(nodes.filter(n => n.type === 'episode'))
.join('g')
.attr('class', 'node-episode')
.on('click', (e, d) => showEpisode(d))
.call(drag(simulation));
epNodes.append('circle')
.attr('r', d => d.r)
.attr('fill', 'transparent')
.attr('stroke', d => d.color)
.attr('stroke-width', 1.5);
epNodes.append('text')
.attr('dy', 4)
.text(d => d.label);
// Theme nodes
const themeNodes = nodeG.selectAll('.node-theme')
.data(nodes.filter(n => n.type === 'theme'))
.join('g')
.attr('class', 'node-theme')
.on('click', (e, d) => showTheme(d))
.call(drag(simulation));
themeNodes.append('circle')
.attr('r', d => d.r)
.attr('fill', d => d.color + '33')
.attr('stroke', d => d.color);
themeNodes.append('text')
.attr('dy', d => -d.r - 8)
.text(d => d.label);
// Center node
const centerNode = nodeG.selectAll('.node-center')
.data(nodes.filter(n => n.type === 'center'))
.join('g')
.attr('class', 'node-center');
centerNode.append('circle')
.attr('r', d => d.r)
.attr('fill', d => d.color + '22')
.attr('stroke', d => d.color)
.attr('stroke-width', 2);
centerNode.append('text')
.attr('text-anchor', 'middle')
.attr('fill', '#60a5fa')
.attr('font-size', '11px')
.attr('font-weight', '700')
.selectAll('tspan')
.data(d => d.label.split('\n'))
.join('tspan')
.attr('x', 0)
.attr('dy', (d, i) => i === 0 ? '-0.3em' : '1.2em')
.text(d => d);
// Tick
simulation.on('tick', () => {
linkEls
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
quoteNodes.attr('transform', d => `translate(${d.x},${d.y})`);
epNodes.attr('transform', d => `translate(${d.x},${d.y})`);
themeNodes.attr('transform', d => `translate(${d.x},${d.y})`);
centerNode.attr('transform', d => `translate(${d.x},${d.y})`);
});
// Store references
window._nodes = nodes;
window._links = links;
window._quoteNodes = quoteNodes;
window._epNodes = epNodes;
window._themeNodes = themeNodes;
window._linkEls = linkEls;
}
function updateVisibility() {
const s = activeStaffel;
window._quoteNodes.style('display', d => s === 0 || d.staffel === s ? null : 'none');
window._epNodes.style('display', d => s === 0 || d.staffel === s ? null : 'none');
window._linkEls.style('display', d => {
if (s === 0) return null;
const tgt = typeof d.target === 'object' ? d.target : window._nodes.find(n => n.id === d.target);
if (tgt && tgt.staffel && tgt.staffel !== s) return 'none';
return null;
});
}
// ---- Drag (with tap detection for mobile) ----
function drag(sim) {
let dragStartX, dragStartY, dragMoved;
return d3.drag()
.on('start', (e, d) => {
if (!e.active) sim.alphaTarget(0.1).restart();
d.fx = d.x; d.fy = d.y;
dragStartX = e.x; dragStartY = e.y;
dragMoved = false;
})
.on('drag', (e, d) => {
d.fx = e.x; d.fy = e.y;
const dx = e.x - dragStartX, dy = e.y - dragStartY;
if (Math.sqrt(dx * dx + dy * dy) > 5) dragMoved = true;
})
.on('end', (e, d) => {
if (!e.active) sim.alphaTarget(0);
if (d.type !== 'center') { d.fx = null; d.fy = null; }
// Tap (not drag) → trigger click action
if (!dragMoved) {
if (d.type === 'theme') showTheme(d);
else if (d.type === 'episode') showEpisode(d);
else if (d.type === 'quote') playQuote(d);
}
});
}
// ---- Panel: Show Theme ----
function showTheme(theme) {
const panel = document.getElementById('panel');
const themeData = DATA.themes.find(t => t.id === theme.id);
const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id));
let html = `<h2 style="color:${theme.color}">${themeData.label}</h2>`;
html += `<p class="subtitle">${themeData.description}</p>`;
html += `<p class="subtitle">${quotes.length} Zitate aus ${themeData.episodes.length} Episoden</p>`;
// Episode tags
html += '<div style="margin-top:8px">';
themeData.episodes.forEach(epId => {
const ep = DATA.episodes.find(e => e.id === epId);
if (ep) {
const st = DATA.staffeln.find(s => s.id === ep.staffel);
html += `<span class="theme-tag" style="background:${st.color}22;color:${st.color};border:1px solid ${st.color}44">${ep.id} ${ep.guest}</span> `;
}
});
html += '</div>';
// Quotes
quotes.forEach(q => {
html += buildQuoteCard(q, theme.color);
});
panel.innerHTML = html;
}
// ---- Panel: Show Episode ----
function showEpisode(ep) {
const panel = document.getElementById('panel');
const epData = DATA.episodes.find(e => e.id === ep.id);
const staffel = DATA.staffeln.find(s => s.id === epData.staffel);
const quotes = DATA.quotes.filter(q => q.episode === ep.id);
let html = `<h2 style="color:${staffel.color}">${ep.id}: ${epData.title}</h2>`;
html += `<p class="subtitle">Gast: ${epData.guest}<br>Staffel ${epData.staffel}: ${staffel.name}</p>`;
html += `<p class="subtitle">${quotes.length} Zitate${epData.audioFile ? ' · Audio verfügbar' : ''}</p>`;
// Theme tags
const epThemes = DATA.themes.filter(t => t.episodes.includes(ep.id));
html += '<div style="margin-top:8px">';
epThemes.forEach(t => {
html += `<span class="theme-tag" style="background:${t.color}22;color:${t.color};border:1px solid ${t.color}44;cursor:pointer" onclick="showThemeById('${t.id}')">${t.label}</span> `;
});
html += '</div>';
quotes.forEach(q => {
html += buildQuoteCard(q, staffel.color);
});
panel.innerHTML = html;
}
function showThemeById(id) {
const theme = window._nodes.find(n => n.id === id);
if (theme) showTheme(theme);
}
// ---- Quote Card HTML ----
function buildQuoteCard(q, color) {
const hasAudio = q.audioFile && q.startTime !== null;
const cls = hasAudio ? '' : ' no-audio';
const topBadge = q.isTopQuote ? '<span class="top-badge">TOP 10</span>' : '';
const timeStr = q.startTime !== null ? fmtTime(q.startTime) : '';
return `
<div class="quote-card${cls}" id="card-${q.id}" style="border-left-color:${color}"
onclick="${hasAudio ? `playQuoteById('${q.id}')` : ''}">
<div class="quote-text">"${escHtml(q.verbatim || q.text)}"</div>
<div class="quote-meta">
<span>${q.speaker} · ${q.episode}${timeStr ? ' · ' + timeStr : ''}${topBadge}</span>
${hasAudio ? `<span class="play-icon"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span>` : ''}
</div>
</div>`;
}
// ---- Audio Playback ----
function playQuoteById(id) {
const q = DATA.quotes.find(q => q.id === id);
if (q) playQuote(q);
}
function playQuote(q) {
if (!q.audioFile || q.startTime === null) return;
const audio = document.getElementById('main-audio');
const bar = document.getElementById('audio-bar');
const nowPlaying = document.getElementById('now-playing');
const btn = document.getElementById('play-pause-btn');
const icon = document.getElementById('play-pause-icon');
// Stop current playback
audio.pause();
document.querySelectorAll('.quote-card.playing').forEach(c => c.classList.remove('playing'));
// Highlight card
const card = document.getElementById(`card-${q.id}`);
if (card) card.classList.add('playing');
currentQuoteId = q.id;
const endTime = q.endTime;
// Clear old event handlers
audio.ontimeupdate = null;
audio.onended = null;
// Set source — only reload if different file
const newSrc = `audio/${q.audioFile}`;
const sameFile = audio.src && audio.src.endsWith(q.audioFile);
if (!sameFile) {
audio.src = newSrc;
}
// iOS Safari requires play() in the same call stack as the user gesture.
// So we play first (even from wrong position), then seek once loaded.
audio.currentTime = q.startTime;
const playPromise = audio.play();
if (playPromise) {
playPromise.then(() => {
// Seek after play starts (needed when source just changed)
if (Math.abs(audio.currentTime - q.startTime) > 2) {
audio.currentTime = q.startTime;
}
}).catch(() => {
// Autoplay blocked — try after load
audio.addEventListener('canplay', function handler() {
audio.removeEventListener('canplay', handler);
audio.currentTime = q.startTime;
audio.play().catch(() => {});
});
});
}
icon.setAttribute('d', PAUSE_SVG);
// Stop at end time
audio.ontimeupdate = () => {
if (endTime && audio.currentTime >= endTime) {
audio.pause();
icon.setAttribute('d', PLAY_SVG);
}
document.getElementById('audio-time').textContent = fmtTime(audio.currentTime);
};
audio.onended = () => icon.setAttribute('d', PLAY_SVG);
nowPlaying.innerHTML = `<strong>"${q.text.substring(0, 80)}…"</strong> — ${q.speaker} (${q.episode})`;
bar.classList.add('visible');
btn.onclick = () => {
if (audio.paused) {
audio.play();
icon.setAttribute('d', PAUSE_SVG);
} else {
audio.pause();
icon.setAttribute('d', PLAY_SVG);
}
};
}
// ---- Helpers ----
function fmtTime(sec) {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
</body>
</html>

73
webapp/server.py Normal file
View File

@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""HTTP server with Range request support for audio streaming."""
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
MIME_OVERRIDES = {'.m4a': 'audio/mp4', '.srt': 'text/plain'}
class RangeHandler(SimpleHTTPRequestHandler):
def do_GET(self):
path = self.translate_path(self.path)
if not os.path.isfile(path):
return super().do_GET()
range_header = self.headers.get('Range')
file_size = os.path.getsize(path)
ext = os.path.splitext(path)[1].lower()
ctype = MIME_OVERRIDES.get(ext) or self.guess_type(path)
if not range_header:
# Normal response but with Accept-Ranges
self.send_response(200)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(file_size))
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(path, 'rb') as f:
self.copyfile(f, self.wfile)
return
# Parse range
try:
range_spec = range_header.replace('bytes=', '')
parts = range_spec.split('-')
start = int(parts[0]) if parts[0] else 0
end = int(parts[1]) if parts[1] else file_size - 1
end = min(end, file_size - 1)
length = end - start + 1
except (ValueError, IndexError):
self.send_error(416)
return
self.send_response(206)
self.send_header('Content-Type', ctype)
self.send_header('Content-Range', f'bytes {start}-{end}/{file_size}')
self.send_header('Content-Length', str(length))
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(path, 'rb') as f:
f.seek(start)
remaining = length
buf_size = 65536
while remaining > 0:
chunk = f.read(min(buf_size, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def end_headers(self):
# Don't double-add Accept-Ranges
super().end_headers()
if __name__ == '__main__':
port = 9123
print(f"http://0.0.0.0:{port}")
HTTPServer(('0.0.0.0', port), RangeHandler).serve_forever()