Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c333c2289e | |||
| 205af96c19 | |||
| 3c488688d1 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "watch",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Bradley Bonanno"
|
"name": "Bradley Bonanno"
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
All notable changes to `/watch` are documented here.
|
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
|
## [0.1.2] — 2026-04-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+7
-3
@@ -18,8 +18,10 @@ VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi", ".flv", ".wmv"}
|
|||||||
|
|
||||||
|
|
||||||
def is_url(source: str) -> bool:
|
def is_url(source: str) -> bool:
|
||||||
|
if source.startswith("-"):
|
||||||
|
return False
|
||||||
parsed = urlparse(source)
|
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:
|
def resolve_local(path: str) -> dict:
|
||||||
@@ -78,6 +80,7 @@ def download_url(url: str, out_dir: Path) -> dict:
|
|||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--ignore-errors",
|
"--ignore-errors",
|
||||||
"-o", output_template,
|
"-o", output_template,
|
||||||
|
"--",
|
||||||
url,
|
url,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -95,14 +98,15 @@ def download_url(url: str, out_dir: Path) -> dict:
|
|||||||
info: dict = {}
|
info: dict = {}
|
||||||
if info_path.exists():
|
if info_path.exists():
|
||||||
try:
|
try:
|
||||||
raw = json.loads(info_path.read_text())
|
raw = json.loads(info_path.read_text(encoding="utf-8"))
|
||||||
info = {
|
info = {
|
||||||
"title": raw.get("title"),
|
"title": raw.get("title"),
|
||||||
"uploader": raw.get("uploader") or raw.get("channel"),
|
"uploader": raw.get("uploader") or raw.get("channel"),
|
||||||
"duration": raw.get("duration"),
|
"duration": raw.get("duration"),
|
||||||
"url": raw.get("webpage_url") or url,
|
"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}
|
info = {"url": url}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-2
@@ -66,7 +66,7 @@ def get_metadata(video_path: str) -> dict:
|
|||||||
"-print_format", "json",
|
"-print_format", "json",
|
||||||
"-show_format",
|
"-show_format",
|
||||||
"-show_streams",
|
"-show_streams",
|
||||||
video_path,
|
str(Path(video_path).resolve()),
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -162,7 +162,7 @@ def extract(
|
|||||||
cmd += ["-to", f"{end_seconds:.3f}"]
|
cmd += ["-to", f"{end_seconds:.3f}"]
|
||||||
|
|
||||||
cmd += [
|
cmd += [
|
||||||
"-i", video_path,
|
"-i", str(Path(video_path).resolve()),
|
||||||
"-vf", f"fps={fps},scale={resolution}:-2",
|
"-vf", f"fps={fps},scale={resolution}:-2",
|
||||||
"-frames:v", str(max_frames),
|
"-frames:v", str(max_frames),
|
||||||
"-q:v", "4",
|
"-q:v", "4",
|
||||||
|
|||||||
+5
-5
@@ -79,7 +79,7 @@ def _read_env_key(name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
_check_file_permissions(CONFIG_FILE)
|
_check_file_permissions(CONFIG_FILE)
|
||||||
try:
|
try:
|
||||||
for line in CONFIG_FILE.read_text().splitlines():
|
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
@@ -113,7 +113,7 @@ def _scaffold_env() -> bool:
|
|||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
return False
|
return False
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
CONFIG_FILE.write_text(ENV_TEMPLATE)
|
CONFIG_FILE.write_text(ENV_TEMPLATE, encoding="utf-8")
|
||||||
try:
|
try:
|
||||||
CONFIG_FILE.chmod(0o600)
|
CONFIG_FILE.chmod(0o600)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -130,15 +130,15 @@ def _write_setup_complete() -> None:
|
|||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
existing = ""
|
existing = ""
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
existing = CONFIG_FILE.read_text()
|
existing = CONFIG_FILE.read_text(encoding="utf-8")
|
||||||
for line in existing.splitlines():
|
for line in existing.splitlines():
|
||||||
if line.strip().startswith("SETUP_COMPLETE="):
|
if line.strip().startswith("SETUP_COMPLETE="):
|
||||||
return
|
return
|
||||||
if existing and not existing.endswith("\n"):
|
if existing and not existing.endswith("\n"):
|
||||||
existing += "\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:
|
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:
|
try:
|
||||||
CONFIG_FILE.chmod(0o600)
|
CONFIG_FILE.chmod(0o600)
|
||||||
except OSError:
|
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():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
for line in path.read_text().splitlines():
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
@@ -93,13 +93,13 @@ def extract_audio(video_path: str, out_path: Path) -> Path:
|
|||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-y",
|
"-y",
|
||||||
"-i", video_path,
|
"-i", str(Path(video_path).resolve()),
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec", "libmp3lame",
|
"-acodec", "libmp3lame",
|
||||||
"-ar", "16000",
|
"-ar", "16000",
|
||||||
"-ac", "1",
|
"-ac", "1",
|
||||||
"-b:a", "64k",
|
"-b:a", "64k",
|
||||||
str(out_path),
|
str(out_path.resolve()),
|
||||||
]
|
]
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user