From 61814d388efdce8c7b044dc2c8f9bbb528d3ea74 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 9 Nov 2026 23:23:20 +0100 Subject: [PATCH 1/4] feat(scripts): add a demo archive seeder scripts/demo_seed.py renders short sample recordings from FFmpeg test sources, with chat logs, title sidecars, thumbnails and local avatars, so StreamVault can be tried without recording anything. It refuses to write into a non-empty directory it did not create. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + scripts/demo_seed.py | 233 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 scripts/demo_seed.py diff --git a/.gitignore b/.gitignore index 1416ed6..42784ea 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ Thumbs.db *.swp *.bak *.bak_* +/demo-data/ 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()) From 7c20a2114d6ab02e1d9a4eadb93ec1c137dea776 Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 9 Nov 2026 23:23:20 +0100 Subject: [PATCH 2/4] fix(webui): read disk usage portably and log failures storage_info() used os.statvfs and a bare except, so non-Linux hosts always showed N/A and real errors were swallowed. shutil.disk_usage gives the same values on POSIX; OSError is now logged. Also adds tests for the demo seeder's directory guard. Co-Authored-By: Claude Opus 5.5 --- WEBUI/webui.py | 11 +++++--- tests/test_recorder_reliability.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) 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/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.""" From 70ab8ed6b6bba43318830105ff223031e10a02ce Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 9 Nov 2026 23:23:20 +0100 Subject: [PATCH 3/4] fix(webui): polish labels found while taking screenshots - The VOD chat panel was hard-coded to an untranslated 'Twitch Chat', also for YouTube recordings; it now uses the translated 'Chat'. - The sidebar language switcher was truncated to 'A...'; the narrow rail shows a globe and the language code over the native select. - Channel pages printed an empty separator when a recording had no size. - Example values no longer use a real streamer's name or German text. Co-Authored-By: Claude Opus 5.5 --- .../components/language_switcher.html | 7 +++++++ WEBUI/templates/components/sidebar.html | 8 ++++++-- WEBUI/templates/storage.html | 4 ++-- WEBUI/templates/streamer.html | 2 ++ WEBUI/templates/vod.html | 2 +- WEBUI/translations/de/LC_MESSAGES/messages.mo | Bin 80716 -> 80712 bytes WEBUI/translations/de/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/es/LC_MESSAGES/messages.mo | Bin 83717 -> 83713 bytes WEBUI/translations/es/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/fr/LC_MESSAGES/messages.mo | Bin 85926 -> 85922 bytes WEBUI/translations/fr/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/it/LC_MESSAGES/messages.mo | Bin 82157 -> 82153 bytes WEBUI/translations/it/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/ja/LC_MESSAGES/messages.mo | Bin 94013 -> 94009 bytes WEBUI/translations/ja/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/ko/LC_MESSAGES/messages.mo | Bin 86053 -> 86049 bytes WEBUI/translations/ko/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/messages.pot | 2 +- WEBUI/translations/nl/LC_MESSAGES/messages.mo | Bin 80278 -> 80274 bytes WEBUI/translations/nl/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/pl/LC_MESSAGES/messages.mo | Bin 82264 -> 82260 bytes WEBUI/translations/pl/LC_MESSAGES/messages.po | 4 ++-- .../pt_BR/LC_MESSAGES/messages.mo | Bin 82624 -> 82620 bytes .../pt_BR/LC_MESSAGES/messages.po | 4 ++-- WEBUI/translations/tr/LC_MESSAGES/messages.mo | Bin 81744 -> 81740 bytes WEBUI/translations/tr/LC_MESSAGES/messages.po | 4 ++-- .../zh_Hans/LC_MESSAGES/messages.mo | Bin 76485 -> 76481 bytes .../zh_Hans/LC_MESSAGES/messages.po | 4 ++-- core/storage/cli.py | 4 ++-- 29 files changed, 43 insertions(+), 30 deletions(-) diff --git a/WEBUI/templates/components/language_switcher.html b/WEBUI/templates/components/language_switcher.html index f24d537..c5bc7e1 100644 --- a/WEBUI/templates/components/language_switcher.html +++ b/WEBUI/templates/components/language_switcher.html @@ -2,6 +2,13 @@ + {% if lang_compact %} + {# Narrow rail: show a globe and the active language code; the native select sits invisibly on top so the dropdown keeps the full names. #} + + {% endif %} + diff --git a/WEBUI/templates/streamer.html b/WEBUI/templates/streamer.html index cfb15c6..4152881 100644 --- a/WEBUI/templates/streamer.html +++ b/WEBUI/templates/streamer.html @@ -273,8 +273,10 @@

{{ hero_stream.title }}

{{ f.date_str }} + {% if f.size_str %} • {{ f.size_str }} + {% endif %} {% if f.category %} • {{ _('Game: %(category)s', category=f.category) }} 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 @@

{{ _('Transcript') }}