Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind
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/<uuid>/ 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 <noreply@anthropic.com>
2026-07-11 13:14:19 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Raeumt Podcast-Daten auf, die Open Notebook selbst liegen laesst.
|
|
|
|
|
|
|
|
|
|
Zwei Arten von Muell entstehen unter notebook_data/podcasts/episodes/<uuid>/:
|
|
|
|
|
|
|
|
|
|
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
|
Run containers as the host user instead of root
Everything the containers wrote into the bind mounts (surreal_data, notebook_data)
was owned by root, so the host user could not delete or back up his own podcast
data — prune_podcast_data.py and update_stack.sh had to detour through
`docker compose exec` for every rm and tar.
That was not a requirement, just the default: the open_notebook image declares no
USER, and the compose file even overrode surrealdb's own non-root user (65532)
with `user: root`, under the comment "Required for bind mounts on Linux" — which
is not true.
Both services now run as user: "1000:1000". Two things this needs:
- HOME=/tmp for open_notebook. Without it HOME resolves to "/" for a non-root uid,
uv cannot create /.cache/uv, and api + worker exit 2 at startup. Verified by
running the image as uid 1000 both ways.
- The data directories must be owned by that uid. Existing data was adopted with a
throwaway root container (chown -R), no sudo needed.
Both scripts drop the container detour and operate on the host directly, which is
simpler and now honest. smoke_test.sh gained two checks so a silent regression to
root cannot go unnoticed: the container's uid must match the host user, and no
foreign-owned files may exist under the data directories.
Verified: containers run as uid 1000, new files land as dschlueter and are
deletable without sudo, SurrealDB writes as 1000, a source can be created,
embedded and deleted through the API, and the full smoke test is green.
Note this deviates from what the image expects (it assumes root), so it is exactly
the kind of assumption an update can break — hence the smoke-test checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:47 +02:00
|
|
|
import shutil
|
Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind
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/<uuid>/ 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 <noreply@anthropic.com>
2026-07-11 13:14:19 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Run containers as the host user instead of root
Everything the containers wrote into the bind mounts (surreal_data, notebook_data)
was owned by root, so the host user could not delete or back up his own podcast
data — prune_podcast_data.py and update_stack.sh had to detour through
`docker compose exec` for every rm and tar.
That was not a requirement, just the default: the open_notebook image declares no
USER, and the compose file even overrode surrealdb's own non-root user (65532)
with `user: root`, under the comment "Required for bind mounts on Linux" — which
is not true.
Both services now run as user: "1000:1000". Two things this needs:
- HOME=/tmp for open_notebook. Without it HOME resolves to "/" for a non-root uid,
uv cannot create /.cache/uv, and api + worker exit 2 at startup. Verified by
running the image as uid 1000 both ways.
- The data directories must be owned by that uid. Existing data was adopted with a
throwaway root container (chown -R), no sudo needed.
Both scripts drop the container detour and operate on the host directly, which is
simpler and now honest. smoke_test.sh gained two checks so a silent regression to
root cannot go unnoticed: the container's uid must match the host user, and no
foreign-owned files may exist under the data directories.
Verified: containers run as uid 1000, new files land as dschlueter and are
deletable without sudo, SurrealDB writes as 1000, a source can be created,
embedded and deleted through the API, and the full smoke test is green.
Note this deviates from what the image expects (it assumes root), so it is exactly
the kind of assumption an update can break — hence the smoke-test checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:47 +02:00
|
|
|
def delete_dirs(paths: list[Path]) -> None:
|
|
|
|
|
"""Loescht die Verzeichnisse direkt auf dem Host.
|
Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind
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/<uuid>/ 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 <noreply@anthropic.com>
2026-07-11 13:14:19 +02:00
|
|
|
|
Run containers as the host user instead of root
Everything the containers wrote into the bind mounts (surreal_data, notebook_data)
was owned by root, so the host user could not delete or back up his own podcast
data — prune_podcast_data.py and update_stack.sh had to detour through
`docker compose exec` for every rm and tar.
That was not a requirement, just the default: the open_notebook image declares no
USER, and the compose file even overrode surrealdb's own non-root user (65532)
with `user: root`, under the comment "Required for bind mounts on Linux" — which
is not true.
Both services now run as user: "1000:1000". Two things this needs:
- HOME=/tmp for open_notebook. Without it HOME resolves to "/" for a non-root uid,
uv cannot create /.cache/uv, and api + worker exit 2 at startup. Verified by
running the image as uid 1000 both ways.
- The data directories must be owned by that uid. Existing data was adopted with a
throwaway root container (chown -R), no sudo needed.
Both scripts drop the container detour and operate on the host directly, which is
simpler and now honest. smoke_test.sh gained two checks so a silent regression to
root cannot go unnoticed: the container's uid must match the host user, and no
foreign-owned files may exist under the data directories.
Verified: containers run as uid 1000, new files land as dschlueter and are
deletable without sudo, SurrealDB writes as 1000, a source can be created,
embedded and deleted through the API, and the full smoke test is green.
Note this deviates from what the image expects (it assumes root), so it is exactly
the kind of assumption an update can break — hence the smoke-test checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:47 +02:00
|
|
|
Das geht, weil die Container als Host-User laufen (user: "1000:1000" in
|
|
|
|
|
docker-compose.yml) und die Episodenordner deshalb uns gehoeren. Liefen sie als
|
|
|
|
|
root — der Default, wenn man den user-Eintrag entfernt — schlaegt das hier mit
|
|
|
|
|
PermissionError fehl; dann gehoert der Eintrag zurueck in die Compose-Datei.
|
Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind
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/<uuid>/ 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 <noreply@anthropic.com>
2026-07-11 13:14:19 +02:00
|
|
|
"""
|
Run containers as the host user instead of root
Everything the containers wrote into the bind mounts (surreal_data, notebook_data)
was owned by root, so the host user could not delete or back up his own podcast
data — prune_podcast_data.py and update_stack.sh had to detour through
`docker compose exec` for every rm and tar.
That was not a requirement, just the default: the open_notebook image declares no
USER, and the compose file even overrode surrealdb's own non-root user (65532)
with `user: root`, under the comment "Required for bind mounts on Linux" — which
is not true.
Both services now run as user: "1000:1000". Two things this needs:
- HOME=/tmp for open_notebook. Without it HOME resolves to "/" for a non-root uid,
uv cannot create /.cache/uv, and api + worker exit 2 at startup. Verified by
running the image as uid 1000 both ways.
- The data directories must be owned by that uid. Existing data was adopted with a
throwaway root container (chown -R), no sudo needed.
Both scripts drop the container detour and operate on the host directly, which is
simpler and now honest. smoke_test.sh gained two checks so a silent regression to
root cannot go unnoticed: the container's uid must match the host user, and no
foreign-owned files may exist under the data directories.
Verified: containers run as uid 1000, new files land as dschlueter and are
deletable without sudo, SurrealDB writes as 1000, a source can be created,
embedded and deleted through the API, and the full smoke test is green.
Note this deviates from what the image expects (it assumes root), so it is exactly
the kind of assumption an update can break — hence the smoke-test checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:47 +02:00
|
|
|
guard = (EPISODES_DIR).resolve()
|
|
|
|
|
for p in paths: # Sicherheitsnetz gegen Pfade ausserhalb der Episoden.
|
|
|
|
|
if guard not in p.resolve().parents:
|
|
|
|
|
die(f"Abbruch: {p} liegt ausserhalb von {guard}")
|
|
|
|
|
for p in paths:
|
|
|
|
|
try:
|
|
|
|
|
shutil.rmtree(p)
|
|
|
|
|
except PermissionError as e:
|
|
|
|
|
die(f"Keine Berechtigung fuer {p} ({e}).\n"
|
|
|
|
|
"Gehoeren die Daten root? Dann laeuft der Container als root — pruefe\n"
|
|
|
|
|
"den Eintrag user: \"1000:1000\" in docker-compose.yml.")
|
Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind
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/<uuid>/ 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 <noreply@anthropic.com>
2026-07-11 13:14:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 # <uuid>/audio/<uuid>.mp3 -> <uuid>/
|
|
|
|
|
|
|
|
|
|
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]
|
Run containers as the host user instead of root
Everything the containers wrote into the bind mounts (surreal_data, notebook_data)
was owned by root, so the host user could not delete or back up his own podcast
data — prune_podcast_data.py and update_stack.sh had to detour through
`docker compose exec` for every rm and tar.
That was not a requirement, just the default: the open_notebook image declares no
USER, and the compose file even overrode surrealdb's own non-root user (65532)
with `user: root`, under the comment "Required for bind mounts on Linux" — which
is not true.
Both services now run as user: "1000:1000". Two things this needs:
- HOME=/tmp for open_notebook. Without it HOME resolves to "/" for a non-root uid,
uv cannot create /.cache/uv, and api + worker exit 2 at startup. Verified by
running the image as uid 1000 both ways.
- The data directories must be owned by that uid. Existing data was adopted with a
throwaway root container (chown -R), no sudo needed.
Both scripts drop the container detour and operate on the host directly, which is
simpler and now honest. smoke_test.sh gained two checks so a silent regression to
root cannot go unnoticed: the container's uid must match the host user, and no
foreign-owned files may exist under the data directories.
Verified: containers run as uid 1000, new files land as dschlueter and are
deletable without sudo, SurrealDB writes as 1000, a source can be created,
embedded and deleted through the API, and the full smoke test is green.
Note this deviates from what the image expects (it assumes root), so it is exactly
the kind of assumption an update can break — hence the smoke-test checks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:47 +02:00
|
|
|
delete_dirs(victims)
|
Add prune_podcast_data.py: clean up podcast data Open Notebook leaves behind
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/<uuid>/ 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 <noreply@anthropic.com>
2026-07-11 13:14:19 +02:00
|
|
|
|
|
|
|
|
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())
|