diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf8d69e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + smoke: + name: smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install deps (macOS) + if: matrix.os == 'macos-latest' + run: | + brew update + brew install yt-dlp ffmpeg jq + + - name: Install deps (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y yt-dlp ffmpeg jq curl python3 + + - name: Verify required binaries + run: | + for b in yt-dlp ffmpeg ffprobe jq curl python3 bash; do + command -v "$b" >/dev/null 2>&1 || { echo "missing $b"; exit 1; } + done + + - name: Bash syntax check + run: | + bash -n bin/watch bin/dl-video bin/extract-frames bin/transcribe bin/audio-q bin/models + + - name: Make bins executable + run: chmod +x bin/* + + - name: Install symlinks + run: | + # Run install.sh in an isolated dir so it doesn't pull a fresh clone. + # We just want the symlink step to validate, but install.sh is geared + # to clone-and-link from GitHub. Skip it and replicate the symlink + # step inline against the checked-out tree. + mkdir -p "$HOME/.local/bin" + for bin in watch dl-video extract-frames transcribe audio-q models; do + ln -sf "$GITHUB_WORKSPACE/bin/$bin" "$HOME/.local/bin/$bin" + done + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Smoke — watch --help + run: watch --help + + - name: Smoke — transcribe --help + run: transcribe --help + + - name: Smoke — extract-frames --help + run: extract-frames --help + + - name: Smoke — dl-video --help + run: dl-video --help + + - name: Smoke — audio-q --help + run: audio-q --help + + - name: Smoke — models --help + run: models --help + + - name: Negative — watch with no args must exit 64 + run: | + set +e + watch + rc=$? + set -e + if [[ $rc -ne 64 ]]; then + echo "expected exit 64 from 'watch' with no args, got $rc" >&2 + exit 1 + fi + echo "OK: rc=$rc" + + - name: Version — watch --version must exit 0 and print a version string + run: | + out="$(watch --version)" + echo "$out" + [[ "$out" =~ watch-cli ]] || { echo "expected 'watch-cli' in version output" >&2; exit 1; } + + - name: Output schema test + run: bash tests/test-output-schema.sh diff --git a/README.md b/README.md index 9b40654..8d175a3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # watch-cli +![CI](https://github.com/sonpiaz/watch-cli/actions/workflows/ci.yml/badge.svg) + **Watch any social video → get an architecture diagram, working component, runnable notebook, or step-by-step cheat sheet — automatically.** Eyes and ears for your AI agent. watch-cli composes `yt-dlp` + `ffmpeg` + a Whisper-class ASR into a single command that hands an agent the raw materials to "watch" any video: VIDEO + FRAMES + TRANSCRIPT, ready for an LLM to read frames as images and transcript as text. diff --git a/bin/audio-q b/bin/audio-q index a260a00..776028d 100755 --- a/bin/audio-q +++ b/bin/audio-q @@ -8,8 +8,11 @@ # - Kyma mode (KYMA_API_KEY set): POST api.kymaapi.com/v1/audio/understand # Get a Kyma key at https://kymaapi.com. # - Direct mode (GOOGLE_AI_KEY set): POST Gemini API directly (BYO). +# +# Exit codes (see docs/exit-codes.md): +# 0 success · 1 general · 2 missing-dep · 4 transcribe fail · 64 usage error -set -euo pipefail +set -uo pipefail SELF="${BASH_SOURCE[0]}" SELF_DIR="$(cd "$(dirname "$SELF")" && pwd)" @@ -17,39 +20,67 @@ ROOT_DIR="$(cd "$SELF_DIR/.." && pwd)" # shellcheck source=../lib/env.sh source "$ROOT_DIR/lib/env.sh" -INPUT="${1:-}" -QUESTION="${2:-}" +INPUT="" +QUESTION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -V|--version) + echo "watch-cli v0.2.0" + exit 0 + ;; + *) + if [[ -z "$INPUT" ]]; then + INPUT="$1" + elif [[ -z "$QUESTION" ]]; then + QUESTION="$1" + fi + shift + ;; + esac +done -if [[ -z "$INPUT" || ! -f "$INPUT" || -z "$QUESTION" ]]; then - echo 'usage: audio-q ""' >&2 +if [[ -z "$INPUT" || -z "$QUESTION" ]]; then + echo 'usage: audio-q "" tag=usage-error' >&2 exit 64 fi -watch_cli_audio_mode_check "understand" || exit 1 - -if ! command -v ffmpeg >/dev/null 2>&1; then - echo "[audio-q] ffmpeg required. brew install ffmpeg / apt install ffmpeg" >&2 - exit 1 +if [[ ! -f "$INPUT" ]]; then + echo "[audio-q] error: file not found: $INPUT tag=usage-error" >&2 + exit 64 fi -if ! command -v jq >/dev/null 2>&1; then - echo "[audio-q] jq required. brew install jq / apt install jq" >&2 - exit 1 + +if ! watch_cli_audio_mode_check "understand"; then + echo "[audio-q] error: no usable audio backend tag=transcribe-other" >&2 + exit 4 fi +for dep in ffmpeg ffprobe jq curl base64; do + if ! command -v "$dep" >/dev/null 2>&1; then + echo "[audio-q] error: $dep not found on PATH tag=missing-dep:$dep" >&2 + exit 2 + fi +done + HASH="$(echo -n "$INPUT" | shasum | cut -c1-10)" AUDIO="/tmp/audioq_${HASH}.mp3" if [[ ! -s "$AUDIO" ]]; then - ffmpeg -hide_banner -loglevel error -y -i "$INPUT" \ - -vn -ac 1 -ar 16000 -b:a 48k -f mp3 "$AUDIO" 2>&1 >&2 || { - echo "[audio-q] ffmpeg audio extraction failed" >&2 - exit 1 - } + if ! ffmpeg -hide_banner -loglevel error -y -i "$INPUT" \ + -vn -ac 1 -ar 16000 -b:a 48k -f mp3 "$AUDIO" >&2 2>&1; then + echo "[audio-q] error: ffmpeg audio extraction failed tag=transcribe-other" >&2 + exit 4 + fi fi -SIZE=$(stat -f%z "$AUDIO" 2>/dev/null || stat -c%s "$AUDIO") +# POSIX size check: wc -c works on both macOS and Linux without a flag. +SIZE="$(wc -c < "$AUDIO" | tr -d ' ')" if (( SIZE > 19 * 1024 * 1024 )); then - echo "[audio-q] audio is $((SIZE / 1024 / 1024))MB — exceeds 20MB inline cap. Trim source first." >&2 - exit 1 + echo "[audio-q] error: audio is $((SIZE / 1024 / 1024))MB — exceeds 20MB inline cap. Trim source first. tag=transcribe-other" >&2 + exit 4 fi # Silence guard: multimodal LLMs hallucinate plausible-sounding scene @@ -60,9 +91,8 @@ MAX_DB="$(ffmpeg -i "$AUDIO" -af volumedetect -f null /dev/null 2>&1 | \ if [[ -n "$MAX_DB" ]]; then IS_SILENT="$(awk -v v="$MAX_DB" 'BEGIN { print (v < -60.0) ? 1 : 0 }')" if [[ "$IS_SILENT" == "1" ]]; then - echo "[audio-q] audio is silent (max_volume ${MAX_DB} dB). The video has no audible content to analyze." >&2 - echo "(silent audio: ${MAX_DB} dB)" - exit 0 + echo "[audio-q] error: audio is silent (max_volume ${MAX_DB} dB) tag=transcribe-silent-audio" >&2 + exit 4 fi fi @@ -74,27 +104,47 @@ case "$WATCH_AUDIO_MODE" in kyma) # Use the "audio-understand" alias rather than a concrete SKU. # Kyma can swap the underlying model without breaking watch-cli. - RESP="$(curl -sS -X POST \ + BODY="$(curl -sS -X POST \ + -w "%{http_code}" \ + -o /dev/stdout \ + --max-time 300 \ -H "Authorization: Bearer $KYMA_API_KEY" \ -H "User-Agent: $WATCH_CLI_USER_AGENT" \ -F "file=@$AUDIO;type=audio/mpeg" \ -F "model=audio-understand" \ -F "question=$QUESTION" \ -F "duration_sec=$DUR_SEC" \ - "$WATCH_KYMA_BASE/v1/audio/understand")" - - TEXT="$(echo "$RESP" | jq -r '.answer // empty')" - if [[ -z "$TEXT" ]]; then - ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" - echo "[audio-q] Kyma call failed: $ERR" >&2 - exit 1 - fi - echo "$TEXT" + "$WATCH_KYMA_BASE/v1/audio/understand" 2>/dev/null)" || { + echo "[audio-q] error: Kyma request failed tag=transcribe-other" >&2 + exit 4 + } + HTTP_CODE="${BODY: -3}" + RESP="${BODY:0:${#BODY}-3}" + case "$HTTP_CODE" in + 200) + TEXT="$(echo "$RESP" | jq -r '.answer // empty')" + if [[ -z "$TEXT" ]]; then + ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" + echo "[audio-q] error: Kyma call returned empty answer: $ERR tag=transcribe-other" >&2 + exit 4 + fi + echo "$TEXT" + ;; + 402|429) + echo "[audio-q] error: Kyma quota or rate limit (HTTP $HTTP_CODE) tag=transcribe-quota" >&2 + exit 4 ;; + 408|504) + echo "[audio-q] error: Kyma timeout (HTTP $HTTP_CODE) tag=transcribe-timeout" >&2 + exit 4 ;; + *) + echo "[audio-q] error: Kyma returned HTTP $HTTP_CODE tag=transcribe-other" >&2 + exit 4 ;; + esac ;; direct) B64="$(base64 < "$AUDIO" | tr -d '\n')" - BODY="$(jq -n \ + REQ_BODY="$(jq -n \ --arg q "$QUESTION" \ --arg data "$B64" \ '{ @@ -107,19 +157,39 @@ case "$WATCH_AUDIO_MODE" in generationConfig: { temperature: 0.3, maxOutputTokens: 2048 } }')" - RESP="$(curl -sS -X POST \ + BODY="$(curl -sS -X POST \ + -w "%{http_code}" \ + -o /dev/stdout \ + --max-time 300 \ -H "Content-Type: application/json" \ -H "x-goog-api-key: $GOOGLE_AI_KEY" \ -H "User-Agent: $WATCH_CLI_USER_AGENT" \ - -d "$BODY" \ - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent")" - - TEXT="$(echo "$RESP" | jq -r '.candidates[0].content.parts[0].text // empty')" - if [[ -z "$TEXT" ]]; then - ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" - echo "[audio-q] Gemini call failed: $ERR" >&2 - exit 1 - fi - echo "$TEXT" + -d "$REQ_BODY" \ + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" 2>/dev/null)" || { + echo "[audio-q] error: Gemini request failed tag=transcribe-other" >&2 + exit 4 + } + HTTP_CODE="${BODY: -3}" + RESP="${BODY:0:${#BODY}-3}" + case "$HTTP_CODE" in + 200) + TEXT="$(echo "$RESP" | jq -r '.candidates[0].content.parts[0].text // empty')" + if [[ -z "$TEXT" ]]; then + ERR="$(echo "$RESP" | jq -r '.error.message // "unknown error"')" + echo "[audio-q] error: Gemini call returned empty answer: $ERR tag=transcribe-other" >&2 + exit 4 + fi + echo "$TEXT" + ;; + 402|429) + echo "[audio-q] error: Gemini quota or rate limit (HTTP $HTTP_CODE) tag=transcribe-quota" >&2 + exit 4 ;; + 408|504) + echo "[audio-q] error: Gemini timeout (HTTP $HTTP_CODE) tag=transcribe-timeout" >&2 + exit 4 ;; + *) + echo "[audio-q] error: Gemini returned HTTP $HTTP_CODE tag=transcribe-other" >&2 + exit 4 ;; + esac ;; esac diff --git a/bin/dl-video b/bin/dl-video index d836640..ca33631 100755 --- a/bin/dl-video +++ b/bin/dl-video @@ -10,8 +10,11 @@ # Override via WATCH_BROWSER=chrome|firefox|safari|edge|brave|chromium. # 3. Manual cookies file via --cookies (Netscape format). # 4. Graceful error with guidance — no silent failure. +# +# Exit codes (see docs/exit-codes.md): +# 0 success · 2 missing-dep · 3 download fail · 64 usage error · 1 other -set -euo pipefail +set -uo pipefail URL="" OUTDIR="/tmp/dl-video" @@ -28,7 +31,11 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -V|--version) + echo "watch-cli v0.2.0" exit 0 ;; *) @@ -43,15 +50,13 @@ while [[ $# -gt 0 ]]; do done if [[ -z "$URL" ]]; then - echo "usage: dl-video [out-dir] [--cookies ]" >&2 + echo "usage: dl-video [out-dir] [--cookies ] tag=usage-error" >&2 exit 64 fi if ! command -v yt-dlp >/dev/null 2>&1; then - echo "[dl-video] yt-dlp not found. Install:" >&2 - echo " brew install yt-dlp # macOS" >&2 - echo " pipx install yt-dlp # Linux/Windows" >&2 - exit 1 + echo "[dl-video] error: yt-dlp not found on PATH. Install via 'brew install yt-dlp' or 'pipx install yt-dlp'. tag=missing-dep:yt-dlp" >&2 + exit 2 fi mkdir -p "$OUTDIR" @@ -81,6 +86,24 @@ run_dl() { "$URL" >&2 } +# Classify yt-dlp's last stderr log into one of the documented tags. +# Falls through to download-other when nothing matches. +classify_failure() { + local log="/tmp/dl-video.last.log" + [[ -f "$log" ]] || { echo "download-other"; return; } + local body + body="$(tr '[:upper:]' '[:lower:]' < "$log")" + if grep -qE 'sign in|login required|authentication|private|members[- ]only|http error 401|http error 403' <<< "$body"; then + echo "download-auth" + elif grep -qE 'geo[- ]restrict|not available in your country|region' <<< "$body"; then + echo "download-region" + elif grep -qE 'timed out|timeout|connection reset|temporary failure|network is unreachable|name or service not known|getaddrinfo|tls handshake' <<< "$body"; then + echo "download-network" + else + echo "download-other" + fi +} + # Tier 1: anonymous. Public videos succeed here. if run_dl 2>/tmp/dl-video.last.log; then if [[ -s "$OUT" ]]; then @@ -93,17 +116,18 @@ fi if [[ -n "$COOKIES_FILE" ]]; then echo "[dl-video] anonymous fetch failed, retrying with $COOKIES_FILE…" >&2 if [[ ! -f "$COOKIES_FILE" ]]; then - echo "[dl-video] cookies file not found: $COOKIES_FILE" >&2 - exit 1 + echo "[dl-video] error: cookies file not found: $COOKIES_FILE tag=usage-error" >&2 + exit 64 fi - if run_dl --cookies "$COOKIES_FILE"; then + if run_dl --cookies "$COOKIES_FILE" 2>/tmp/dl-video.last.log; then if [[ -s "$OUT" ]]; then echo "$OUT" exit 0 fi fi - echo "[dl-video] failed even with provided cookies" >&2 - exit 1 + TAG="$(classify_failure)" + echo "[dl-video] error: download failed for $URL even with provided cookies tag=$TAG" >&2 + exit 3 fi # Tier 2: auto-detect signed-in browser. @@ -126,9 +150,12 @@ for BROWSER in "${BROWSERS[@]}"; do fi done -# Tier 4: graceful error with actionable guidance. -cat >&2 <<'ERR' -[dl-video] Could not download — this video appears to require authentication. +# Tier 4: classify the final failure and emit a tag-tagged stderr line. +TAG="$(classify_failure)" +case "$TAG" in + download-auth) + cat >&2 < Three ways to fix: 1. Sign in to the platform in Chrome (or Firefox/Safari/Edge), then re-run. @@ -141,4 +168,15 @@ Three ways to fix: 3. Try a public URL — most YouTube, TikTok, and Reddit videos work without cookies. ERR -exit 1 + ;; + download-region) + echo "[dl-video] error: download failed for $URL tag=download-region — video is geo-restricted, try a VPN in the supported region" >&2 + ;; + download-network) + echo "[dl-video] error: download failed for $URL tag=download-network — transient network failure, wait and retry" >&2 + ;; + *) + echo "[dl-video] error: download failed for $URL tag=download-other — surface yt-dlp stderr above for details" >&2 + ;; +esac +exit 3 diff --git a/bin/extract-frames b/bin/extract-frames index 2163664..fbf80c1 100755 --- a/bin/extract-frames +++ b/bin/extract-frames @@ -1,36 +1,66 @@ #!/usr/bin/env bash # extract-frames