Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to `/watch` are documented here.

## [Unreleased]

### Added
- `--cookies-from-browser BROWSER` flag on `watch.py` (forwarded to yt-dlp) for sources behind a login wall — Instagram Reels, private YouTube videos, some X posts. Accepts yt-dlp's full grammar (`chrome`, `firefox:default`, `safari`, etc.). yt-dlp reads the user's existing browser session transiently; no cookies are written to disk, logged, or sent anywhere except the URL's host. Off by default. `SKILL.md` updated with usage notes and a security disclosure; `Failure modes` now points at the flag as the documented retry path for login-required downloads.

## [0.1.3] — 2026-05-09

### Fixed
Expand Down
6 changes: 4 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Optional flags:
- `--out-dir DIR` — keep working files somewhere specific (default: an auto-generated tmp dir)
- `--whisper groq|openai` — force a specific Whisper backend (default: prefer Groq if both keys exist)
- `--no-whisper` — disable the Whisper fallback entirely (frames-only if no captions)
- `--cookies-from-browser BROWSER` — forward to yt-dlp. Use when a source is login-walled (Instagram Reels, private YouTube videos, some X posts). Accepts yt-dlp's full grammar: `BROWSER[+KEYRING][:PROFILE][::CONTAINER]`, e.g. `chrome`, `firefox:default`, `safari`. yt-dlp reads the existing browser session — see "Security & Permissions" below.

### Focusing on a section (higher frame rate)

Expand Down Expand Up @@ -139,7 +140,7 @@ Both keys live in `~/.config/watch/.env`. The script prefers Groq when both are
- **Setup preflight failed** → run `python3 "${CLAUDE_SKILL_DIR}/scripts/setup.py"` (auto-installs ffmpeg/yt-dlp via brew on macOS, scaffolds the `.env`). For API key, ask the user via `AskUserQuestion` and write it to `~/.config/watch/.env`.
- **No transcript available** → captions missing AND (no Whisper key OR Whisper API failed). Script prints a hint pointing to setup. Proceed frames-only and tell the user.
- **Long video warning printed** → acknowledge it in your answer. Offer to re-run focused on a specific section via `--start`/`--end` rather than a sparse full-video scan.
- **Download fails** → yt-dlp's error goes to stderr. If it's a login-required or region-locked video, tell the user plainly; do not keep retrying.
- **Download fails** → yt-dlp's error goes to stderr. If it's a region-locked video, tell the user plainly; do not keep retrying. If it's login-required (Instagram Reels, private YouTube, some X posts), retry once with `--cookies-from-browser <browser>` to reuse the user's existing browser session — ask which browser they're signed in to first; do not assume.
- **Whisper request fails** → the error is printed to stderr (likely: invalid key, rate limit, or 25 MB upload limit on a very long video). The report will say "none available" for transcript. You can retry with `--whisper openai` if Groq failed (or vice versa).

## Token efficiency
Expand All @@ -155,6 +156,7 @@ If you already watched a video this session and the user asks a follow-up, do **

**What this skill does:**
- Runs `yt-dlp` locally to download the video and pull native captions when the source supports them (public data; the request goes directly to whatever host the URL points at)
- When `--cookies-from-browser BROWSER` is passed, yt-dlp reads the user's existing session cookies for that browser and sends them along with the download request to the source platform. Cookies are read transiently by yt-dlp itself — they are never written to the working directory, never logged, never sent anywhere except the platform the URL points at. The user remains signed in to the platform afterwards; nothing about the browser session is changed.
- Runs `ffmpeg` / `ffprobe` locally to extract frames as JPEGs and, when Whisper is needed, a mono 16 kHz audio clip
- Sends the extracted audio clip to Groq's Whisper API (`api.groq.com/openai/v1/audio/transcriptions`) when `GROQ_API_KEY` is set (preferred — cheaper, faster)
- Sends the extracted audio clip to OpenAI's audio transcription API (`api.openai.com/v1/audio/transcriptions`) when `OPENAI_API_KEY` is set and Groq is not, or when `--whisper openai` is forced
Expand All @@ -163,7 +165,7 @@ If you already watched a video this session and the user asks a follow-up, do **

**What this skill does NOT do:**
- Does not upload the video itself to any API — only the extracted audio goes out, and only when native captions are missing AND Whisper is not disabled with `--no-whisper`
- Does not access any platform account (no login, no session cookies, no posting)
- Does not access any platform account on its own — login session cookies are only used when `--cookies-from-browser` is explicitly passed by the user, and even then yt-dlp only sends them to the URL's host. The skill never posts, comments, follows, or modifies the user's account.
- Does not share API keys between providers (Groq key only goes to `api.groq.com`, OpenAI key only goes to `api.openai.com`)
- Does not log, cache, or write API keys to stdout, stderr, or output files
- Does not persist anything outside the working directory and `~/.config/watch/.env` — clean up the working directory when you're done (Step 5)
Expand Down
22 changes: 15 additions & 7 deletions scripts/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def _pick_video(out_dir: Path) -> Path | None:
return None


def download_url(url: str, out_dir: Path) -> dict:
def download_url(url: str, out_dir: Path, cookies_from_browser: str | None = None) -> dict:
if shutil.which("yt-dlp") is None:
raise SystemExit("yt-dlp is not installed. Install with: brew install yt-dlp")

Expand All @@ -80,9 +80,16 @@ def download_url(url: str, out_dir: Path) -> dict:
"--no-playlist",
"--ignore-errors",
"-o", output_template,
"--",
url,
]
if cookies_from_browser:
# yt-dlp accepts BROWSER[+KEYRING][:PROFILE][::CONTAINER]; reject anything
# that looks like it could be another flag rather than a browser name.
if cookies_from_browser.startswith("-"):
raise SystemExit(
f"--cookies-from-browser value must be a browser name, got {cookies_from_browser!r}"
)
cmd.extend(["--cookies-from-browser", cookies_from_browser])
cmd.extend(["--", 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.
Expand Down Expand Up @@ -117,15 +124,16 @@ def download_url(url: str, out_dir: Path) -> dict:
}


def download(source: str, out_dir: Path) -> dict:
def download(source: str, out_dir: Path, cookies_from_browser: str | None = None) -> dict:
if is_url(source):
return download_url(source, out_dir)
return download_url(source, out_dir, cookies_from_browser=cookies_from_browser)
return resolve_local(source)


if __name__ == "__main__":
if len(sys.argv) < 3:
print("usage: download.py <url-or-path> <out-dir>", file=sys.stderr)
print("usage: download.py <url-or-path> <out-dir> [<cookies-from-browser>]", file=sys.stderr)
raise SystemExit(2)
result = download(sys.argv[1], Path(sys.argv[2]))
cookies = sys.argv[3] if len(sys.argv) > 3 else None
result = download(sys.argv[1], Path(sys.argv[2]), cookies_from_browser=cookies)
print(json.dumps(result, indent=2))
15 changes: 14 additions & 1 deletion scripts/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ def main() -> int:
default=None,
help="Force a specific Whisper backend. Default: prefer Groq, fall back to OpenAI.",
)
ap.add_argument(
"--cookies-from-browser",
type=str,
default=None,
metavar="BROWSER",
help=(
"Forwarded to yt-dlp. Use when a source is login-walled (Instagram, "
"private YouTube, some Twitter/X posts). Accepts the same value as "
"yt-dlp: BROWSER[+KEYRING][:PROFILE][::CONTAINER], e.g. 'chrome', "
"'firefox:default', 'safari'. yt-dlp will read your existing browser "
"session cookies — see SKILL.md for security notes."
),
)
args = ap.parse_args()

max_frames = min(args.max_frames, 100)
Expand All @@ -59,7 +72,7 @@ def main() -> int:
"[watch] downloading via yt-dlp…" if is_url(args.source) else "[watch] using local file…",
file=sys.stderr,
)
dl = download(args.source, work / "download")
dl = download(args.source, work / "download", cookies_from_browser=args.cookies_from_browser)
video_path = dl["video_path"]

meta = get_metadata(video_path)
Expand Down