Skill: patfitzner/stremio-unwatched (version 1.0.0)

--- name: stremio-unwatched description: > Check Stremio library for unwatched TV episodes, view upcoming release calendar, and download new episodes. Use when you need to: (1) find unwatched episodes in Stremio library, (2) see upcoming episode air dates, (3) download pending episodes via Stremio server or torrent clients, (4) sync Stremio episode calendar to Google Calendar. Connects to the Stremio central API and Cinemeta addon. Supports transmission, aria2, deluge, and qbittorrent for standalone downloads. metadata: { "openclaw": { "emoji": "📺", "os": ["linux", "darwin"], "requires": { "bins": ["curl", "jq"], "anyBins": ["node", "bun"] }, }, } --- # Stremio Unwatched Manage unwatched TV episodes from your Stremio library. ## Install Clone into the OpenClaw workspace skills directory: ```bash cd /path/to/openclaw-workspace/skills git clone https://github.com/pat-industries/stremio-unwatched.git ``` Dependencies: `curl`, `jq`, and `node` (or `bun`). Install any missing ones: ```bash # Debian/Ubuntu (Docker) apt-get update && apt-get install -y curl jq # node should already be present; verify: node --version ``` ## Authentication First-time use prompts for Stremio email/password. The auth key is cached in `~/.openclaw/credentials/stremio.json`. ```bash scripts/stremio_auth.sh # Login (interactive) scripts/stremio_auth.sh --check # Validate cached key scripts/stremio_auth.sh --logout # Clear credentials ``` ## List library ```bash scripts/stremio_library.sh # All series scripts/stremio_library.sh --filter "name" # Filter by name scripts/stremio_library.sh --id tt1234567 # Specific show ``` ## Find unwatched episodes The core feature. Fetches metadata from Cinemeta, decodes the watched bitfield, and identifies episodes that have aired but haven't been watched. ```bash scripts/stremio_unwatched.sh # Table of all unwatched scripts/stremio_unwatched.sh --filter "show" # Specific show scripts/stremio_unwatched.sh --season 2 # Specific season scripts/stremio_unwatched.sh --json # JSON output scripts/stremio_unwatched.sh --summary # Counts per show ``` An episode is "unwatched" if it has aired, is not flagged in the watched bitfield, and playback progress is under 70%. ## Upcoming calendar Shows upcoming episode air dates using Cinemeta's `calendar-videos` endpoint. ```bash scripts/stremio_calendar.sh # Next 30 days scripts/stremio_calendar.sh --days 7 # Next 7 days scripts/stremio_calendar.sh --filter "show" # Specific show scripts/stremio_calendar.sh --json # JSON output ``` ### Google Calendar sync Syncs upcoming episodes to a **dedicated "Stremio TV" Google Calendar** (never the default calendar). Requires the `gog` skill (gogcli). ```bash scripts/stremio_calendar.sh --gcal-sync # Sync upcoming to Google Calendar scripts/stremio_calendar.sh --gcal-clear # Remove all Stremio TV events scripts/stremio_calendar.sh --gcal-sync --days 14 # Sync next 2 weeks only ``` Events are created with purple color, deduplicated by summary, and include the Stremio video ID in the description. ## Download episodes Resolves streams via installed Stremio addons, then downloads. Priority: Stremio server (if running) > auto-detected torrent client > magnet link output. ```bash scripts/stremio_download.sh # All unwatched (interactive) scripts/stremio_download.sh --filter "show" # Specific show scripts/stremio_download.sh --limit 5 # Max 5 episodes scripts/stremio_download.sh --quality 1080p # Prefer 1080p scripts/stremio_download.sh --client transmission # Force client scripts/stremio_download.sh --dry-run # Preview only scripts/stremio_download.sh --magnets # Output magnet links ``` Supported torrent clients (auto-detected): `transmission-remote`, `aria2c`, `deluge-console`, `qbittorrent-nox`. ## Check download status ```bash scripts/stremio_status.sh # Server info scripts/stremio_status.sh --hash <infoHash> # Specific torrent progress scripts/stremio_status.sh --watch # Live refresh (5s) ``` ## API details For endpoint documentation, data structures, and the watched bitfield format, see `references/stremio_api.md`.

path: README.md
content: # stremio-unwatched

An [OpenClaw](https://openclaw.ai) skill that connects to [Stremio](https://www.stremio.com) to find unwatched TV episodes, view upcoming release calendars, and download new episodes.

## Features

- **Unwatched detection** — scans your Stremio library for series with new episodes you haven't watched yet (only episodes after your last watched, not old backlog)
- **Upcoming calendar** — shows episode air dates for the next N days using Cinemeta
- **Google Calendar sync** — pushes upcoming episodes to a dedicated "Stremio TV" Google Calendar (requires [gog](https://gogcli.sh))
- **Download** — resolves streams via your installed Stremio addons (Torrentio, TPB+, etc.) and downloads via Stremio server, transmission, aria2, deluge, or qbittorrent
- **Quality preference** — choose 1080p, 720p, 4K, or best-available
- **Dry-run by default** — always preview before downloading

## Requirements

- `curl`, `jq`
- `node` or `bun` (for watched bitfield decoding)
- A [Stremio](https://www.stremio.com) account with series in your library
- Optional: a torrent client (`transmission-remote`, `aria2c`, `deluge-console`, or `qbittorrent-nox`)
- Optional: [gog](https://gogcli.sh) for Google Calendar sync

## Install

Clone into your OpenClaw workspace skills directory:

```bash
cd /path/to/workspace/skills
git clone https://github.com/Pat-Industries/stremio-unwatched.git
```

On Debian/Ubuntu (or Docker), ensure dependencies are present:

```bash
apt-get update && apt-get install -y curl jq
```

## Usage

### Authentication

On first use, you'll be prompted for your Stremio email and password. The auth token is cached locally — your password is never stored.

```bash
scripts/stremio_auth.sh            # login (interactive)
scripts/stremio_auth.sh --check    # validate cached token
scripts/stremio_auth.sh --logout   # clear credentials
```

For non-interactive use (cron, Docker), set `STREMIO_EMAIL` and `STREMIO_PASSWORD` environment variables.

### Find unwatched episodes

```bash
scripts/stremio_unwatched.sh                      # table of all unwatched
scripts/stremio_unwatched.sh --filter "Fargo"     # specific show
scripts/stremio_unwatched.sh --season 2           # specific season
scripts/stremio_unwatched.sh --summary            # counts per show
scripts/stremio_unwatched.sh --json               # JSON output
```

Only episodes that have aired and come after your last watched episode are reported. The 70% watch threshold from Stremio is respected for in-progress episodes.

### Upcoming calendar

```bash
scripts/stremio_calendar.sh                       # next 30 days
scripts/stremio_calendar.sh --days 7              # next 7 days
scripts/stremio_calendar.sh --gcal-sync           # sync to Google Calendar
scripts/stremio_calendar.sh --gcal-clear          # clear synced events
```

Google Calendar sync creates a dedicated **"Stremio TV"** calendar — it never touches your default calendar. Requires [gog](https://gogcli.sh) to be installed and authenticated.

### Download episodes

```bash
scripts/stremio_download.sh                       # download all unwatched
scripts/stremio_download.sh --dry-run             # preview only
scripts/stremio_download.sh --filter "Silo"       # specific show
scripts/stremio_download.sh --limit 5             # cap at 5 episodes
scripts/stremio_download.sh --quality 1080p       # prefer 1080p
scripts/stremio_download.sh --magnets             # output magnet links only
```

Download priority: Stremio local server (if running) → auto-detected torrent client → magnet link output.

### Check download status

```bash
scripts/stremio_status.sh                         # server info
scripts/stremio_status.sh --hash <infoHash>       # specific torrent
scripts/stremio_status.sh --watch                 # live refresh
```

## How it works

1. Authenticates against the Stremio central API (`api.strem.io`)
2. Fetches your library via the datastore API
3. For each series, pulls full episode metadata from [Cinemeta](https://v3-cinemeta.strem.io)
4. Decodes Stremio's watched bitfield (zlib-compressed, base64-encoded bit array) to determine which episodes you've seen
5. Resolves streams by querying your installed Stremio addons
6. Downloads via the local streaming server or a standalone torrent client

## License

MIT


path: references/stremio_api.md
content: # Stremio API Reference

## Central API

Base URL: `https://api.strem.io/api`

All requests are POST with JSON body. Auth via `authKey` in body (not headers).

### Authentication

```bash
# Login
curl -X POST https://api.strem.io/api/login \
  -H "Content-Type: application/json" \
  -d '{"type":"Login","email":"...","password":"...","facebook":false}'
# Response: {"result":{"authKey":"...","user":{...}}}

# Validate
curl -X POST https://api.strem.io/api/getUser \
  -H "Content-Type: application/json" \
  -d '{"authKey":"..."}'

# Logout
curl -X POST https://api.strem.io/api/logout \
  -H "Content-Type: application/json" \
  -d '{"authKey":"..."}'
```

### Library (Datastore)

```bash
# Fetch all library items
curl -X POST https://api.strem.io/api/datastoreGet \
  -H "Content-Type: application/json" \
  -d '{"authKey":"...","collection":"libraryItem","all":true}'

# Fetch specific items
curl -X POST https://api.strem.io/api/datastoreGet \
  -H "Content-Type: application/json" \
  -d '{"authKey":"...","collection":"libraryItem","ids":["tt123"]}'

# Get metadata (IDs + timestamps only)
curl -X POST https://api.strem.io/api/datastoreMeta \
  -H "Content-Type: application/json" \
  -d '{"authKey":"...","collection":"libraryItem"}'
```

### LibraryItem structure

| Field | Description |
|-------|-------------|
| `_id` | IMDB ID (e.g. `tt1234567`) |
| `name` | Show name |
| `type` | `series`, `movie`, `other` |
| `removed` | User removed from library |
| `temp` | Auto-added during playback |
| `state.video_id` | Last watched video (e.g. `tt123:2:5` for S02E05) |
| `state.timeOffset` | Playback position (ms) |
| `state.duration` | Total duration (ms) |
| `state.timeWatched` | Time spent watching current video (ms) |
| `state.overallTimeWatched` | Total watch time across all videos (ms) |
| `state.timesWatched` | Times watched to completion |
| `state.lastWatched` | ISO 8601 timestamp |
| `state.watched` | Watched bitfield string |
| `state.flaggedWatched` | Movie watched flag |
| `state.noNotif` | Disable notifications |

### Watched threshold

An episode is "watched" when `timeWatched > duration * 0.7` (70%).

### Addon Collection

```bash
# Get installed addons
curl -X POST https://api.strem.io/api/addonCollectionGet \
  -H "Content-Type: application/json" \
  -d '{"authKey":"..."}'
```

## Cinemeta (Metadata Addon)

Base URL: `https://v3-cinemeta.strem.io`

### Series metadata

```bash
GET https://v3-cinemeta.strem.io/meta/series/{imdb_id}.json
```

Returns `MetaItem` with `videos[]` containing season, episode, released date.

### Calendar videos (upcoming episodes)

```bash
GET https://v3-cinemeta.strem.io/catalog/series/calendar-videos/calendarVideosIds={ids}.json
```

- Comma-separated IMDB IDs, max 100
- Returns `metasDetailed[]` with videos including **future** release dates
- Includes specials (season 0)

### Last videos (notifications)

```bash
GET https://v3-cinemeta.strem.io/catalog/series/last-videos/lastVideosIds={ids}.json
```

- Only past episodes, no specials, no future dates

## Addon Protocol (Streams)

```bash
# Get streams for a series episode
GET {addon_url}/stream/series/{imdb_id}:{season}:{episode}.json
# Response: {"streams":[{"infoHash":"...","fileIdx":0,"name":"...","description":"..."}]}
```

Stream source types:
- `infoHash` + `fileIdx` — BitTorrent
- `url` — Direct HTTP
- `ytId` — YouTube
- `externalUrl` — Opens in browser

## Local Streaming Server

Base URL: `http://127.0.0.1:11470`

```bash
# Check if running
GET /settings

# Create torrent download
POST /{infoHash}/create

# Get download stats
GET /{infoHash}/{fileIdx}/stats.json
# Response: {downloadSpeed, uploadSpeed, downloaded, peers, streamProgress, ...}
```

## Watched Bitfield Format

Serialized: `{anchorVideoId}:{anchorLength}:{base64_zlib_data}`

Parse by popping from the right (anchorVideoId contains colons).

Decode: Base64 -> zlib inflate -> byte array. Each bit is LSB-first: `(bytes[floor(i/8)] & (1 << (i%8))) != 0`. Video IDs sorted by (season ASC, episode ASC, released ASC).


path: scripts/bitfield_decode.mjs
content: #!/usr/bin/env node
// Decodes Stremio's watched bitfield format.
// Usage: node bitfield_decode.mjs <serialized_bitfield> <video_ids_json>
// Output: JSON object mapping video_id -> watched (boolean)
//
// The serialized format is: {anchorVideoId}:{anchorLength}:{base64_zlib_data}
// Since anchorVideoId contains colons (e.g. tt123:1:5), parse by popping from the right.

import { inflateSync } from "node:zlib";

function decodeWatchedBitfield(serialized, videoIds) {
  if (!serialized || !videoIds.length) {
    return Object.fromEntries(videoIds.map((id) => [id, false]));
  }

  const parts = serialized.split(":");
  if (parts.length < 3) {
    return Object.fromEntries(videoIds.map((id) => [id, false]));
  }

  const base64Data = parts.pop();
  const anchorLength = parseInt(parts.pop(), 10);
  const anchorVideoId = parts.join(":");

  let rawBytes;
  try {
    rawBytes = inflateSync(Buffer.from(base64Data, "base64"));
  } catch {
    return Object.fromEntries(videoIds.map((id) => [id, false]));
  }

  const anchorIdx = videoIds.indexOf(anchorVideoId);
  if (anchorIdx === -1) {
    return Object.fromEntries(videoIds.map((id) => [id, false]));
  }

  const offset = anchorLength - 1 - anchorIdx;

  function getBit(i) {
    if (i < 0 || i >= rawBytes.length * 8) return false;
    return (rawBytes[Math.floor(i / 8)] & (1 << (i % 8))) !== 0;
  }

  const result = {};
  for (let i = 0; i < videoIds.length; i++) {
    result[videoIds[i]] = getBit(i + offset);
  }
  return result;
}

// CLI interface
const [serialized, videoIdsJson] = process.argv.slice(2);
if (!serialized || !videoIdsJson) {
  console.error(
    "Usage: node bitfield_decode.mjs <serialized_bitfield> <video_ids_json>"
  );
  process.exit(1);
}

const videoIds = JSON.parse(videoIdsJson);
const result = decodeWatchedBitfield(serialized, videoIds);
console.log(JSON.stringify(result));


path: scripts/stremio_auth.sh
content: #!/usr/bin/env bash
# Stremio authentication: login, cache authKey, validate, logout.
# Usage:
#   stremio_auth.sh              # Interactive login (or use cached key)
#   stremio_auth.sh --logout     # Clear cached credentials
#   stremio_auth.sh --check      # Validate cached key, exit 0 if valid
#   stremio_auth.sh --key        # Print authKey to stdout (for piping)
set -euo pipefail

STREMIO_API="https://api.strem.io/api"
CRED_DIR="${OPENCLAW_CREDENTIALS_DIR:-${HOME}/.openclaw/credentials}"
CRED_FILE="${CRED_DIR}/stremio.json"

die() { echo "error: $*" >&2; exit 1; }

load_key() {
  if [[ -f "$CRED_FILE" ]]; then
    jq -r '.authKey // empty' "$CRED_FILE" 2>/dev/null
  fi
}

save_credentials() {
  local auth_key="$1" user_id="$2" email="$3"
  mkdir -p "$CRED_DIR"
  chmod 700 "$CRED_DIR"
  jq -n \
    --arg key "$auth_key" \
    --arg uid "$user_id" \
    --arg email "$email" \
    '{authKey: $key, userId: $uid, email: $email}' > "$CRED_FILE"
  chmod 600 "$CRED_FILE"
}

validate_key() {
  local key="$1"
  local resp
  resp=$(curl -sf -X POST "${STREMIO_API}/getUser" \
    -H "Content-Type: application/json" \
    -d "{\"authKey\":\"${key}\"}" 2>/dev/null) || return 1
  # Check for error in response
  if echo "$resp" | jq -e '.error' &>/dev/null; then
    return 1
  fi
  return 0
}

do_login() {
  local email password resp auth_key user_id
  # Support env vars for non-interactive use (e.g. from agent or cron)
  email="${STREMIO_EMAIL:-}"
  [REDACTED_SECRET]"
  if [[ -z "$email" || -z "$password" ]]; then
    echo "Stremio login" >&2
    [[ -z "$email" ]] && read -rp "Email: " email
    [[ -z "$password" ]] && read -rsp "Password: " password
    echo >&2
  fi

  resp=$(curl -sf -X POST "${STREMIO_API}/login" \
    -H "Content-Type: application/json" \
    -d "$(jq -n --arg e "$email" --arg p "$password" \
      '{type:"Login", email:$e, password:$p, facebook:false}')" \
  ) || die "API request failed"

  if echo "$resp" | jq -e '.error' &>/dev/null; then
    die "Login failed: $(echo "$resp" | jq -r '.error.message')"
  fi

  auth_key=$(echo "$resp" | jq -r '.result.authKey')
  user_id=$(echo "$resp" | jq -r '.result.user._id')
  [[ -n "$auth_key" && "$auth_key" != "null" ]] || die "No authKey in response"

  save_credentials "$auth_key" "$user_id" "$email"
  echo "Logged in as ${email}" >&2
  echo "$auth_key"
}

do_logout() {
  local key
  key=$(load_key)
  if [[ -n "$key" ]]; then
    curl -sf -X POST "${STREMIO_API}/logout" \
      -H "Content-Type: application/json" \
      -d "{\"authKey\":\"${key}\"}" &>/dev/null || true
  fi
  rm -f "$CRED_FILE"
  echo "Logged out" >&2
}

# Main
case "${1:-}" in
  --logout)
    do_logout
    ;;
  --check)
    key=$(load_key)
    [[ -n "$key" ]] || die "No cached credentials"
    validate_key "$key" || die "Cached key is invalid"
    echo "Valid ($(jq -r '.email // "unknown"' "$CRED_FILE"))" >&2
    ;;
  --key)
    key=$(load_key)
    if [[ -n "$key" ]] && validate_key "$key"; then
      echo "$key"
    else
      do_login
    fi
    ;;
  "")
    key=$(load_key)
    if [[ -n "$key" ]] && validate_key "$key"; then
      echo "Already logged in ($(jq -r '.email // "unknown"' "$CRED_FILE"))" >&2
      echo "$key"
    else
      do_login
    fi
    ;;
  *)
    die "Unknown option: $1"
    ;;
esac


path: scripts/stremio_calendar.sh
content: #!/usr/bin/env bash
# Stremio calendar: show upcoming episodes and optionally sync to Google Calendar.
# Usage:
#   stremio_calendar.sh                     # Show upcoming episodes (next 30 days)
#   stremio_calendar.sh --days 7            # Next 7 days
#   stremio_calendar.sh --filter "name"     # Filter by show name
#   stremio_calendar.sh --json              # JSON output
#   stremio_calendar.sh --gcal-sync         # Sync to Google Calendar (requires gog)
#   stremio_calendar.sh --gcal-clear        # Remove all events from Stremio TV calendar
# Google Calendar sync creates a dedicated "Stremio TV" calendar, never the default.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CINEMETA="https://v3-cinemeta.strem.io"
STREMIO_API="https://api.strem.io/api"
GCAL_NAME="Stremio TV"
# Purple/grape color for episode events
GCAL_EVENT_COLOR="3"

DAYS=30
FILTER_NAME=""
JSON_OUTPUT=false
GCAL_SYNC=false
GCAL_CLEAR=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --days) DAYS="$2"; shift 2 ;;
    --filter) FILTER_NAME="$2"; shift 2 ;;
    --json) JSON_OUTPUT=true; shift ;;
    --gcal-sync) GCAL_SYNC=true; shift ;;
    --gcal-clear) GCAL_CLEAR=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

# Fetch library series IDs
lib_args=()
[[ -n "$FILTER_NAME" ]] && lib_args+=(--filter "$FILTER_NAME")
library=$("${SCRIPT_DIR}/stremio_library.sh" "${lib_args[@]}")
count=$(echo "$library" | jq 'length')

if [[ "$count" -eq 0 ]]; then
  echo "No series in library" >&2
  echo "[]"
  exit 0
fi

# Collect IMDB IDs (Cinemeta calendar endpoint accepts up to 100)
ids=$(echo "$library" | jq -r '.[].id' | head -100 | paste -sd, -)

echo "Fetching calendar for ${count} series..." >&2

# Call the Cinemeta calendar-videos endpoint
calendar_resp=$(curl -sfL "${CINEMETA}/catalog/series/calendar-videos/calendarVideosIds=${ids}.json" 2>/dev/null) || {
  echo "error: failed to fetch calendar data" >&2
  exit 1
}

# Build a name lookup from library
name_lookup=$(echo "$library" | jq 'map({key: .id, value: .name}) | from_entries')

# Parse date range
now=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
if [[ "$(uname)" == "Darwin" ]]; then
  future=$(date -u -v+${DAYS}d +%Y-%m-%dT%H:%M:%S.000Z)
else
  future=$(date -u -d "+${DAYS} days" +%Y-%m-%dT%H:%M:%S.000Z)
fi

# Extract and filter upcoming episodes
upcoming=$(echo "$calendar_resp" | jq --arg now "$now" --arg future "$future" \
  --argjson names "$name_lookup" '
  [.metasDetailed // [] | .[] | .id as $show_id | .name as $show_name |
    (.videos // [])[] |
    select(.released != null and .released >= $now and .released <= $future) |
    {
      show: ($names[$show_id] // $show_name // $show_id),
      show_id: $show_id,
      season: (.season // 0),
      episode: (.episode // 0),
      title: (.title // .name // ""),
      video_id: .id,
      released: .released,
      thumbnail: (.thumbnail // "")
    }
  ] | sort_by(.released)')

total=$(echo "$upcoming" | jq 'length')
echo "${total} upcoming episode(s) in next ${DAYS} days" >&2

# Google Calendar sync
if $GCAL_SYNC || $GCAL_CLEAR; then
  if ! command -v gog &>/dev/null; then
    echo "error: gog (gogcli) is required for Google Calendar sync" >&2
    echo "Install: brew install steipete/tap/gogcli" >&2
    exit 1
  fi

  # Find or create the dedicated Stremio TV calendar
  echo "Looking for '${GCAL_NAME}' calendar..." >&2
  calendars=$(gog calendar calendars --json --no-input 2>/dev/null) || {
    echo "error: failed to list Google Calendars (is gog authenticated?)" >&2
    exit 1
  }

  cal_id=$(echo "$calendars" | jq -r \
    --arg name "$GCAL_NAME" \
    '.calendars[] | select(.summary == $name) | .id // empty' | head -1)

  if [[ -z "$cal_id" ]]; then
    if $GCAL_CLEAR; then
      echo "No '${GCAL_NAME}' calendar found, nothing to clear" >&2
      exit 0
    fi
    echo "Creating '${GCAL_NAME}' calendar..." >&2
    # Try gog calendar calendars create first
    create_resp=$(gog calendar calendars create --summary "$GCAL_NAME" --json --no-input 2>/dev/null) || {
      echo "error: could not create '${GCAL_NAME}' calendar via gog" >&2
      echo "Please create a calendar named '${GCAL_NAME}' manually in Google Calendar" >&2
      exit 1
    }
    cal_id=$(echo "$create_resp" | jq -r '.id // empty')
    [[ -n "$cal_id" ]] || {
      echo "error: calendar created but could not extract ID" >&2
      exit 1
    }
    echo "Created calendar: ${cal_id}" >&2
  fi

  if $GCAL_CLEAR; then
    echo "Clearing all events from '${GCAL_NAME}'..." >&2
    # List events in a wide window and delete them
    if [[ "$(uname)" == "Darwin" ]]; then
      from=$(date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
      to=$(date -u -v+365d +%Y-%m-%dT%H:%M:%SZ)
    else
      from=$(date -u -d "-30 days" +%Y-%m-%dT%H:%M:%SZ)
      to=$(date -u -d "+365 days" +%Y-%m-%dT%H:%M:%SZ)
    fi
    events=$(gog calendar events "$cal_id" --from "$from" --to "$to" --json --no-input 2>/dev/null) || events='{"events":[]}'
    event_ids=$(echo "$events" | jq -r '.events[].id // empty')
    del_count=0
    for eid in $event_ids; do
      gog calendar delete "$cal_id" "$eid" --no-input 2>/dev/null && ((del_count++)) || true
    done
    echo "Deleted ${del_count} event(s)" >&2
    exit 0
  fi

  # Sync upcoming episodes as events
  echo "Syncing ${total} episode(s) to '${GCAL_NAME}'..." >&2

  # Fetch existing events in the date range to avoid duplicates
  if [[ "$(uname)" == "Darwin" ]]; then
    from_gcal=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    to_gcal=$(date -u -v+${DAYS}d +%Y-%m-%dT%H:%M:%SZ)
  else
    from_gcal=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    to_gcal=$(date -u -d "+${DAYS} days" +%Y-%m-%dT%H:%M:%SZ)
  fi
  existing=$(gog calendar events "$cal_id" --from "$from_gcal" --to "$to_gcal" --json --no-input 2>/dev/null) || existing='{"events":[]}'
  existing_summaries=$(echo "$existing" | jq -r '[.events[].summary // ""] | .[]')

  synced=0
  skipped=0
  echo "$upcoming" | jq -c '.[]' | while IFS= read -r ep; do
    show=$(echo "$ep" | jq -r '.show')
    season=$(echo "$ep" | jq -r '.season')
    episode_num=$(echo "$ep" | jq -r '.episode')
    title=$(echo "$ep" | jq -r '.title')
    released=$(echo "$ep" | jq -r '.released')
    video_id=$(echo "$ep" | jq -r '.video_id')

    summary="${show} S$(printf '%02d' "$season")E$(printf '%02d' "$episode_num")"
    [[ -n "$title" && "$title" != "null" ]] && summary="${summary}: ${title}"

    # Skip if event already exists (by summary match)
    if echo "$existing_summaries" | grep -qF "$summary"; then
      ((skipped++)) || true
      continue
    fi

    # Parse released timestamp for event time (1-hour event at air time)
    event_start="$released"
    # Calculate end time (+1h) using date manipulation
    if [[ "$(uname)" == "Darwin" ]]; then
      start_epoch=$(date -jf "%Y-%m-%dT%H:%M:%S" "${released%%.*}" +%s 2>/dev/null || echo "")
      if [[ -n "$start_epoch" ]]; then
        end_epoch=$((start_epoch + 3600))
        event_end=$(date -u -r "$end_epoch" +%Y-%m-%dT%H:%M:%SZ)
      else
        event_end="$event_start"
      fi
    else
      event_end=$(date -u -d "${released} + 1 hour" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "$event_start")
    fi

    description="Stremio: ${video_id}"

    gog calendar create "$cal_id" \
      --summary "$summary" \
      --description "$description" \
      --from "$event_start" --to "$event_end" \
      --event-color "$GCAL_EVENT_COLOR" \
      --no-input 2>/dev/null && {
        ((synced++)) || true
        echo "  + ${summary}" >&2
      } || {
        echo "  ! Failed: ${summary}" >&2
      }
  done

  echo "Sync complete (created: ${synced}, skipped: ${skipped})" >&2
fi

# Output
if $JSON_OUTPUT; then
  echo "$upcoming"
else
  if [[ "$total" -eq 0 ]]; then
    echo "No upcoming episodes in the next ${DAYS} days"
    exit 0
  fi
  printf "%-12s  %-28s %3s %3s  %-28s\n" "DATE" "SHOW" "S" "E" "TITLE"
  printf "%-12s  %-28s %3s %3s  %-28s\n" "----" "----" "--" "--" "-----"
  echo "$upcoming" | jq -r '.[] |
    [(.released[:10]), .show[:28], (.season|tostring), (.episode|tostring), .title[:28]] | @tsv' |
    while IFS=$'\t' read -r date show s e title; do
      printf "%-12s  %-28s %3s %3s  %-28s\n" "$date" "$show" "$s" "$e" "$title"
    done
fi


path: scripts/stremio_download.sh
content: #!/usr/bin/env bash
# Download unwatched episodes via Stremio server or standalone torrent client.
# Usage:
#   stremio_download.sh                         # Download all unwatched (interactive)
#   stremio_download.sh --filter "show"         # Only specific show
#   stremio_download.sh --id tt1234567          # By IMDB ID
#   stremio_download.sh --season 2              # Only season 2
#   stremio_download.sh --limit 5               # Max 5 episodes
#   stremio_download.sh --quality 1080p         # Prefer quality (1080p|720p|any)
#   stremio_download.sh --client transmission   # Force torrent client
#   stremio_download.sh --dry-run               # Just show what would be downloaded
#   stremio_download.sh --magnets               # Output magnet links only
# Download priority: Stremio server > auto-detected torrent client > magnet output
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
STREMIO_API="https://api.strem.io/api"
STREMIO_SERVER="http://127.0.0.1:11470"

FILTER_NAME=""
FILTER_ID=""
FILTER_SEASON=""
LIMIT=0
QUALITY="any"
CLIENT=""
DRY_RUN=false
MAGNETS_ONLY=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --filter) FILTER_NAME="$2"; shift 2 ;;
    --id) FILTER_ID="$2"; shift 2 ;;
    --season) FILTER_SEASON="$2"; shift 2 ;;
    --limit) LIMIT="$2"; shift 2 ;;
    --quality) QUALITY="$2"; shift 2 ;;
    --client) CLIENT="$2"; shift 2 ;;
    --dry-run) DRY_RUN=true; shift ;;
    --magnets) MAGNETS_ONLY=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

# Detect download method
detect_stremio_server() {
  curl -sf "${STREMIO_SERVER}/settings" &>/dev/null
}

detect_torrent_client() {
  if [[ -n "$CLIENT" ]]; then
    command -v "$CLIENT" &>/dev/null && echo "$CLIENT" && return
    # Try common binary names
    case "$CLIENT" in
      transmission) command -v transmission-remote &>/dev/null && echo "transmission-remote" && return ;;
      aria2) command -v aria2c &>/dev/null && echo "aria2c" && return ;;
      deluge) command -v deluge-console &>/dev/null && echo "deluge-console" && return ;;
      qbittorrent) command -v qbittorrent-nox &>/dev/null && echo "qbittorrent-nox" && return ;;
    esac
    echo "error: torrent client '${CLIENT}' not found" >&2
    return 1
  fi
  # Auto-detect
  for cmd in transmission-remote aria2c deluge-console qbittorrent-nox; do
    if command -v "$cmd" &>/dev/null; then
      echo "$cmd"
      return
    fi
  done
  return 1
}

download_via_stremio() {
  local info_hash="$1" file_idx="${2:-0}"
  curl -sf -X POST "${STREMIO_SERVER}/${info_hash}/create" \
    -H "Content-Type: application/json" \
    -d '{}' &>/dev/null
  echo "  Queued in Stremio (${info_hash:0:12}...)" >&2
}

download_via_client() {
  local client="$1" magnet="$2"
  case "$client" in
    transmission-remote)
      transmission-remote -a "$magnet" 2>/dev/null
      echo "  Added to Transmission" >&2
      ;;
    aria2c)
      aria2c --dir="${DOWNLOAD_DIR:-$HOME/Downloads}" -d "$magnet" &>/dev/null &
      echo "  Started aria2c download" >&2
      ;;
    deluge-console)
      deluge-console "add $magnet" 2>/dev/null
      echo "  Added to Deluge" >&2
      ;;
    qbittorrent-nox)
      # qbittorrent-nox uses web API
      echo "  magnet: ${magnet}" >&2
      echo "  (Add manually to qBittorrent)" >&2
      ;;
    *)
      echo "  magnet: ${magnet}" >&2
      ;;
  esac
}

# Get auth key for addon fetching
AUTH_KEY=$("${SCRIPT_DIR}/stremio_auth.sh" --key) || exit 1

# Get unwatched episodes
unwatched_args=(--json)
[[ -n "$FILTER_NAME" ]] && unwatched_args+=(--filter "$FILTER_NAME")
[[ -n "$FILTER_ID" ]] && unwatched_args+=(--id "$FILTER_ID")
[[ -n "$FILTER_SEASON" ]] && unwatched_args+=(--season "$FILTER_SEASON")

unwatched=$("${SCRIPT_DIR}/stremio_unwatched.sh" "${unwatched_args[@]}")
total=$(echo "$unwatched" | jq 'length')

if [[ "$total" -eq 0 ]]; then
  echo "No unwatched episodes to download" >&2
  exit 0
fi

if [[ "$LIMIT" -gt 0 && "$total" -gt "$LIMIT" ]]; then
  echo "Limiting to ${LIMIT} of ${total} episodes" >&2
  unwatched=$(echo "$unwatched" | jq ".[0:${LIMIT}]")
  total=$LIMIT
fi

# Fetch user's installed addons (for stream resolution)
addons_resp=$(curl -sf -X POST "${STREMIO_API}/addonCollectionGet" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg key "$AUTH_KEY" '{authKey: $key}')" 2>/dev/null)

# Extract addon URLs that provide streams for series
# The API returns {result: {addons: [...]}}
stream_addons=$(echo "$addons_resp" | jq -r '
  [(.result.addons // .result // [])[] |
    select(
      (.manifest.resources // [] | map(if type == "string" then . else .name end) | index("stream")) and
      (.manifest.types // [] | index("series"))
    ) | .transportUrl
  ] | .[]' 2>/dev/null)

if [[ -z "$stream_addons" ]]; then
  echo "warning: no stream addons found, using default sources" >&2
fi

# Determine download method
use_stremio=false
torrent_client=""
if ! $MAGNETS_ONLY; then
  if detect_stremio_server; then
    use_stremio=true
    echo "Download method: Stremio streaming server" >&2
  else
    torrent_client=$(detect_torrent_client 2>/dev/null) || true
    if [[ -n "$torrent_client" ]]; then
      echo "Download method: ${torrent_client}" >&2
    else
      echo "Download method: magnet links (no torrent client found)" >&2
      MAGNETS_ONLY=true
    fi
  fi
fi

# Process each episode
downloaded=0
failed=0

while IFS= read -r ep; do
  show=$(echo "$ep" | jq -r '.show')
  video_id=$(echo "$ep" | jq -r '.video_id')
  season=$(echo "$ep" | jq -r '.season')
  episode_num=$(echo "$ep" | jq -r '.episode')
  title=$(echo "$ep" | jq -r '.title')

  label="${show} S$(printf '%02d' "$season")E$(printf '%02d' "$episode_num")"
  [[ -n "$title" && "$title" != "null" ]] && label="${label}: ${title}"

  echo "---" >&2
  echo "Resolving: ${label}" >&2

  # Query each stream addon
  best_stream=""
  best_hash=""
  best_url=""

  for addon_url in $stream_addons; do
    # Strip manifest.json to get base URL
    addon_base="${addon_url%/manifest.json}"
    addon_base="${addon_base%/}"
    streams=$(curl -sfL "${addon_base}/stream/series/${video_id}.json" 2>/dev/null) || continue
    stream_count=$(echo "$streams" | jq '[.streams // [] | .[]] | length')
    [[ "$stream_count" -eq 0 ]] && continue

    # Score and pick best stream based on quality preference
    selected=$(echo "$streams" | jq --arg q "$QUALITY" '
      [.streams // [] | .[] |
        # Compute quality score from name/description/title
        ((.name // "") + " " + (.description // "") + " " + (.title // "")) as $text |
        (if ($text | test("2160p|4k|4K|UHD"; "i")) then 4
         elif ($text | test("1080p|FHD"; "i")) then 3
         elif ($text | test("720p|HD"; "i")) then 2
         elif ($text | test("480p|SD"; "i")) then 1
         else 0 end) as $score |
        {
          infoHash: (.infoHash // ""),
          fileIdx: (.fileIdx // 0),
          url: (.url // ""),
          externalUrl: (.externalUrl // ""),
          name: (.name // ""),
          description: (.description // ""),
          score: $score
        }
      ]
      | (if $q == "1080p" then sort_by(- (if .score == 3 then 100 else .score end))
         elif $q == "720p" then sort_by(- (if .score == 2 then 100 else .score end))
         else sort_by(-.score) end)
      | first // empty')

    [[ -z "$selected" || "$selected" == "null" ]] && continue

    hash=$(echo "$selected" | jq -r '.infoHash // ""')
    url=$(echo "$selected" | jq -r '.url // ""')
    file_idx=$(echo "$selected" | jq -r '.fileIdx // 0')
    desc=$(echo "$selected" | jq -r '(.name // "") + " " + (.description // "")' | head -c 80)

    if [[ -n "$hash" && "$hash" != "null" ]]; then
      best_hash="$hash"
      best_stream="$desc"
      break
    elif [[ -n "$url" && "$url" != "null" ]]; then
      best_url="$url"
      best_stream="$desc"
      break
    fi
  done

  if [[ -z "$best_hash" && -z "$best_url" ]]; then
    echo "  No streams found" >&2
    ((failed++)) || true
    continue
  fi

  echo "  Stream: ${best_stream}" >&2

  if $DRY_RUN; then
    if [[ -n "$best_hash" ]]; then
      echo "  [dry-run] Would download: magnet:?xt=urn:btih:${best_hash}" >&2
    else
      echo "  [dry-run] Would download: ${best_url}" >&2
    fi
    ((downloaded++)) || true
    continue
  fi

  if $MAGNETS_ONLY; then
    if [[ -n "$best_hash" ]]; then
      echo "magnet:?xt=urn:btih:${best_hash}&dn=$(echo "$label" | sed 's/ /%20/g')"
    else
      echo "$best_url"
    fi
    ((downloaded++)) || true
    continue
  fi

  # Download
  if [[ -n "$best_hash" ]]; then
    if $use_stremio; then
      download_via_stremio "$best_hash" "${file_idx:-0}" && ((downloaded++)) || ((failed++))
    elif [[ -n "$torrent_client" ]]; then
      magnet="magnet:?xt=urn:btih:${best_hash}&dn=$(echo "$label" | sed 's/ /%20/g')"
      download_via_client "$torrent_client" "$magnet" && ((downloaded++)) || ((failed++))
    fi
  elif [[ -n "$best_url" ]]; then
    echo "  Direct URL: ${best_url}" >&2
    echo "  (Direct URL downloads not yet supported, use --magnets)" >&2
    ((failed++)) || true
  fi
done < <(echo "$unwatched" | jq -c '.[]')

echo "===" >&2
echo "Done: ${downloaded} downloaded, ${failed} failed" >&2


path: scripts/stremio_library.sh
content: #!/usr/bin/env bash
# Fetch Stremio library items (series by default).
# Usage:
#   stremio_library.sh                    # All series in library
#   stremio_library.sh --filter "name"    # Filter by show name (case-insensitive)
#   stremio_library.sh --id tt1234567     # Get specific show by IMDB ID
#   stremio_library.sh --type movie       # Get movies instead of series
#   stremio_library.sh --all              # All types, no filter
#   stremio_library.sh --raw              # Raw API output (no filtering)
# Output: JSON array to stdout
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
STREMIO_API="https://api.strem.io/api"

FILTER_NAME=""
FILTER_ID=""
FILTER_TYPE="series"
RAW=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --filter) FILTER_NAME="$2"; shift 2 ;;
    --id) FILTER_ID="$2"; shift 2 ;;
    --type) FILTER_TYPE="$2"; shift 2 ;;
    --all) FILTER_TYPE=""; shift ;;
    --raw) RAW=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

# Get auth key
AUTH_KEY=$("${SCRIPT_DIR}/stremio_auth.sh" --key) || exit 1

# Fetch all library items
resp=$(curl -sf -X POST "${STREMIO_API}/datastoreGet" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg key "$AUTH_KEY" \
    '{authKey: $key, collection: "libraryItem", all: true}')")

if echo "$resp" | jq -e '.error' &>/dev/null; then
  echo "error: $(echo "$resp" | jq -r '.error.message')" >&2
  exit 1
fi

items=$(echo "$resp" | jq '.result // []')

if $RAW; then
  echo "$items"
  exit 0
fi

# Apply filters
filtered=$(echo "$items" | jq --arg type "$FILTER_TYPE" \
  --arg name "$FILTER_NAME" --arg id "$FILTER_ID" '
  [.[] | select(
    (.removed | not) and
    (.temp | not) and
    (if $type != "" then .type == $type else true end) and
    (if $name != "" then (.name | ascii_downcase | contains($name | ascii_downcase)) else true end) and
    (if $id != "" then ._id == $id else true end)
  )]
  | sort_by(.name)
  | [.[] | {
    id: ._id,
    name: .name,
    type: .type,
    poster: .poster,
    last_watched: .state.lastWatched,
    video_id: .state.video_id,
    watched_bitfield: .state.watched,
    time_offset: .state.timeOffset,
    duration: .state.duration,
    times_watched: .state.timesWatched,
    overall_time_watched: .state.overallTimeWatched,
    no_notif: .state.noNotif
  }]')

echo "$filtered"


path: scripts/stremio_status.sh
content: #!/usr/bin/env bash
# Check download progress for active Stremio/torrent downloads.
# Usage:
#   stremio_status.sh                     # Show all active downloads
#   stremio_status.sh --hash <infoHash>   # Status of specific torrent
#   stremio_status.sh --json              # JSON output
#   stremio_status.sh --watch             # Refresh every 5s
set -euo pipefail

STREMIO_SERVER="http://127.0.0.1:11470"

INFO_HASH=""
JSON_OUTPUT=false
WATCH=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --hash) INFO_HASH="$2"; shift 2 ;;
    --json) JSON_OUTPUT=true; shift ;;
    --watch) WATCH=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

format_bytes() {
  local bytes=$1
  if (( bytes >= 1073741824 )); then
    echo "$(echo "scale=1; $bytes / 1073741824" | bc)G"
  elif (( bytes >= 1048576 )); then
    echo "$(echo "scale=1; $bytes / 1048576" | bc)M"
  elif (( bytes >= 1024 )); then
    echo "$(echo "scale=1; $bytes / 1024" | bc)K"
  else
    echo "${bytes}B"
  fi
}

check_stremio_server() {
  curl -sf "${STREMIO_SERVER}/settings" &>/dev/null
}

get_torrent_status() {
  local hash="$1" file_idx="${2:-0}"
  curl -sf "${STREMIO_SERVER}/${hash}/${file_idx}/stats.json" 2>/dev/null
}

show_status() {
  if ! check_stremio_server; then
    echo "Stremio streaming server not running on ${STREMIO_SERVER}" >&2

    # Try to show torrent client status
    if command -v transmission-remote &>/dev/null; then
      echo "--- Transmission ---" >&2
      transmission-remote -l 2>/dev/null || echo "Could not connect to Transmission" >&2
      return
    fi
    if command -v aria2c &>/dev/null; then
      echo "--- aria2 ---" >&2
      echo "(aria2 status requires aria2c RPC; check manually)" >&2
      return
    fi

    echo "No download status available" >&2
    return 1
  fi

  if [[ -n "$INFO_HASH" ]]; then
    stats=$(get_torrent_status "$INFO_HASH")
    if [[ -z "$stats" ]]; then
      echo "No stats for hash: ${INFO_HASH}" >&2
      return 1
    fi
    if $JSON_OUTPUT; then
      echo "$stats"
    else
      name=$(echo "$stats" | jq -r '.streamName // .name // "unknown"')
      progress=$(echo "$stats" | jq -r '.streamProgress // 0')
      progress_pct=$(echo "scale=1; $progress * 100" | bc)
      dl_speed=$(echo "$stats" | jq -r '.downloadSpeed // 0' | cut -d. -f1)
      ul_speed=$(echo "$stats" | jq -r '.uploadSpeed // 0' | cut -d. -f1)
      peers=$(echo "$stats" | jq -r '.peers // 0')
      downloaded=$(echo "$stats" | jq -r '.downloaded // 0')

      printf "%-50s\n" "$name"
      printf "  Progress: %s%%  Downloaded: %s  Peers: %s\n" \
        "$progress_pct" "$(format_bytes "$downloaded")" "$peers"
      printf "  DL: %s/s  UL: %s/s\n" \
        "$(format_bytes "$dl_speed")" "$(format_bytes "$ul_speed")"
    fi
  else
    echo "Stremio server is running" >&2
    echo "Use --hash <infoHash> to check specific torrent status" >&2
    # Show server info
    settings=$(curl -sf "${STREMIO_SERVER}/settings" 2>/dev/null)
    if [[ -n "$settings" ]]; then
      version=$(echo "$settings" | jq -r '.values.serverVersion // "unknown"')
      cache_size=$(echo "$settings" | jq -r '.values.cacheSize // 0')
      echo "  Server version: ${version}" >&2
      echo "  Cache size: $(format_bytes "$cache_size")" >&2
    fi
  fi
}

if $WATCH; then
  while true; do
    clear
    echo "=== Stremio Download Status ($(date +%H:%M:%S)) ==="
    show_status || true
    sleep 5
  done
else
  show_status
fi


path: scripts/stremio_unwatched.sh
content: #!/usr/bin/env bash
# Find unwatched episodes for series in Stremio library.
# Usage:
#   stremio_unwatched.sh                        # All unwatched across library
#   stremio_unwatched.sh --filter "show name"   # Specific show by name
#   stremio_unwatched.sh --id tt1234567         # Specific show by IMDB ID
#   stremio_unwatched.sh --season 2             # Only season 2
#   stremio_unwatched.sh --json                 # JSON output (default is table)
#   stremio_unwatched.sh --summary              # Just counts per show
# Output: table or JSON to stdout
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CINEMETA="https://v3-cinemeta.strem.io"
NODE_CMD="${NODE_CMD:-$(command -v bun 2>/dev/null || command -v node 2>/dev/null)}"

FILTER_NAME=""
FILTER_ID=""
FILTER_SEASON=""
JSON_OUTPUT=false
SUMMARY=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --filter) FILTER_NAME="$2"; shift 2 ;;
    --id) FILTER_ID="$2"; shift 2 ;;
    --season) FILTER_SEASON="$2"; shift 2 ;;
    --json) JSON_OUTPUT=true; shift ;;
    --summary) SUMMARY=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

[[ -n "$NODE_CMD" ]] || { echo "error: node or bun required for bitfield decoding" >&2; exit 1; }

# Build library args
lib_args=()
[[ -n "$FILTER_NAME" ]] && lib_args+=(--filter "$FILTER_NAME")
[[ -n "$FILTER_ID" ]] && lib_args+=(--id "$FILTER_ID")

# Fetch library
library=$("${SCRIPT_DIR}/stremio_library.sh" "${lib_args[@]}")
count=$(echo "$library" | jq 'length')

if [[ "$count" -eq 0 ]]; then
  echo "No series found in library" >&2
  echo "[]"
  exit 0
fi

all_unwatched="[]"

for i in $(seq 0 $((count - 1))); do
  item=$(echo "$library" | jq ".[$i]")
  show_id=$(echo "$item" | jq -r '.id')
  show_name=$(echo "$item" | jq -r '.name')
  bitfield=$(echo "$item" | jq -r '.watched_bitfield // ""')
  current_video=$(echo "$item" | jq -r '.video_id // ""')
  time_offset=$(echo "$item" | jq -r '.time_offset // 0')
  duration=$(echo "$item" | jq -r '.duration // 0')

  echo "Checking: ${show_name} (${show_id})..." >&2

  # Fetch full metadata from Cinemeta
  meta=$(curl -sfL "${CINEMETA}/meta/series/${show_id}.json" 2>/dev/null) || {
    echo "  warning: could not fetch meta for ${show_id}" >&2
    continue
  }

  # Extract and sort videos by season, episode, release date
  videos=$(echo "$meta" | jq '
    [.meta.videos // [] | .[]
      | select(.season and .episode and (.season | type) == "number" and (.episode | type) == "number")
      | {
          id: .id,
          title: (.title // .name // ""),
          season: .season,
          episode: .episode,
          released: .released,
          thumbnail: (.thumbnail // "")
        }
    ] | sort_by(.season, .episode, .released)')

  video_count=$(echo "$videos" | jq 'length')
  if [[ "$video_count" -eq 0 ]]; then
    echo "  no episodes found" >&2
    continue
  fi

  # Apply season filter
  if [[ -n "$FILTER_SEASON" ]]; then
    videos=$(echo "$videos" | jq --argjson s "$FILTER_SEASON" \
      '[.[] | select(.season == $s)]')
    video_count=$(echo "$videos" | jq 'length')
    if [[ "$video_count" -eq 0 ]]; then
      echo "  no episodes in season ${FILTER_SEASON}" >&2
      continue
    fi
  fi

  # Get sorted video IDs for bitfield decoding
  video_ids=$(echo "$videos" | jq '[.[].id]')

  # Decode watched bitfield
  watched="{}"
  if [[ -n "$bitfield" && "$bitfield" != "null" ]]; then
    # For bitfield decoding, we need ALL video IDs (not just filtered season)
    # since bitfield positions are based on the full sorted list
    all_videos=$(echo "$meta" | jq '
      [.meta.videos // [] | .[]
        | select(.season and .episode and (.season | type) == "number" and (.episode | type) == "number")
      ] | sort_by(.season, .episode, .released) | [.[].id]')
    watched=$("$NODE_CMD" "${SCRIPT_DIR}/bitfield_decode.mjs" "$bitfield" "$all_videos" 2>/dev/null) || watched="{}"
  fi

  now=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)

  # Find unwatched episodes (only those AFTER the last watched episode)
  show_unwatched=$(echo "$videos" | jq \
    --argjson watched "$watched" \
    --arg now "$now" \
    --arg current "$current_video" \
    --argjson offset "$time_offset" \
    --argjson dur "$duration" \
    --arg show "$show_name" \
    --arg show_id "$show_id" '

    # Find the index of the last watched/in-progress episode
    ([to_entries[] | select(
      ($watched[.value.id] // false) or .value.id == $current
    ) | .key] | if length > 0 then max else -1 end) as $last_idx |

    # Take only episodes after the last watched
    [.[$last_idx + 1:][] |
      select(.id) |
      (.id) as $vid |
      ($watched[$vid] // false) as $is_watched |
      (.released and (.released <= $now)) as $aired |
      ($vid == $current and $offset > 0 and $dur > 0) as $in_progress |
      (if $in_progress then (($offset / $dur) * 100 | floor) else 0 end) as $progress |
      select(
        $aired and
        ($is_watched | not) and
        (if $in_progress then $progress < 70 else true end)
      ) |
      {
        show: $show,
        show_id: $show_id,
        season: .season,
        episode: .episode,
        title: .title,
        video_id: .id,
        released: .released,
        status: (if $in_progress then "in_progress (\($progress)%)" else "unwatched" end)
      }
    ]')

  unwatched_count=$(echo "$show_unwatched" | jq 'length')
  echo "  ${unwatched_count} unwatched episode(s)" >&2

  all_unwatched=$(echo "$all_unwatched" | jq --argjson new "$show_unwatched" '. + $new')
done

total=$(echo "$all_unwatched" | jq 'length')
echo "---" >&2
echo "Total: ${total} unwatched episode(s)" >&2

if $SUMMARY; then
  echo "$all_unwatched" | jq '
    group_by(.show) | [.[] | {
      show: .[0].show,
      show_id: .[0].show_id,
      unwatched: length,
      seasons: ([.[].season] | unique)
    }]'
elif $JSON_OUTPUT; then
  echo "$all_unwatched"
else
  # Table output
  if [[ "$total" -eq 0 ]]; then
    echo "All caught up!"
    exit 0
  fi
  printf "%-30s %3s %3s  %-30s  %s\n" "SHOW" "S" "E" "TITLE" "STATUS"
  printf "%-30s %3s %3s  %-30s  %s\n" "----" "--" "--" "-----" "------"
  echo "$all_unwatched" | jq -r '.[] |
    [.show[:30], (.season|tostring), (.episode|tostring), .title[:30], .status] | @tsv' |
    while IFS=$'\t' read -r show s e title status; do
      printf "%-30s %3s %3s  %-30s  %s\n" "$show" "$s" "$e" "$title" "$status"
    done
fi


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)