429f3143e5
Move the skill into skills/watch/ so SKILL.md and its scripts/ runtime are
siblings inside one folder. `npx skills add` (Codex/Cursor/Copilot/agents) now
copies a working skill as a unit; previously it grabbed the root SKILL.md alone
and left scripts/ behind, so the skill was dead on arrival on every non-Claude
host. Mirrors the layout last30days-skill adopted for the same reason.
- skills/watch/{SKILL.md,scripts/}: self-contained skill folder
- SKILL.md: resolve a harness-agnostic $SKILL_DIR (the dir it was Read from)
instead of the Claude-Code-only ${CLAUDE_SKILL_DIR}; guard + 19 call sites
- drop commands/watch.md: /watch derives from frontmatter (name + user-invocable)
- .codex-plugin/plugin.json: full manifest with "skills": "./skills/" + interface
- add .agents/plugins/marketplace.json, AGENTS.md, CLAUDE.md, .skillignore
- build-skill.sh: archive the skills/watch subtree (one SKILL.md, no zip -d)
- fix paths in tests, hooks hint, .gitattributes, release.yml
- relocate dev-sync.sh to repo root and fix REPO_ROOT
- README: content-ideas structure, npx skills install, star history
Verified: 37/37 tests pass; npx skills add bundles the full scripts/ runtime;
manifests valid; versions synced at 0.1.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
5.3 KiB
Python
Executable File
181 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Download a video via yt-dlp, or resolve a local file path.
|
|
|
|
Also fetches subtitles (manual first, then auto-generated) in VTT format so
|
|
transcribe.py can parse them without needing Whisper.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
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") and bool(parsed.netloc)
|
|
|
|
|
|
def resolve_local(path: str) -> dict:
|
|
p = Path(path).expanduser().resolve()
|
|
if not p.exists():
|
|
raise SystemExit(f"File not found: {p}")
|
|
if p.suffix.lower() not in VIDEO_EXTS:
|
|
print(
|
|
f"[watch] warning: {p.suffix} is not a known video extension, proceeding anyway",
|
|
file=sys.stderr,
|
|
)
|
|
return {
|
|
"video_path": str(p),
|
|
"subtitle_path": None,
|
|
"info": {"title": p.name, "url": str(p)},
|
|
"downloaded": False,
|
|
}
|
|
|
|
|
|
def _pick_subtitle(out_dir: Path) -> Path | None:
|
|
candidates = sorted(out_dir.glob("video*.vtt"))
|
|
if not candidates:
|
|
return None
|
|
preferred = [
|
|
c for c in candidates
|
|
if any(marker in c.name for marker in (".en.", ".en-US.", ".en-GB.", ".en-orig."))
|
|
]
|
|
return preferred[0] if preferred else candidates[0]
|
|
|
|
|
|
def _pick_video(out_dir: Path) -> Path | None:
|
|
for ext in (".mp4", ".mkv", ".webm", ".mov", ".m4a", ".mp3", ".opus"):
|
|
for candidate in out_dir.glob(f"video*{ext}"):
|
|
return candidate
|
|
for candidate in out_dir.glob("video.*"):
|
|
if candidate.suffix.lower() in VIDEO_EXTS:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def fetch_captions(url: str, out_dir: Path) -> dict:
|
|
"""Fetch metadata and best available VTT captions without downloading video."""
|
|
if shutil.which("yt-dlp") is None:
|
|
raise SystemExit("yt-dlp is not installed. Install with: brew install yt-dlp")
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
output_template = str(out_dir / "video.%(ext)s")
|
|
cmd = [
|
|
"yt-dlp",
|
|
"--skip-download",
|
|
"--write-info-json",
|
|
"--write-subs",
|
|
"--write-auto-subs",
|
|
"--sub-langs", "en.*",
|
|
"--sub-format", "vtt",
|
|
"--convert-subs", "vtt",
|
|
"--no-playlist",
|
|
"--ignore-errors",
|
|
"-o", output_template,
|
|
"--",
|
|
url,
|
|
]
|
|
subprocess.run(cmd, stdout=sys.stderr, stderr=sys.stderr)
|
|
subtitle = _pick_subtitle(out_dir)
|
|
info = _read_info(out_dir / "video.info.json", url)
|
|
return {
|
|
"video_path": None,
|
|
"subtitle_path": str(subtitle) if subtitle else None,
|
|
"info": info or {"url": url},
|
|
"downloaded": False,
|
|
}
|
|
|
|
|
|
def _read_info(info_path: Path, url: str) -> dict:
|
|
info: dict = {}
|
|
if info_path.exists():
|
|
try:
|
|
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 as exc:
|
|
print(f"[watch] info.json parse failed: {exc}", file=sys.stderr)
|
|
info = {"url": url}
|
|
return info
|
|
|
|
|
|
def download_url(
|
|
url: str,
|
|
out_dir: Path,
|
|
audio_only: bool = False,
|
|
) -> dict:
|
|
if shutil.which("yt-dlp") is None:
|
|
raise SystemExit("yt-dlp is not installed. Install with: brew install yt-dlp")
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
output_template = str(out_dir / "video.%(ext)s")
|
|
|
|
fmt = "ba/bestaudio" if audio_only else "bv*[height<=720]+ba/b[height<=720]/bv+ba/b"
|
|
cmd = [
|
|
"yt-dlp",
|
|
"-N", "8",
|
|
"-f", fmt,
|
|
"--merge-output-format", "mp4",
|
|
"--write-info-json",
|
|
"--write-subs",
|
|
"--write-auto-subs",
|
|
"--sub-langs", "en.*",
|
|
"--sub-format", "vtt",
|
|
"--convert-subs", "vtt",
|
|
"--no-playlist",
|
|
"--ignore-errors",
|
|
"-o", output_template,
|
|
"--",
|
|
url,
|
|
]
|
|
|
|
# yt-dlp may exit non-zero if a subtitle variant fails (e.g. 429) even when
|
|
# the video itself downloaded fine. Treat "video file present" as success.
|
|
result = subprocess.run(cmd, stdout=sys.stderr, stderr=sys.stderr)
|
|
video = _pick_video(out_dir)
|
|
if video is None:
|
|
raise SystemExit(
|
|
f"yt-dlp did not produce a video file in {out_dir} (exit {result.returncode})"
|
|
)
|
|
|
|
subtitle = _pick_subtitle(out_dir)
|
|
info = _read_info(out_dir / "video.info.json", url)
|
|
|
|
return {
|
|
"video_path": str(video),
|
|
"subtitle_path": str(subtitle) if subtitle else None,
|
|
"info": info or {"url": url},
|
|
"downloaded": True,
|
|
}
|
|
|
|
|
|
def download(
|
|
source: str,
|
|
out_dir: Path,
|
|
audio_only: bool = False,
|
|
) -> dict:
|
|
if is_url(source):
|
|
return download_url(source, out_dir, audio_only=audio_only)
|
|
return resolve_local(source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 3:
|
|
print("usage: download.py <url-or-path> <out-dir>", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
result = download(sys.argv[1], Path(sys.argv[2]))
|
|
print(json.dumps(result, indent=2))
|