Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c333c2289e | |||
| 205af96c19 | |||
| 3c488688d1 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watch",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Let Claude watch a video. Downloads with yt-dlp, extracts auto-scaled frames with ffmpeg, pulls captions or falls back to Whisper, and hands frames + transcript to Claude so it can answer questions about the video.",
|
||||
"author": {
|
||||
"name": "Bradley Bonanno"
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to `/watch` are documented here.
|
||||
|
||||
## [0.1.3] — 2026-05-09
|
||||
|
||||
### Fixed
|
||||
- Windows: `video.info.json` is read as UTF-8 (#4). Previously `Path.read_text()` defaulted to cp1252 on Windows and crashed on yt-dlp's UTF-8 output, silently dropping Title/Uploader from the report. Same fix applied to `.env` reads/writes in `whisper.py` and `setup.py`.
|
||||
- `download.py` now logs info.json parse failures to stderr instead of swallowing them.
|
||||
|
||||
### Security
|
||||
- Hardened subprocess argv against option injection (#2): inserted `--` before the URL in the yt-dlp argv, and tightened `is_url` to reject `-`-prefixed sources and require a non-empty netloc. Resolved video/audio paths to absolute via `Path.resolve()` before passing to `ffmpeg`/`ffprobe`, so a relative path starting with `-` can't be misinterpreted as a flag.
|
||||
|
||||
## [0.1.2] — 2026-04-24
|
||||
|
||||
### Fixed
|
||||
|
||||
+7
-3
@@ -18,8 +18,10 @@ VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi", ".flv", ".wmv"}
|
||||
|
||||
|
||||
def is_url(source: str) -> bool:
|
||||
if source.startswith("-"):
|
||||
return False
|
||||
parsed = urlparse(source)
|
||||
return parsed.scheme in ("http", "https")
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def resolve_local(path: str) -> dict:
|
||||
@@ -78,6 +80,7 @@ def download_url(url: str, out_dir: Path) -> dict:
|
||||
"--no-playlist",
|
||||
"--ignore-errors",
|
||||
"-o", output_template,
|
||||
"--",
|
||||
url,
|
||||
]
|
||||
|
||||
@@ -95,14 +98,15 @@ def download_url(url: str, out_dir: Path) -> dict:
|
||||
info: dict = {}
|
||||
if info_path.exists():
|
||||
try:
|
||||
raw = json.loads(info_path.read_text())
|
||||
raw = json.loads(info_path.read_text(encoding="utf-8"))
|
||||
info = {
|
||||
"title": raw.get("title"),
|
||||
"uploader": raw.get("uploader") or raw.get("channel"),
|
||||
"duration": raw.get("duration"),
|
||||
"url": raw.get("webpage_url") or url,
|
||||
}
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
print(f"[watch] info.json parse failed: {exc}", file=sys.stderr)
|
||||
info = {"url": url}
|
||||
|
||||
return {
|
||||
|
||||
+2
-2
@@ -66,7 +66,7 @@ def get_metadata(video_path: str) -> dict:
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
video_path,
|
||||
str(Path(video_path).resolve()),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -162,7 +162,7 @@ def extract(
|
||||
cmd += ["-to", f"{end_seconds:.3f}"]
|
||||
|
||||
cmd += [
|
||||
"-i", video_path,
|
||||
"-i", str(Path(video_path).resolve()),
|
||||
"-vf", f"fps={fps},scale={resolution}:-2",
|
||||
"-frames:v", str(max_frames),
|
||||
"-q:v", "4",
|
||||
|
||||
+5
-5
@@ -79,7 +79,7 @@ def _read_env_key(name: str) -> str | None:
|
||||
return None
|
||||
_check_file_permissions(CONFIG_FILE)
|
||||
try:
|
||||
for line in CONFIG_FILE.read_text().splitlines():
|
||||
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
@@ -113,7 +113,7 @@ def _scaffold_env() -> bool:
|
||||
if CONFIG_FILE.exists():
|
||||
return False
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(ENV_TEMPLATE)
|
||||
CONFIG_FILE.write_text(ENV_TEMPLATE, encoding="utf-8")
|
||||
try:
|
||||
CONFIG_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
@@ -130,15 +130,15 @@ def _write_setup_complete() -> None:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
existing = ""
|
||||
if CONFIG_FILE.exists():
|
||||
existing = CONFIG_FILE.read_text()
|
||||
existing = CONFIG_FILE.read_text(encoding="utf-8")
|
||||
for line in existing.splitlines():
|
||||
if line.strip().startswith("SETUP_COMPLETE="):
|
||||
return
|
||||
if existing and not existing.endswith("\n"):
|
||||
existing += "\n"
|
||||
CONFIG_FILE.write_text(existing + "SETUP_COMPLETE=true\n")
|
||||
CONFIG_FILE.write_text(existing + "SETUP_COMPLETE=true\n", encoding="utf-8")
|
||||
else:
|
||||
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n")
|
||||
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n", encoding="utf-8")
|
||||
try:
|
||||
CONFIG_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
|
||||
+3
-3
@@ -45,7 +45,7 @@ def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None,
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
for line in path.read_text().splitlines():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
@@ -93,13 +93,13 @@ def extract_audio(video_path: str, out_path: Path) -> Path:
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-y",
|
||||
"-i", video_path,
|
||||
"-i", str(Path(video_path).resolve()),
|
||||
"-vn",
|
||||
"-acodec", "libmp3lame",
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-b:a", "64k",
|
||||
str(out_path),
|
||||
str(out_path.resolve()),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
|
||||
Reference in New Issue
Block a user