diff --git a/WEBUI/templates/vod.html b/WEBUI/templates/vod.html
index 0e84552..59fa0e5 100644
--- a/WEBUI/templates/vod.html
+++ b/WEBUI/templates/vod.html
@@ -250,7 +250,7 @@
{% if not is_mobile %}
diff --git a/WEBUI/translations/de/LC_MESSAGES/messages.mo b/WEBUI/translations/de/LC_MESSAGES/messages.mo
index b017e94..a7c7b6e 100644
Binary files a/WEBUI/translations/de/LC_MESSAGES/messages.mo and b/WEBUI/translations/de/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/de/LC_MESSAGES/messages.po b/WEBUI/translations/de/LC_MESSAGES/messages.po
index aa42f0a..cc728e7 100644
--- a/WEBUI/translations/de/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/de/LC_MESSAGES/messages.po
@@ -3362,8 +3362,8 @@ msgstr "z.B. alice, bob"
msgid "e.g. john_doe"
msgstr "z.B. max_mustermann"
-msgid "e.g. montanablack88"
-msgstr "z. B. montanablack88"
+msgid "e.g. channel_name"
+msgstr "z. B. channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/es/LC_MESSAGES/messages.mo b/WEBUI/translations/es/LC_MESSAGES/messages.mo
index c0bfd64..07377a1 100644
Binary files a/WEBUI/translations/es/LC_MESSAGES/messages.mo and b/WEBUI/translations/es/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/es/LC_MESSAGES/messages.po b/WEBUI/translations/es/LC_MESSAGES/messages.po
index ddce659..3e2add7 100644
--- a/WEBUI/translations/es/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/es/LC_MESSAGES/messages.po
@@ -3362,8 +3362,8 @@ msgstr "p. ej., alice, bob"
msgid "e.g. john_doe"
msgstr "p. ej., juan_perez"
-msgid "e.g. montanablack88"
-msgstr "p. ej., montanablack88"
+msgid "e.g. channel_name"
+msgstr "p. ej., channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/fr/LC_MESSAGES/messages.mo b/WEBUI/translations/fr/LC_MESSAGES/messages.mo
index 2e82bc0..7e02db5 100644
Binary files a/WEBUI/translations/fr/LC_MESSAGES/messages.mo and b/WEBUI/translations/fr/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/fr/LC_MESSAGES/messages.po b/WEBUI/translations/fr/LC_MESSAGES/messages.po
index 62c9b15..66e748b 100644
--- a/WEBUI/translations/fr/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/fr/LC_MESSAGES/messages.po
@@ -3362,8 +3362,8 @@ msgstr "ex. alice, bob"
msgid "e.g. john_doe"
msgstr "ex. jean_dupont"
-msgid "e.g. montanablack88"
-msgstr "ex. montanablack88"
+msgid "e.g. channel_name"
+msgstr "ex. channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/it/LC_MESSAGES/messages.mo b/WEBUI/translations/it/LC_MESSAGES/messages.mo
index fd76697..8cabbb7 100644
Binary files a/WEBUI/translations/it/LC_MESSAGES/messages.mo and b/WEBUI/translations/it/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/it/LC_MESSAGES/messages.po b/WEBUI/translations/it/LC_MESSAGES/messages.po
index 6967b04..535abf8 100644
--- a/WEBUI/translations/it/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/it/LC_MESSAGES/messages.po
@@ -3362,8 +3362,8 @@ msgstr "es. alice, bob"
msgid "e.g. john_doe"
msgstr "es. mario_rossi"
-msgid "e.g. montanablack88"
-msgstr "es. montanablack88"
+msgid "e.g. channel_name"
+msgstr "es. channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/ja/LC_MESSAGES/messages.mo b/WEBUI/translations/ja/LC_MESSAGES/messages.mo
index 41584df..f8e0010 100644
Binary files a/WEBUI/translations/ja/LC_MESSAGES/messages.mo and b/WEBUI/translations/ja/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/ja/LC_MESSAGES/messages.po b/WEBUI/translations/ja/LC_MESSAGES/messages.po
index 99737f4..119d76e 100644
--- a/WEBUI/translations/ja/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/ja/LC_MESSAGES/messages.po
@@ -3356,8 +3356,8 @@ msgstr "例: alice, bob"
msgid "e.g. john_doe"
msgstr "例: john_doe"
-msgid "e.g. montanablack88"
-msgstr "例: montanablack88"
+msgid "e.g. channel_name"
+msgstr "例: channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/ko/LC_MESSAGES/messages.mo b/WEBUI/translations/ko/LC_MESSAGES/messages.mo
index 11c53f2..5ac6a53 100644
Binary files a/WEBUI/translations/ko/LC_MESSAGES/messages.mo and b/WEBUI/translations/ko/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/ko/LC_MESSAGES/messages.po b/WEBUI/translations/ko/LC_MESSAGES/messages.po
index ecaef05..c2ab7aa 100644
--- a/WEBUI/translations/ko/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/ko/LC_MESSAGES/messages.po
@@ -3356,8 +3356,8 @@ msgstr "예: alice, bob"
msgid "e.g. john_doe"
msgstr "예: hong_gildong"
-msgid "e.g. montanablack88"
-msgstr "예: montanablack88"
+msgid "e.g. channel_name"
+msgstr "예: channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/messages.pot b/WEBUI/translations/messages.pot
index 97ab6d5..1f08211 100644
--- a/WEBUI/translations/messages.pot
+++ b/WEBUI/translations/messages.pot
@@ -3482,7 +3482,7 @@ msgstr ""
msgid "e.g. john_doe"
msgstr ""
-msgid "e.g. montanablack88"
+msgid "e.g. channel_name"
msgstr ""
#, python-format
diff --git a/WEBUI/translations/nl/LC_MESSAGES/messages.mo b/WEBUI/translations/nl/LC_MESSAGES/messages.mo
index 4b7174b..1d8665e 100644
Binary files a/WEBUI/translations/nl/LC_MESSAGES/messages.mo and b/WEBUI/translations/nl/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/nl/LC_MESSAGES/messages.po b/WEBUI/translations/nl/LC_MESSAGES/messages.po
index be19651..3d0d81a 100644
--- a/WEBUI/translations/nl/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/nl/LC_MESSAGES/messages.po
@@ -3362,8 +3362,8 @@ msgstr "bijv. alice, bob"
msgid "e.g. john_doe"
msgstr "bijv. jan_jansen"
-msgid "e.g. montanablack88"
-msgstr "bijv. montanablack88"
+msgid "e.g. channel_name"
+msgstr "bijv. channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/pl/LC_MESSAGES/messages.mo b/WEBUI/translations/pl/LC_MESSAGES/messages.mo
index 71c9ce9..a0460b3 100644
Binary files a/WEBUI/translations/pl/LC_MESSAGES/messages.mo and b/WEBUI/translations/pl/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/pl/LC_MESSAGES/messages.po b/WEBUI/translations/pl/LC_MESSAGES/messages.po
index ef8fbc7..e13b03a 100644
--- a/WEBUI/translations/pl/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/pl/LC_MESSAGES/messages.po
@@ -3368,8 +3368,8 @@ msgstr "np. alice, bob"
msgid "e.g. john_doe"
msgstr "np. jan_kowalski"
-msgid "e.g. montanablack88"
-msgstr "np. montanablack88"
+msgid "e.g. channel_name"
+msgstr "np. channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/pt_BR/LC_MESSAGES/messages.mo b/WEBUI/translations/pt_BR/LC_MESSAGES/messages.mo
index 18b775e..17dc046 100644
Binary files a/WEBUI/translations/pt_BR/LC_MESSAGES/messages.mo and b/WEBUI/translations/pt_BR/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/pt_BR/LC_MESSAGES/messages.po b/WEBUI/translations/pt_BR/LC_MESSAGES/messages.po
index 117a17b..4628000 100644
--- a/WEBUI/translations/pt_BR/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/pt_BR/LC_MESSAGES/messages.po
@@ -3362,8 +3362,8 @@ msgstr "ex.: alice, bob"
msgid "e.g. john_doe"
msgstr "ex.: joao_silva"
-msgid "e.g. montanablack88"
-msgstr "ex.: montanablack88"
+msgid "e.g. channel_name"
+msgstr "ex.: channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/tr/LC_MESSAGES/messages.mo b/WEBUI/translations/tr/LC_MESSAGES/messages.mo
index 4cf3924..b3606e3 100644
Binary files a/WEBUI/translations/tr/LC_MESSAGES/messages.mo and b/WEBUI/translations/tr/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/tr/LC_MESSAGES/messages.po b/WEBUI/translations/tr/LC_MESSAGES/messages.po
index 210c3ca..e7746ee 100644
--- a/WEBUI/translations/tr/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/tr/LC_MESSAGES/messages.po
@@ -3356,8 +3356,8 @@ msgstr "ör. alice, bob"
msgid "e.g. john_doe"
msgstr "ör. john_doe"
-msgid "e.g. montanablack88"
-msgstr "ör. montanablack88"
+msgid "e.g. channel_name"
+msgstr "ör. channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.mo b/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.mo
index b011f68..1abce46 100644
Binary files a/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.mo and b/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.mo differ
diff --git a/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.po b/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.po
index ad62199..e235842 100644
--- a/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.po
+++ b/WEBUI/translations/zh_Hans/LC_MESSAGES/messages.po
@@ -3356,8 +3356,8 @@ msgstr "如:alice, bob"
msgid "e.g. john_doe"
msgstr "如:zhang_san"
-msgid "e.g. montanablack88"
-msgstr "如:montanablack88"
+msgid "e.g. channel_name"
+msgstr "如:channel_name"
#, python-format
msgid "effective from %(date)s"
diff --git a/WEBUI/webui.py b/WEBUI/webui.py
index 685daa9..ed248b1 100644
--- a/WEBUI/webui.py
+++ b/WEBUI/webui.py
@@ -1829,9 +1829,11 @@ def format_bytes(num_bytes):
def storage_info():
try:
- st = os.statvfs(BASE_DIR)
- total = st.f_frsize * st.f_blocks
- free = st.f_frsize * st.f_bavail
+ # shutil.disk_usage works on every platform; free is the space available
+ # to unprivileged users (statvfs f_bavail on POSIX).
+ usage = shutil.disk_usage(BASE_DIR)
+ total = usage.total
+ free = usage.free
used = total - free
pct = (used / total) * 100 if total else 0
return {
@@ -1842,7 +1844,8 @@ def storage_info():
"total_str": format_bytes(total),
"free_str": format_bytes(free),
}
- except Exception:
+ except OSError as exc:
+ logging.warning("Storage info unavailable for %s: %s", BASE_DIR, exc)
return {
"total": 0,
"free": 0,
diff --git a/core/storage/cli.py b/core/storage/cli.py
index 94dbd23..f71b47e 100644
--- a/core/storage/cli.py
+++ b/core/storage/cli.py
@@ -2,7 +2,7 @@
Usage:
python -m core.storage.cli list
- python -m core.storage.cli add --type google_drive --name "Mein Drive"
+ python -m core.storage.cli add --type google_drive --name "My Drive"
python -m core.storage.cli test 1
python -m core.storage.cli sync
python -m core.storage.cli upload /pfad/zum/video.mp4
@@ -87,7 +87,7 @@ def _cmd_list(args):
return 0
if not rows:
_print("Keine Cloud-Speicher konfiguriert.")
- _print("Anlegen: python -m core.storage.cli add --type google_drive --name 'Mein Drive'")
+ _print("Anlegen: python -m core.storage.cli add --type google_drive --name 'My Drive'")
return 0
settings = service.get_settings()
default_id = settings.get("default_provider_id")
diff --git a/docs/screenshots/admin_panel.png b/docs/screenshots/admin_panel.png
index 7358fc8..79ac75d 100644
Binary files a/docs/screenshots/admin_panel.png and b/docs/screenshots/admin_panel.png differ
diff --git a/docs/screenshots/admin_users.png b/docs/screenshots/admin_users.png
deleted file mode 100644
index f9b0897..0000000
Binary files a/docs/screenshots/admin_users.png and /dev/null differ
diff --git a/docs/screenshots/cloud_storage.png b/docs/screenshots/cloud_storage.png
new file mode 100644
index 0000000..34439be
Binary files /dev/null and b/docs/screenshots/cloud_storage.png differ
diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png
index 1371895..c8d523a 100644
Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ
diff --git a/docs/screenshots/mobile_view.png b/docs/screenshots/mobile_view.png
index b0e61be..ee1499f 100644
Binary files a/docs/screenshots/mobile_view.png and b/docs/screenshots/mobile_view.png differ
diff --git a/docs/screenshots/streamer_hub.png b/docs/screenshots/streamer_hub.png
index 6135746..933f90d 100644
Binary files a/docs/screenshots/streamer_hub.png and b/docs/screenshots/streamer_hub.png differ
diff --git a/docs/screenshots/vod_player.png b/docs/screenshots/vod_player.png
index 0d35089..d6ae345 100644
Binary files a/docs/screenshots/vod_player.png and b/docs/screenshots/vod_player.png differ
diff --git a/scripts/demo_seed.py b/scripts/demo_seed.py
new file mode 100644
index 0000000..280c00c
--- /dev/null
+++ b/scripts/demo_seed.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""Create a small demo archive so StreamVault can be tried without recording.
+
+Generates short MP4 recordings (FFmpeg test sources, no third-party content)
+and matching chat logs for a few fictional channels:
+
+ python scripts/demo_seed.py --base-dir ./demo-data
+ VOD_BASE_DIR=./demo-data python -m WEBUI.webui
+
+Existing files are never overwritten. The target directory must be empty or a
+directory created by an earlier run of this script.
+"""
+
+import argparse
+import json
+import logging
+import os
+import random
+import shutil
+import subprocess
+import sys
+from datetime import datetime, timedelta
+
+logger = logging.getLogger("demo_seed")
+
+MARKER_FILE = ".streamvault-demo"
+PLATFORMS = {"tw": "twitch", "yt": "youtube"}
+CHAT_LOG_SUBDIR = os.environ.get("VOD_CHAT_SUBDIR", "chat_logs")
+FFMPEG_TIMEOUT_SEC = 300
+
+# name, platform tag, category, gradient colours, stream titles (newest first)
+CHANNELS = [
+ ("pixelharbor", "tw", "Retro", ("0x1e1b4b", "0x7c3aed", "0x0ea5e9"),
+ ["Speedrun practice: any% routes", "Retro Sunday: 16-bit classics"]),
+ ("lunarlab", "yt", "Music", ("0x022c22", "0x059669", "0xfacc15"),
+ ["Building a synth from scratch", "Q&A and modular patching"]),
+ ("tidecraft", "tw", "Simulation", ("0x431407", "0xea580c", "0xf472b6"),
+ ["Cozy farming, day 112", "Island tour and base building"]),
+]
+
+CHATTERS = [
+ ("mossbyte", "#22c55e"), ("quietfox", "#f97316"), ("neonwren", "#a855f7"),
+ ("drift_owl", "#38bdf8"), ("pebble42", "#facc15"), ("saltyhare", "#f43f5e"),
+ ("kilnmoth", "#14b8a6"), ("orbitpine", "#818cf8"),
+]
+
+MESSAGES = [
+ "hello chat!", "that was clean", "GG", "first time here, love the vibe",
+ "what settings are you using?", "haha", "no way", "chat is flying today",
+ "can you show that again?", "hydrate check", "this song is great",
+ "W", "how long have you been streaming?", "clip that", "let's go",
+ "the new overlay looks good", "brb grabbing tea", "nice save",
+]
+
+
+def _fontfile_option():
+ """Return a drawtext fontfile option for a sans font, or '' for the default."""
+ candidates = [
+ "C:/Windows/Fonts/segoeuib.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
+ ]
+ for path in candidates:
+ if os.path.isfile(path):
+ # drawtext option values treat ':' as a separator.
+ escaped = path.replace("\\", "/").replace(":", "\\:")
+ return f":fontfile='{escaped}'"
+ return ""
+
+
+def _escape_drawtext(text):
+ return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\u2019")
+
+
+def render_video(ffmpeg, path, title, channel, colors, duration):
+ font = _fontfile_option()
+ c0, c1, c2 = colors
+ label = _escape_drawtext(title)
+ sub = _escape_drawtext(channel)
+ tmp_path = path + ".part"
+ # expansion=none: titles may contain '%', which drawtext would parse as a function.
+ video_filter = (
+ f"drawtext=text='{label}':expansion=none{font}:fontsize=64:fontcolor=white:"
+ "x=(w-tw)/2:y=(h-th)/2-30:shadowcolor=black@0.4:shadowx=2:shadowy=2,"
+ f"drawtext=text='{sub}':expansion=none{font}:fontsize=34:fontcolor=white@0.75:"
+ "x=(w-tw)/2:y=(h-th)/2+50"
+ )
+ cmd = [
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi",
+ "-i", f"gradients=s=1280x720:r=30:c0={c0}:c1={c1}:c2={c2}:n=3:speed=0.01:d={duration}",
+ "-f", "lavfi", "-i", f"sine=frequency=220:sample_rate=48000:d={duration}",
+ "-vf", video_filter,
+ "-af", "volume=0.05",
+ "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
+ "-c:a", "aac", "-b:a", "96k",
+ "-movflags", "+faststart", "-shortest",
+ "-f", "mp4", tmp_path,
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_SEC)
+ if result.returncode != 0:
+ if os.path.exists(tmp_path):
+ os.remove(tmp_path)
+ raise RuntimeError(f"ffmpeg failed for {path}: {result.stderr.strip()[-500:]}")
+ # Only a finished file gets the final name, so a failed run is retried next time.
+ os.replace(tmp_path, path)
+
+
+def render_thumbnail(ffmpeg, video_path, thumb_path):
+ """Pre-render the card thumbnail so the demo looks complete without the worker."""
+ os.makedirs(os.path.dirname(thumb_path), exist_ok=True)
+ cmd = [
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
+ "-ss", "3", "-i", video_path, "-frames:v", "1",
+ "-vf", "scale=640:-2", "-q:v", "3", thumb_path,
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_SEC)
+ if result.returncode != 0:
+ raise RuntimeError(f"thumbnail failed for {video_path}: {result.stderr.strip()[-500:]}")
+
+
+def render_avatar(ffmpeg, path, channel, colors):
+ """Local avatar, so the UI never pulls the profile picture of a real account with the same name."""
+ font = _fontfile_option()
+ c0, c1, c2 = colors
+ initial = _escape_drawtext(channel[:1].upper())
+ cmd = [
+ ffmpeg, "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", f"gradients=s=256x256:c0={c0}:c1={c1}:c2={c2}:n=3:d=1",
+ "-vf", f"drawtext=text='{initial}':expansion=none{font}:fontsize=120:fontcolor=white:"
+ "x=(w-tw)/2:y=(h-th)/2",
+ "-frames:v", "1", path,
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_SEC)
+ if result.returncode != 0:
+ raise RuntimeError(f"avatar failed for {channel}: {result.stderr.strip()[-500:]}")
+
+
+def write_chat_log(path, start, duration, rng):
+ lines = []
+ rel = 1.0
+ while rel < duration:
+ user, color = rng.choice(CHATTERS)
+ entry = {
+ "t": round(start.timestamp() + rel, 3),
+ "rel": round(rel, 3),
+ "user": user,
+ "msg": rng.choice(MESSAGES),
+ "color": color,
+ }
+ lines.append(json.dumps(entry, ensure_ascii=False))
+ rel += rng.uniform(0.6, 2.4)
+ with open(path, "x", encoding="utf-8") as handle:
+ handle.write("\n".join(lines) + "\n")
+ return len(lines)
+
+
+def check_target(base_dir):
+ if not os.path.isdir(base_dir):
+ os.makedirs(base_dir)
+ return
+ entries = [name for name in os.listdir(base_dir) if name != MARKER_FILE]
+ if entries and not os.path.isfile(os.path.join(base_dir, MARKER_FILE)):
+ raise SystemExit(
+ f"{base_dir} is not empty and was not created by this script; "
+ "choose an empty directory so real archives are never mixed with demo data."
+ )
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description="Create a demo archive for trying StreamVault.")
+ parser.add_argument("--base-dir", default=os.environ.get("VOD_BASE_DIR") or "demo-data",
+ help="archive root to fill (default: $VOD_BASE_DIR or ./demo-data)")
+ parser.add_argument("--duration", type=int, default=90,
+ help="length of each demo recording in seconds (default: 90)")
+ parser.add_argument("--seed", type=int, default=7, help="random seed for chat logs")
+ args = parser.parse_args(argv)
+
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
+ ffmpeg = shutil.which("ffmpeg")
+ if not ffmpeg:
+ logger.error("ffmpeg was not found on PATH")
+ return 1
+ if args.duration < 5:
+ logger.error("--duration must be at least 5 seconds")
+ return 1
+
+ base_dir = os.path.abspath(args.base_dir)
+ check_target(base_dir)
+ with open(os.path.join(base_dir, MARKER_FILE), "w", encoding="utf-8") as handle:
+ handle.write("Created by scripts/demo_seed.py\n")
+
+ thumb_root = os.environ.get("VOD_THUMB_DIR") or os.path.join(base_dir, ".thumbs")
+ rng = random.Random(args.seed)
+ now = datetime.now().replace(minute=0, second=0, microsecond=0)
+ created = 0
+ for index, (channel, platform, category, colors, titles) in enumerate(CHANNELS):
+ channel_dir = os.path.join(base_dir, channel)
+ chat_dir = os.path.join(channel_dir, CHAT_LOG_SUBDIR)
+ os.makedirs(chat_dir, exist_ok=True)
+ avatar_path = os.path.join(channel_dir, "avatar.png")
+ if not os.path.exists(avatar_path):
+ render_avatar(ffmpeg, avatar_path, channel, colors)
+ for age, title in enumerate(titles):
+ start = now - timedelta(days=age * 3 + index, hours=index * 2 + 3)
+ stem = f"{channel}_{platform}_{start:%Y-%m-%d_%H-%M-%S}"
+ video_path = os.path.join(channel_dir, f"{stem}.mp4")
+ chat_path = os.path.join(chat_dir, f"{stem}.jsonl")
+ thumb_path = os.path.join(thumb_root, channel, f"{stem}.jpg")
+ info_path = os.path.join(channel_dir, f"{stem}.info.json")
+ if not os.path.exists(info_path):
+ # Same sidecar format yt-dlp writes; the web UI reads title and category from it.
+ with open(info_path, "x", encoding="utf-8") as handle:
+ json.dump({"title": title, "category": category, "platform": PLATFORMS[platform]},
+ handle, ensure_ascii=False)
+ if os.path.exists(video_path):
+ logger.info("skip existing %s", video_path)
+ if not os.path.exists(thumb_path):
+ render_thumbnail(ffmpeg, video_path, thumb_path)
+ continue
+ render_video(ffmpeg, video_path, title, channel, colors, args.duration)
+ render_thumbnail(ffmpeg, video_path, thumb_path)
+ messages = write_chat_log(chat_path, start, args.duration, rng)
+ created += 1
+ logger.info("created %s (%d chat messages)", os.path.relpath(video_path, base_dir), messages)
+
+ logger.info("%d recordings created in %s", created, base_dir)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_recorder_reliability.py b/tests/test_recorder_reliability.py
index d35ac2b..c202b18 100644
--- a/tests/test_recorder_reliability.py
+++ b/tests/test_recorder_reliability.py
@@ -776,6 +776,49 @@ def test_version_follows_static_files(self):
self.assertNotEqual(first, webui._compute_asset_version(str(static)))
+class StorageInfoTests(IsolatedBaseTest):
+ """Disk usage must work on every platform and log when it cannot be read."""
+
+ def test_reports_real_disk_values(self):
+ from WEBUI import webui
+ with mock.patch.object(webui, "BASE_DIR", str(self.base)):
+ info = webui.storage_info()
+ self.assertGreater(info["total"], 0)
+ self.assertNotEqual(info["free_str"], "N/A")
+
+ def test_unreadable_path_is_logged(self):
+ from WEBUI import webui
+ missing = str(self.base / "does-not-exist")
+ with mock.patch.object(webui, "BASE_DIR", missing), self.assertLogs(level="WARNING"):
+ info = webui.storage_info()
+ self.assertEqual(info["total_str"], "N/A")
+
+
+class DemoSeedTests(IsolatedBaseTest):
+ """The demo seeder must never write into an existing, unrelated archive."""
+
+ @classmethod
+ def setUpClass(cls):
+ import importlib.util
+ path = Path(__file__).resolve().parents[1] / "scripts" / "demo_seed.py"
+ spec = importlib.util.spec_from_file_location("demo_seed", path)
+ cls.demo_seed = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(cls.demo_seed)
+
+ def test_refuses_non_empty_foreign_directory(self):
+ (self.base / "realchannel").mkdir()
+ with self.assertRaises(SystemExit):
+ self.demo_seed.check_target(str(self.base))
+
+ def test_accepts_empty_and_own_directory(self):
+ self.demo_seed.check_target(str(self.base / "fresh"))
+ own = self.base / "own"
+ own.mkdir()
+ (own / self.demo_seed.MARKER_FILE).write_text("x", encoding="utf-8")
+ (own / "pixelharbor").mkdir()
+ self.demo_seed.check_target(str(own))
+
+
class LoadJsonTests(IsolatedBaseTest):
"""A corrupt state file must survive instead of being overwritten by the default."""