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:
commit
e6164e6696
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
22
project.example.yaml
Normal file
22
project.example.yaml
Normal 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
42
scripts/config.py
Normal 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
131
scripts/convert_srt.py
Normal 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
41
scripts/download_audio.py
Normal 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
293
scripts/match_quotes.py
Normal 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
114
scripts/pipeline.py
Normal 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
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
889
webapp/index.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
73
webapp/server.py
Normal file
73
webapp/server.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user