From ba98ac4eb4a5e850b1f15f01ebac637fe85baf1a Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 11 Jul 2026 13:14:19 +0200 Subject: [PATCH] Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an episode does not delete its data. DELETE /api/podcasts/episodes/{id} resolves episode.audio_file and unlinks that one MP3 — the enclosing data/podcasts/episodes// directory, holding clips/ (one MP3 per dialogue segment), outline.json and transcript.json, is never touched. Failed and /retry-replaced runs leak a directory as well. Nothing reaps any of it: this deployment had 15 directories on disk against 4 episodes in the database. That is an upstream gap (roughly a shutil.rmtree of audio_path.parent.parent in that handler), not something configurable here. Deliberately not patched by bind-mounting a modified router — that would fork app logic into a deployment repo and rot silently against pull_policy: always. Local cleanup instead. scripts/prune_podcast_data.py reconciles the episode list from the API against the directories on disk and removes: - orphaned directories (no corresponding episode), and - clips/ of completed episodes, since the clips are intermediate output once the final MP3 exists. Dry-run by default; --yes applies, --keep-clips restricts it to orphans. Two guards, both tested: it aborts when any job is running/pending (a running job has no audio_file yet, so its working directory is indistinguishable from an orphan) and it skips directories touched within the last 60 minutes (--min-age). It also refuses to act if the API is unreachable, rather than guessing. Deletion runs inside the container (docker compose exec … rm -rf): the container writes as root, so the host user cannot remove those directories — a plain host-side rmtree fails with EPERM after the first directory. Verified on this deployment: freed 13 MB (10 orphaned dirs + clips of 3 episodes, 49 MB -> 36 MB); all four surviving episodes still stream byte-identical MP3s from /audio afterwards. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + BEDIENUNGSANLEITUNG.md | 30 +++++ CLAUDE.md | 15 +++ README.md | 11 +- scripts/prune_podcast_data.py | 209 ++++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100755 scripts/prune_podcast_data.py diff --git a/.gitignore b/.gitignore index 03c8c44..f433aa9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ surreal_data/ notebook_data/ services/logs/ services/voices/ + +# Python +__pycache__/ +*.pyc diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index d7c00a4..d757f51 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -118,6 +118,36 @@ mehreren Sprechern) erzeugen — Text via Sprachmodell, Audio via TTS (hier: lok Alle Episode-/Sprecherprofile sind bereits auf lokale Modelle (`qwen3.5:27b` für Text, Chatterbox für Sprache) umgestellt — keine zusätzlichen Kosten. +### Episoden löschen räumt nur halb auf + +**Der Mülleimer-Button löscht nicht alles.** Er entfernt die finale MP3 und den +Datenbankeintrag — der Ordner der Episode bleibt aber liegen, samt Einzelclips: + +``` +notebook_data/podcasts/episodes// +├── clips/ ← 1 MP3 pro Dialogsegment BLEIBT LIEGEN +├── outline.json BLEIBT LIEGEN +├── transcript.json BLEIBT LIEGEN +└── audio/.mp3 ← nur diese wird gelöscht +``` + +Das ist eine Lücke im Open-Notebook-Code selbst (der Löschpfad ruft `unlink()` nur auf die eine +MP3-Datei auf), nicht an dieser Installation einstellbar. Ebenso hinterlässt jeder +fehlgeschlagene oder per Retry wiederholte Lauf einen Ordner, der nie aufgeräumt wird. + +Zum Aufräumen dient: + +```bash +./scripts/prune_podcast_data.py # zeigt nur an, was wegfiele (Trockenlauf) +./scripts/prune_podcast_data.py --yes # löscht wirklich +``` + +Es entfernt (a) Ordner ohne zugehörige Episode und (b) die `clips/` fertiger Episoden — die +Einzelclips sind nach dem Zusammenbau reines Zwischenprodukt, die Episode bleibt vollständig +abspielbar. Schutzmechanismen: bei einem laufenden Podcast-Job bricht es ab, und Ordner, die +jünger als 60 Minuten sind, bleiben unangetastet (`--min-age`). Mit `--keep-clips` werden nur +die verwaisten Ordner gelöscht. + --- ## 5. Stimmklonung diff --git a/CLAUDE.md b/CLAUDE.md index 576d082..2fb698b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,21 @@ line becomes inert but harmless. If you create your own episode profile, just set `language` — the template handles the rest. +**Deleting an episode leaks its working directory (upstream gap).** `DELETE /api/podcasts/episodes/{id}` +(`api/routers/podcasts.py`) resolves `episode.audio_file` and calls `audio_path.unlink()` on it — the +final MP3 only. The enclosing `data/podcasts/episodes//` directory, holding `clips/` (one MP3 per +dialogue segment), `outline.json` and `transcript.json`, is never touched, and nothing else reaps it. +Failed and `/retry`-replaced runs leak a directory too (the retry writes a *new* episode row with a new +uuid dir; the old one loses its DB entry and just stays). Upstream this would be roughly +`shutil.rmtree(audio_path.parent.parent)` in that handler. We deliberately do **not** patch it here by +bind-mounting a modified router — that would fork app logic into a deployment repo and silently rot +against `pull_policy: always`. Instead: `scripts/prune_podcast_data.py` (dry-run by default, `--yes` to +apply) reconciles the episode list from the API against the directories on disk and removes orphans plus +the `clips/` of completed episodes. Two guards matter — it aborts if any job is `running`/`pending` +(a running job has no `audio_file` yet, so its directory is indistinguishable from an orphan) and it +skips directories touched in the last 60 minutes. **Deletion runs inside the container** (`docker compose +exec … rm -rf`), because the container writes as root and the host user can't remove those directories. + ### Text-to-speech / speech-to-text Open Notebook's `openai_compatible` provider type talks to any endpoint implementing OpenAI's audio API shape (`POST /audio/speech`, `POST /audio/transcriptions` — see `esperanto.providers.tts.openai_compatible` / `.stt.openai_compatible` inside the app container for the exact contract). Since neither Ollama nor OpenRouter support TTS/STT, two thin wrapper servers in `services/` expose the locally installed tools this way: diff --git a/README.md b/README.md index 6de42a5..b017a0b 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,9 @@ systemctl --user status open-notebook-tts open-notebook-stt # TTS/STT-Status systemctl --user restart open-notebook-tts # z.B. nach neuer Stimme journalctl --user -u open-notebook-tts -f # TTS-Logs ./scripts/start_services.sh # Units neu installieren + starten (idempotent) + +./scripts/prune_podcast_data.py # Podcast-Datenmüll anzeigen (Trockenlauf) +./scripts/prune_podcast_data.py --yes # ... und löschen ``` TTS/STT starten nach einem Reboot von selbst — `start_services.sh` ist nur für die @@ -202,7 +205,8 @@ open-notebook/ │ └── logs/ # (gitignored, Altlast der früheren nohup-Variante) ├── scripts/ │ ├── setup_models.sh # Provider/Modelle einrichten (idempotent) -│ └── start_services.sh # TTS/STT als systemd-User-Dienste installieren+starten +│ ├── start_services.sh # TTS/STT als systemd-User-Dienste installieren+starten +│ └── prune_podcast_data.py # verwaiste Podcast-Ordner + Zwischenclips aufräumen ├── README.md # diese Datei ├── BEDIENUNGSANLEITUNG.md # ausführliche Bedienungsanleitung (Deutsch) └── CLAUDE.md # technische Architektur-Doku für Weiterentwicklung @@ -222,6 +226,11 @@ Kurzreferenz — Details jeweils in [`CLAUDE.md`](CLAUDE.md): leere Chat-Antworten bei größerem Kontext, ohne sichtbaren Fehler. - **Neue Host-Ports brauchen eine `ufw`-Regel**, sonst kann der Container sie nicht erreichen (Timeout, keine Fehlermeldung in Open Notebook selbst). +- **Episoden löschen räumt nicht auf.** Der Mülleimer-Button (bzw. + `DELETE /api/podcasts/episodes/{id}`) entfernt nur die finale MP3 und den DB-Eintrag — der + Ordner `notebook_data/podcasts/episodes//` mit den Einzelclips, `outline.json` und + `transcript.json` bleibt liegen. Das ist eine Lücke im Upstream-Code, kein lokales + Konfigurationsproblem. Abhilfe: `./scripts/prune_podcast_data.py`. - **GPU 2 teilen sich drei Prozesse** (TTS, STT und der separate `chatterbox-tts`-MCP-Dienst auf Port 9999) — es bleiben nur ~12 GB Arbeitsspeicher für die Sprachsynthese. Deshalb serialisiert `tts_server.py` seine Generierung intern und `TTS_BATCH_SIZE=1` steht in `docker-compose.yml`: diff --git a/scripts/prune_podcast_data.py b/scripts/prune_podcast_data.py new file mode 100755 index 0000000..8e9932a --- /dev/null +++ b/scripts/prune_podcast_data.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Raeumt Podcast-Daten auf, die Open Notebook selbst liegen laesst. + +Zwei Arten von Muell entstehen unter notebook_data/podcasts/episodes//: + +1. Verwaiste Episodenordner. Beim Loeschen einer Episode entfernt Open Notebook + nur die finale MP3 (api/routers/podcasts.py: audio_path.unlink()), nicht den + Ordner mit clips/, outline.json und transcript.json. Auch fehlgeschlagene und + per /retry ersetzte Laeufe lassen ihren Ordner zurueck. + +2. clips/ fertiger Episoden. Die Einzelclips sind nur Zwischenprodukt; sobald die + finale MP3 existiert, werden sie nicht mehr gebraucht. + +Standardmaessig Trockenlauf — es wird nur angezeigt, was wegfiele. Erst mit --yes +wird geloescht. + + ./scripts/prune_podcast_data.py # anzeigen + ./scripts/prune_podcast_data.py --yes # loeschen + ./scripts/prune_podcast_data.py --keep-clips --yes # nur Verwaiste loeschen +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +API = "http://127.0.0.1:5055" +REPO = Path(__file__).resolve().parent.parent +EPISODES_DIR = REPO / "notebook_data" / "podcasts" / "episodes" + +# Pfade in der DB sind Container-Pfade (/app/data/...), auf dem Host liegen sie +# unter notebook_data/ (Bind-Mount aus docker-compose.yml). +CONTAINER_PREFIX = "/app/data/" +HOST_PREFIX = REPO / "notebook_data" + +# Ein laufender Job hat noch keine audio_file, sein Ordner ist also nicht als +# "in Benutzung" erkennbar. Frisch angefasste Ordner deshalb grundsaetzlich in +# Ruhe lassen. +DEFAULT_MIN_AGE_MIN = 60 + + +def die(msg: str) -> None: + print(f"FEHLER: {msg}", file=sys.stderr) + sys.exit(1) + + +def fetch_episodes() -> list[dict]: + try: + with urllib.request.urlopen(f"{API}/api/podcasts/episodes", timeout=10) as r: + return json.load(r) + except (urllib.error.URLError, TimeoutError) as e: + die(f"API unter {API} nicht erreichbar ({e}). Laeuft der Container? " + "Ohne die Episodenliste laesst sich nicht sicher unterscheiden, " + "welche Ordner noch gebraucht werden.") + + +def to_host_path(audio_file: str) -> Path | None: + """Container-Pfad der DB -> Pfad auf dem Host.""" + if audio_file.startswith("file://"): + audio_file = audio_file[len("file://"):] + if audio_file.startswith(CONTAINER_PREFIX): + return HOST_PREFIX / audio_file[len(CONTAINER_PREFIX):] + p = Path(audio_file) + return p if p.is_absolute() and p.exists() else None + + +def to_container_path(host_path: Path) -> str: + """Pfad auf dem Host -> Container-Pfad (Umkehrung von to_host_path).""" + rel = host_path.resolve().relative_to(HOST_PREFIX.resolve()) + return CONTAINER_PREFIX + str(rel) + + +def delete_in_container(paths: list[Path]) -> None: + """Loescht im Container statt auf dem Host. + + Der open_notebook-Container laeuft als root und legt die Episodenordner + entsprechend root:root an — der Host-User darf sie nicht entfernen. Statt + dafuer sudo zu verlangen, loeschen wir dort, wo die Rechte ohnehin stimmen. + """ + targets = [to_container_path(p) for p in paths] + guard = CONTAINER_PREFIX + "podcasts/episodes/" + for t in targets: # Sicherheitsnetz gegen Pfade ausserhalb der Episoden. + if not t.startswith(guard): + die(f"Abbruch: {t} liegt ausserhalb von {guard}") + subprocess.run( + ["docker", "compose", "exec", "-T", "open_notebook", "rm", "-rf", "--", *targets], + cwd=REPO, check=True, capture_output=True, + ) + + +def dir_size_mb(path: Path) -> float: + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1e6 + + +def age_minutes(path: Path) -> float: + newest = max((f.stat().st_mtime for f in path.rglob("*")), default=path.stat().st_mtime) + return (time.time() - newest) / 60 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--yes", action="store_true", + help="wirklich loeschen (ohne dies nur Trockenlauf)") + ap.add_argument("--keep-clips", action="store_true", + help="clips/ fertiger Episoden behalten, nur verwaiste Ordner loeschen") + ap.add_argument("--min-age", type=int, default=DEFAULT_MIN_AGE_MIN, metavar="MIN", + help=f"Ordner juenger als MIN Minuten nie anfassen (Default: {DEFAULT_MIN_AGE_MIN})") + args = ap.parse_args() + + if not EPISODES_DIR.is_dir(): + print(f"Nichts zu tun: {EPISODES_DIR} existiert nicht.") + return 0 + + episodes = fetch_episodes() + + # Laufende Jobs schreiben gerade in ihren Ordner, den wir nicht zuordnen + # koennen (noch keine audio_file). Dann lieber gar nichts loeschen. + running = [e for e in episodes if e.get("job_status") in ("running", "pending", "submitted")] + if running: + die(f"{len(running)} Podcast-Job(s) laufen gerade " + f"({', '.join(e.get('name') or e['id'] for e in running)}). " + "Bitte warten, bis sie fertig sind — sonst wuerde ihr Arbeitsverzeichnis geloescht.") + + # Ordner, die zu einer existierenden Episode gehoeren. + live: dict[Path, dict] = {} + for e in episodes: + af = e.get("audio_file") + if not af: + continue + host = to_host_path(af) + if host: + live[host.parent.parent.resolve()] = e # /audio/.mp3 -> / + + orphans: list[tuple[Path, float]] = [] + clip_dirs: list[tuple[Path, float, str]] = [] + skipped_young: list[str] = [] + + for d in sorted(EPISODES_DIR.iterdir()): + if not d.is_dir(): + continue + d = d.resolve() + if d.parent != EPISODES_DIR.resolve(): # Symlink-Ausbruch o.ae. + continue + if age_minutes(d) < args.min_age: + skipped_young.append(d.name) + continue + + ep = live.get(d) + if ep is None: + orphans.append((d, dir_size_mb(d))) + continue + + clips = d / "clips" + final_mp3 = to_host_path(ep["audio_file"]) + if not args.keep_clips and clips.is_dir() and final_mp3 and final_mp3.exists(): + n = len(list(clips.glob("*"))) + if n: + clip_dirs.append((clips, dir_size_mb(clips), ep.get("name") or ep["id"])) + + if orphans: + print(f"Verwaiste Episodenordner (kein DB-Eintrag mehr) — {len(orphans)}:") + for d, mb in orphans: + print(f" {d.name} {mb:6.1f} MB") + if clip_dirs: + print(f"\nZwischenclips fertiger Episoden (finale MP3 existiert) — {len(clip_dirs)}:") + for c, mb, name in clip_dirs: + print(f" {c.parent.name}/clips {mb:6.1f} MB ({name})") + if skipped_young: + print(f"\nUebersprungen (juenger als {args.min_age} min): {', '.join(skipped_young)}") + + total = sum(mb for _, mb in orphans) + sum(mb for _, mb, _ in clip_dirs) + if not orphans and not clip_dirs: + print("Nichts aufzuraeumen.") + return 0 + + print(f"\nFreizugeben: {total:.1f} MB") + if not args.yes: + print("Trockenlauf — nichts geloescht. Mit --yes wirklich loeschen.") + return 0 + + victims = [d for d, _ in orphans] + [c for c, _, _ in clip_dirs] + try: + delete_in_container(victims) + except FileNotFoundError: + die("docker nicht gefunden — das Loeschen laeuft im Container " + "(die Ordner gehoeren root).") + except subprocess.CalledProcessError as e: + die(f"Loeschen im Container fehlgeschlagen: {e.stderr.decode().strip()}") + + for d, _ in orphans: + print(f"geloescht: {d.name}") + for c, _, _ in clip_dirs: + print(f"geloescht: {c.parent.name}/clips") + + rest = [p for p in victims if p.exists()] + if rest: + die(f"{len(rest)} Pfad(e) existieren noch: {', '.join(p.name for p in rest)}") + print(f"\n{total:.1f} MB freigegeben.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())