Skill: graysonzeng/video-fetch-download (version 1.1.9)

--- name: video-fetch-download version: 1.1.9 description: Download videos and torrents to cloud storage. Requires python3.12, rclone (115-fork, installed to ~/.local/bin), yt-dlp, and aria2c. Install via scripts/install.sh (installs all deps to user directory, no sudo; may append ~/.local/bin to PATH in shell profile). Writes credentials to ~/.config/rclone/rclone.conf (115 Pan, chmod 600) and ~/.config/video-fetch/quark_cookie.txt (Quark cookie, chmod 600) — stored locally only, never transmitted to third parties. Supports 115 offline download (server-side, zero local bandwidth), Quark Pan offline download and share link transfers, and 1000+ video site downloads via yt-dlp. Torrent search via Knaben API + apibay. Also supports Google Drive, OneDrive, Dropbox, and any rclone-compatible storage. Keywords: 离线下载, 下载视频, 磁力链, 种子, 夸克转存, 夸克离线, 115网盘, yt-dlp, 搜索电影, 磁力搜索. --- # video-fetch-download **中文简介：** 视频与种子下载神器，支持：①按片名搜索磁力链并一键提交115离线下载（`magnet_search.py`，支持中文片名自动转换，基于Knaben API + apibay双源）；②磁力链/种子URL通过115网盘离线下载（服务器端，不占本机带宽）；③夸克网盘磁力/URL离线下载；④夸克网盘分享链接一键转存；⑤YouTube/Bilibili/Twitter等1000+网站视频通过yt-dlp下载并上传至网盘，支持Google Drive、OneDrive、Dropbox等40+云存储。一键安装脚本（含SHA256校验），115支持扫码登录，夸克需手动提取一次Cookie（有效期数月）。 > ⚠️ **夸克说明：** 夸克网盘未开放扫码登录API，首次使用需从浏览器手动复制Cookie，操作约1分钟，之后数月内无需重复。 --- Download videos and torrents directly to cloud storage. Five input modes: | Input | Tool | Destination | Bandwidth | |---|---|---|---| | Magnet / Torrent URL | 115 offline API | 115 Pan | Zero (server-side) | | Magnet / URL | quark_offline.py | Quark Pan | Zero (server-side) | | Quark share link | quark_save.py | Quark Pan | Zero (server-side) | | Video URL (YT/Bili/etc.) | yt-dlp + rclone | 115 Pan | Local download | | Local .torrent file | aria2c + rclone | 115 Pan | Local download | --- ## Installation Run the one-command installer. It detects your OS/arch and installs all dependencies: ```bash bash scripts/install.sh ``` Installs: `yt-dlp`, `rclone` (115-patched build), `aria2`, `python3.12`, `p115client` > All binaries are verified with SHA256 checksums before installation. --- ## Authentication ### 115 Pan — QR code login (recommended, TV mode) TV mode does not compete with your phone/browser sessions: ```bash python3 scripts/115_qrlogin.py # Scan the QR code with 115 App → confirm → done # Credentials are saved securely to rclone config (chmod 600) ``` Re-login when cookie expires: `python3 scripts/115_qrlogin.py` ### Quark Pan — Cookie (manual, one-time setup) > ⚠️ **Note:** Quark does not provide a public QR code login API. > Authentication requires manually extracting a browser Cookie. > The cookie is valid for several months and only needs to be set once. **Steps:** 1. Open https://pan.quark.cn in a browser and log in 2. Press F12 → Network tab → Refresh the page 3. Click any request → Request Headers → Copy the `cookie` field 4. Run the setup wizard: ```bash python3 scripts/quark_login.py # Paste your cookie when prompted # It is saved to ~/.config/video-fetch/quark_cookie.txt (chmod 600) ``` --- ## Usage ### Search & submit to 115 (v1.1.3) ```bash # Search only (Knaben API, auto-fallback to apibay) python3 scripts/magnet_search.py "电影名" # Search and auto-submit best result to 115 python3 scripts/magnet_search.py "电影名" --submit # Submit specific result (e.g. #2) python3 scripts/magnet_search.py "电影名" --submit --index 2 # Use apibay only python3 scripts/magnet_search.py "Movie Name" --source apibay # Merge results from both sources python3 scripts/magnet_search.py "Movie Name" --source both ``` Supports Chinese titles (auto-mapped to English for search). Powered by Knaben API (primary) + apibay/TPB (fallback). Use `--source both` to merge results from both sources. --- ### Magnet link / Torrent URL → 115 offline download ```bash python3.12 scripts/115_offline.py 'magnet:?xt=urn:btih:...' python3.12 scripts/115_offline.py 'https://example.com/file.torrent' python3.12 scripts/115_offline.py --list # check task status ``` ### Magnet link / URL → Quark offline download ```bash python3 scripts/quark_offline.py 'magnet:?xt=urn:btih:...' python3 scripts/quark_offline.py 'https://example.com/file.torrent' python3 scripts/quark_offline.py --list # check task status python3 scripts/quark_offline.py --login # show cookie setup instructions ``` ### Quark share link → transfer to Quark Pan ```bash python3 scripts/quark_save.py 'https://pan.quark.cn/s/xxxxxxxx' python3 scripts/quark_save.py 'https://pan.quark.cn/s/xxxxxxxx' '/MyFolder' python3 scripts/quark_save.py --list # list Quark Pan root ``` ### Video URL → download and upload to 115 ```bash VIDEOFETCH_REMOTE=115drive:云下载 bash scripts/video_fetch.sh 'https://youtube.com/watch?v=...' ``` ### Local .torrent file → aria2c download and upload ```bash bash scripts/video_fetch.sh /path/to/file.torrent 115drive:云下载 ``` --- ## Environment Variables | Variable | Default | Description | |---|---|---| | `VIDEOFETCH_REMOTE` | `115drive:云下载` | rclone remote:path for uploads | | `VIDEOFETCH_TMPDIR` | `/tmp/video-fetch` | local staging directory | --- ## Supported Sites All sites supported by yt-dlp: YouTube, Bilibili, Twitter/X, Instagram, TikTok, Vimeo, and 1000+ more. ```bash yt-dlp --list-extractors | grep -i <site> ``` --- ## Other Cloud Storage The skill uses rclone for uploads, which supports 40+ cloud storage providers. Any rclone-compatible remote works as a destination. ### Supported providers (examples) | Provider | rclone backend | Auth method | Notes | |---|---|---|---| | Google Drive | `drive` | OAuth (browser link) | 15 GB free | | Microsoft OneDrive | `onedrive` | OAuth (browser link) | Included with Microsoft 365 | | Dropbox | `dropbox` | OAuth (browser link) | 2 GB free | | PikPak | `pikpak` | OAuth (browser link) | Also supports server-side offline download | | pCloud | `pcloud` | OAuth (browser link) | 10 GB free, Europe-based | | Proton Drive | `protondrive` | Username + password + 2FA | Privacy-focused | | Mega | `mega` | Username + password | 20 GB free, end-to-end encrypted | | Backblaze B2 | `b2` | API key | Low-cost object storage | | Amazon S3 | `s3` | API key | And 20+ S3-compatible providers | | Any WebDAV | `webdav` | Username + password | Nextcloud, Owncloud, etc. | | SFTP | `sftp` | SSH key or password | Any SSH server | ### Setup (one-time) — Recommended method The easiest way to connect Google Drive (or any OAuth provider) to a headless server is to authorize on your **local machine** first, then copy the token to the server. **Step 1 — On your local machine**, install rclone: ```bash # macOS brew install rclone # Windows: download from https://rclone.org/install/ # Linux curl https://rclone.org/install.sh | sudo bash ``` **Step 2 — Run the authorization wizard locally:** ```bash rclone config # → n (new remote) # → name it: gdrive # → type: drive (Google Drive) # → leave client_id and client_secret blank (press Enter) # → scope: 1 (full access) # → browser will open automatically → log in → allow access # → y (use auto config) # → n (not a team drive, unless you need it) # → q (quit) ``` **Step 3 — Copy the token to your server.** On your local machine, find the token: ```bash cat ~/.config/rclone/rclone.conf # Find the [gdrive] section — copy the entire block ``` On your server, append it to rclone config: ```bash cat >> ~/.config/rclone/rclone.conf << 'EOF' [gdrive] type = drive token = {"access_token":"...","token_type":"Bearer",...} EOF ``` **Step 4 — Verify:** ```bash rclone lsd gdrive: ``` Once configured, use it as your download destination: ```bash VIDEOFETCH_REMOTE=gdrive:Videos bash scripts/video_fetch.sh 'https://youtube.com/watch?v=...' ``` ### Usage ```bash # Download YouTube video → upload to Google Drive VIDEOFETCH_REMOTE=gdrive:Videos bash scripts/video_fetch.sh 'https://youtube.com/watch?v=...' # Download to OneDrive VIDEOFETCH_REMOTE=onedrive:Downloads bash scripts/video_fetch.sh 'https://...' # Download to Dropbox VIDEOFETCH_REMOTE=dropbox:Videos bash scripts/video_fetch.sh 'https://...' ``` > **Note:** Downloads via yt-dlp use local bandwidth (file downloads to server first, then uploads to cloud). This differs from 115/Quark offline download which is server-side with zero local bandwidth. --- ## Re-authentication | Service | Command | |---|---| | 115 Pan | `python3 scripts/115_qrlogin.py` | | Quark Pan | `python3 scripts/quark_login.py` | --- ## Security ### Binary installation - `yt-dlp` is downloaded from the official [yt-dlp/yt-dlp](https://github.com/yt-dlp/yt-dlp) repository and verified against the official `SHA2-256SUMS` file before installation. Installation is aborted if verification fails. - `rclone` 115-fork is downloaded from [gaoyb7/rclone-release](https://github.com/gaoyb7/rclone-release) (a fork with 115 Pan backend support) with pinned SHA256 checksums for all supported architectures. Installation is aborted if verification fails. - The rclone 115-fork is installed to `~/.local/bin/rclone` (user directory only). It does **not** replace or modify any system-level rclone installation. ### Credential handling - 115 Pan credentials are obtained via QR code scan (TV mode) and written directly to the rclone config file (`chmod 600`). They are **never** passed as command-line arguments and are not visible in the process list (`ps aux`). - Quark Pan cookie is manually extracted by the user from their own browser and stored at `~/.config/video-fetch/quark_cookie.txt` (`chmod 600`). - All credentials are stored locally only and are **never** transmitted to any third party. They are only used to authenticate with the respective cloud service APIs (115 Pan, Quark Pan). ### Other - `video_fetch.sh` does not use `--js-runtimes node` (no arbitrary JS execution via yt-dlp) - Torrent search uses public APIs only (Knaben, apibay) — no credentials required --- ## Verified - 115 offline download (magnet → 115 server) ✅ - 115 rclone direct access ✅ - Quark offline download (magnet → Quark server) ✅ - Quark share transfer ✅ - yt-dlp + rclone upload pipeline ✅ - aria2c torrent download ✅ - One-command installer (with SHA256 verification) ✅ - Dual search source: Knaben + apibay ✅ - Chinese title auto-mapping ✅

path: references/rclone-setup.md
content: # rclone 115网盘配置指南

## 首次授权

运行登录脚本，用 115 App 扫码（TV模式）：

```bash
python3 scripts/115_qrlogin.py
```

扫码后凭证自动写入 rclone config，remote 名称为 `115drive`。

**TV模式说明：** 不占用手机/网页的登录名额，不会挤掉其他端，长期稳定。

## 验证连接

```bash
rclone lsd 115drive:        # 列出根目录
rclone lsd 115drive:云下载  # 列出云下载目录
```

## 重新授权

Cookie 失效时（通常数月后），重新运行：

```bash
python3 scripts/115_qrlogin.py
```

## 自定义上传目录

```bash
# 上传到指定目录
export VIDEOFETCH_REMOTE="115drive:我的影片"

# 或在命令行指定
bash scripts/video_fetch.sh <url> 115drive:电影
```

## rclone 115改版说明

标准 rclone 不支持115网盘，本 skill 使用 [gaoyb7/rclone-release](https://github.com/gaoyb7/rclone-release) 改版。
install.sh 会自动安装正确版本。

## 115网盘限制

- 最多同时下载 5 个文件（`--transfers=5`）
- 单文件最多 2 线程（`--multi-thread-streams=2`）
- 离线下载任务由115服务器执行，本机无需下载


path: scripts/115_offline.py
content: #!/usr/bin/env python3.12
"""
115网盘离线下载 - 使用 p115client
用法:
  python3.12 115_offline.py <url_or_magnet>        # 添加离线任务
  python3.12 115_offline.py <url> <folder_cid>     # 添加到指定目录
  python3.12 115_offline.py --list                 # 查看任务列表
"""
import sys
import os
import subprocess
import configparser
from p115client import P115Client


def get_rclone_config_path():
    """动态获取 rclone 配置文件路径，兼容所有用户和平台"""
    try:
        result = subprocess.run(
            ["rclone", "config", "file"],
            capture_output=True, text=True, check=True
        )
        # 输出格式: "Configuration file is stored at:\n/path/to/rclone.conf"
        for line in result.stdout.splitlines():
            line = line.strip()
            if line.endswith(".conf") or line.endswith(".config"):
                return line
        # fallback: 最后一行非空
        lines = [l.strip() for l in result.stdout.splitlines() if l.strip()]
        return lines[-1] if lines else os.path.expanduser("~/.config/rclone/rclone.conf")
    except Exception:
        return os.path.expanduser("~/.config/rclone/rclone.conf")


def get_client():
    config_path = get_rclone_config_path()
    cfg = configparser.ConfigParser()
    cfg.read(config_path)
    if '115drive' not in cfg:
        raise Exception(
            "rclone 115drive 未配置，请先运行:\n"
            "  python3 scripts/115_qrlogin.py"
        )
    c = cfg['115drive']
    cookie = f"UID={c['uid']}; CID={c['cid']}; SEID={c['seid']}; KID={c['kid']}"
    return P115Client(cookie)


def offline_add(url, save_dir_id="0"):
    client = get_client()
    result = client.offline_add_url(url, pid=save_dir_id if save_dir_id != "0" else None)
    data = result.get('data', result)
    if result.get('state'):
        print(f"[115离线] 添加成功 ✓")
        print(f"  名称: {data.get('name', url[:60])}")
        print(f"  Hash: {data.get('info_hash', '-')}")
    else:
        print(f"[115离线] 添加失败: {result.get('error_msg', result)}")
        sys.exit(1)


def offline_list():
    client = get_client()
    result = client.offline_list()
    tasks = result.get('tasks', [])
    status_map = {0: '等待', 1: '下载中', 2: '完成', -1: '失败'}
    print(f"[115离线] 共 {len(tasks)} 个任务:")
    for t in tasks[:15]:
        status = status_map.get(t.get('status', 0), '未知')
        name = t.get('name', '')[:45]
        pct = t.get('percentDone', 0)
        print(f"  [{status}] {name} - {pct}%")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法: python3.12 115_offline.py <url_or_magnet>")
        print("      python3.12 115_offline.py --list")
        sys.exit(1)
    if sys.argv[1] == "--list":
        offline_list()
    else:
        url = sys.argv[1]
        cid = sys.argv[2] if len(sys.argv) > 2 else "0"
        offline_add(url, cid)


path: scripts/115_qrlogin.py
content: #!/usr/bin/env python3
"""
115网盘 QR码登录脚本
登录类型: tv (不占手机/网页名额，最稳定)
用法: python3 115_qrlogin.py
"""
import requests
import time
import subprocess
import sys
import os

HEADERS = {
    "User-Agent": "Mozilla/5.0 (SmartTV; Linux) AppleWebKit/537.36",
}

def qr_login():
    # Security note:
    # - Credentials are written directly to rclone config file (chmod 600)
    # - Credentials are NEVER passed as command-line arguments (not visible in process list)
    # - Credentials are stored locally only and never transmitted to third parties
    # - Only 115 Pan API endpoints are contacted
    s = requests.Session()
    s.headers.update(HEADERS)

    # 1. 获取二维码 token
    print("[1/3] 获取二维码...", flush=True)
    r = s.get("https://qrcodeapi.115.com/api/1.0/tv/1.0/token",
              params={"_": int(time.time()*1000)})
    r.raise_for_status()
    data = r.json()
    if not data.get("state"):
        print("获取二维码失败:", data)
        sys.exit(1)

    token = data["data"]
    uid   = token["uid"]
    sign  = token["sign"]
    ts    = token["time"]
    qr_url = f"https://qrcodeapi.115.com/api/1.0/tv/1.0/qrcode?uid={uid}"

    print(f"[2/3] 请用 115 App 扫码登录 (TV模式，不影响其他端):\n")
    print(f"  二维码图片: {qr_url}\n")
    print("等待扫码...", flush=True)

    # 2. 轮询状态
    for _ in range(120):  # 最多等2分钟
        time.sleep(2)
        r = s.get("https://qrcodeapi.115.com/get/status/",
                  params={"uid": uid, "time": ts, "sign": sign, "_": int(time.time()*1000)})
        r.raise_for_status()
        status = r.json().get("data", {})
        code = status.get("status", 0)

        if code == 0:
            print("  等待扫码...", flush=True)
        elif code == 1:
            print("  已扫码，请在App上确认授权...", flush=True)
        elif code == 2:
            print("  已授权！获取凭证...", flush=True)
            break
        elif code == -1:
            print("二维码已过期，请重新运行脚本")
            sys.exit(1)
        elif code == -2:
            print("已取消")
            sys.exit(1)
    else:
        print("超时")
        sys.exit(1)

    # 3. 获取 Cookie
    r = s.post("https://passportapi.115.com/app/1.0/tv/1.0/login/qrcode/",
               data={"account": uid, "app": "tv"})
    r.raise_for_status()
    result = r.json()

    if not result.get("state"):
        print("获取凭证失败:", result)
        sys.exit(1)

    cookie_data = result.get("data", {}).get("cookie", {})
    uid_val  = cookie_data.get("UID", "")
    cid_val  = cookie_data.get("CID", "")
    seid_val = cookie_data.get("SEID", "")
    kid_val  = cookie_data.get("KID", "")

    if not uid_val or not cid_val or not seid_val:
        print("凭证不完整，请重试")
        sys.exit(1)

    # 4. 写入 rclone config（通过环境变量+临时文件传递凭证，避免凭证出现在进程列表）
    print("[3/3] 写入 rclone 配置...", flush=True)

    # 获取 rclone 配置文件路径
    # 优先使用 skill 安装的 115-fork (~/.local/bin/rclone)，fallback 到 PATH 里的 rclone
    _rclone_candidates = [
        os.path.expanduser("~/.local/bin/rclone"),
        "rclone",
    ]
    _rclone_cmd = None
    for _candidate in _rclone_candidates:
        try:
            _test = subprocess.run([_candidate, "--version"], capture_output=True)
            if _test.returncode == 0:
                _rclone_cmd = _candidate
                break
        except FileNotFoundError:
            continue

    cfg_path = ""
    if _rclone_cmd:
        try:
            cfg_result = subprocess.run([_rclone_cmd, "config", "file"],
                                        capture_output=True, text=True)
            for line in cfg_result.stdout.splitlines():
                line = line.strip()
                if line.endswith(".conf") or line.endswith(".config"):
                    cfg_path = line
                    break
            if not cfg_path:
                lines = [l.strip() for l in cfg_result.stdout.splitlines() if l.strip()]
                cfg_path = lines[-1] if lines else ""
        except Exception:
            pass
    if not cfg_path:
        cfg_path = os.path.expanduser("~/.config/rclone/rclone.conf")

    # 直接写入 rclone.conf，凭证不经过命令行参数（ps aux 不可见）
    import configparser as _cp
    cfg = _cp.ConfigParser()
    try:
        cfg.read(cfg_path)
    except Exception:
        pass
    cfg["115drive"] = {
        "type": "115",
        "uid": uid_val,
        "cid": cid_val,
        "seid": seid_val,
        "kid": kid_val,
        "pacer_min_sleep": "500ms",
    }
    os.makedirs(os.path.dirname(cfg_path), exist_ok=True)
    with open(cfg_path, "w") as f:
        cfg.write(f)
    os.chmod(cfg_path, 0o600)  # 仅当前用户可读
    print(f"  配置文件: {cfg_path}")

    print("\n✓ 115网盘授权完成，配置已写入 rclone (remote: 115drive)")
    print("  验证: rclone lsd 115drive:")

if __name__ == "__main__":
    qr_login()


path: scripts/install.sh
content: #!/usr/bin/env bash
# video-fetch skill installer
# - All binaries are SHA256-verified before installation
# - ALL binaries (yt-dlp and rclone 115-fork) are installed to ~/.local/bin (user-only)
# - Does NOT install anything to /usr/local/bin or any system-wide path
# - MAY append ~/.local/bin to PATH in shell profile files (~/.bashrc, ~/.profile) if not already present
# - No credentials are collected during installation; auth is handled separately

# User-local install directory (no root/sudo required)
INSTALL_DIR="${HOME}/.local/bin"
mkdir -p "${INSTALL_DIR}"
[[ ":${PATH}:" != *":${INSTALL_DIR}:"* ]] && export PATH="${INSTALL_DIR}:${PATH}"

set -euo pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

ok()  { echo -e "${GREEN}[✓]${NC} $*"; }
info(){ echo -e "${YELLOW}[→]${NC} $*"; }
fail(){ echo -e "${RED}[✗]${NC} $*"; exit 1; }

echo ''
echo '══════════════════════════════════════'
echo '  video-fetch skill installer'
echo '══════════════════════════════════════'
echo ''
info "This installer will:"
info "  1. Install yt-dlp to ~/.local/bin (from github.com/yt-dlp/yt-dlp, SHA256 verified)"
info "  2. Install rclone 115-fork to ~/.local/bin (from github.com/gaoyb7/rclone-release, SHA256 verified)"
info "     All binaries install to user directory only (~/.local/bin) — no system paths modified"
info "  3. Install aria2 via system package manager"
info "  4. Install python3.12 + p115client via pip"
echo ''

# ── 1. yt-dlp ────────────────────────────────────────────────────
if command -v yt-dlp &>/dev/null; then
  ok "yt-dlp already installed ($(yt-dlp --version))"
else
  info "Installing yt-dlp ..."
  YTDLP_TMP=$(mktemp)
  curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
    -o "${YTDLP_TMP}"
  if [[ ! -s "${YTDLP_TMP}" ]]; then
    rm -f "${YTDLP_TMP}"
    fail "yt-dlp download failed or file is empty"
  fi
  # Verify SHA256 against official checksum file (mandatory)
  YTDLP_SHA_TMP=$(mktemp)
  curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/SHA2-256SUMS \
    -o "${YTDLP_SHA_TMP}"
  if [[ ! -s "${YTDLP_SHA_TMP}" ]]; then
    rm -f "${YTDLP_TMP}" "${YTDLP_SHA_TMP}"
    fail "Cannot fetch yt-dlp checksum file — refusing to install (check network)"
  fi
  EXPECTED=$(grep ' yt-dlp$' "${YTDLP_SHA_TMP}" | awk '{print $1}')
  ACTUAL=$(sha256sum "${YTDLP_TMP}" | awk '{print $1}')
  if [[ -z "$EXPECTED" || "$EXPECTED" != "$ACTUAL" ]]; then
    rm -f "${YTDLP_TMP}" "${YTDLP_SHA_TMP}"
    fail "yt-dlp SHA256 verification failed — refusing to install"
  fi
  ok "yt-dlp SHA256 verified"
  rm -f "${YTDLP_SHA_TMP}"
  mv "${YTDLP_TMP}" "${INSTALL_DIR}/yt-dlp" && chmod +x "${INSTALL_DIR}/yt-dlp"
  ok "yt-dlp installed to ${INSTALL_DIR}/yt-dlp ($(yt-dlp --version))"
fi

# ── 2. rclone 115-fork (user directory only) ─────────────────────
# Installed to INSTALL_DIR (~/.local/bin) — does NOT touch any system path
RCLONE_INSTALL_DIR="${INSTALL_DIR}"

NEED_RCLONE=true
if [[ -f "${RCLONE_INSTALL_DIR}/rclone" ]] && "${RCLONE_INSTALL_DIR}/rclone" help backends 2>&1 | grep -q '115'; then
  ok "rclone 115-fork already installed ($(${RCLONE_INSTALL_DIR}/rclone version | head -1))"
  NEED_RCLONE=false
fi

if $NEED_RCLONE; then
  info "Installing rclone 115-fork to ${RCLONE_INSTALL_DIR}/rclone ..."
  info "Source: https://github.com/gaoyb7/rclone-release (115 Pan support fork)"
  ARCH=$(uname -m)
  case $ARCH in
    x86_64)  RCLONE_FILE="rclone_v1_20250119_Linux_x86_64.tar.gz" ;;
    aarch64) RCLONE_FILE="rclone_v1_20250119_Linux_arm64.tar.gz" ;;
    armv7*)  RCLONE_FILE="rclone_v1_20250119_Linux_armv7.tar.gz" ;;
    armv6*)  RCLONE_FILE="rclone_v1_20250119_Linux_armv6.tar.gz" ;;
    *)       fail "Unsupported architecture: $ARCH" ;;
  esac

  # SHA256 values verified from gaoyb7/rclone-release v1_20250119
  declare -A RCLONE_SHA256=(
    [rclone_v1_20250119_Linux_x86_64.tar.gz]="88419bdc269da9e8a9c02617de3afcc83197288164131bb3eec16c6e6fb88bba"
    [rclone_v1_20250119_Linux_arm64.tar.gz]="b348552be45ec74da1a9924b836ec02964203d07da8fbb7add10a953fda45839"
    [rclone_v1_20250119_Linux_armv7.tar.gz]="d41c849093c5362989755e606d156f6d6a30a0a17f853a8c411abb16878d0700"
    [rclone_v1_20250119_Linux_armv6.tar.gz]="bd07fd47e26bd8ce20d0963bfa4daa671bcc81413b1149168fd7ceffc6c970be"
  )

  RCLONE_TMP=$(mktemp -d)
  curl -fsSL "https://github.com/gaoyb7/rclone-release/releases/download/v1_20250119/${RCLONE_FILE}" \
    -o "${RCLONE_TMP}/rclone.tar.gz"

  if [[ ! -s "${RCLONE_TMP}/rclone.tar.gz" ]]; then
    rm -rf "${RCLONE_TMP}"
    fail "rclone download failed or file is empty"
  fi

  # SHA256 verification (mandatory for all architectures)
  EXPECTED_SHA="${RCLONE_SHA256[$RCLONE_FILE]:-}"
  if [[ -z "$EXPECTED_SHA" ]]; then
    rm -rf "${RCLONE_TMP}"
    fail "No pinned SHA256 for architecture $ARCH — refusing to install"
  fi
  ACTUAL_SHA=$(sha256sum "${RCLONE_TMP}/rclone.tar.gz" | awk '{print $1}')
  if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then
    rm -rf "${RCLONE_TMP}"
    fail "rclone SHA256 verification failed — refusing to install (expected: ${EXPECTED_SHA}, got: ${ACTUAL_SHA})"
  fi
  ok "rclone SHA256 verified"

  tar xzf "${RCLONE_TMP}/rclone.tar.gz" -C "${RCLONE_TMP}/"
  RCLONE_BIN=$(find "${RCLONE_TMP}" -type f -name 'rclone' | head -1)
  [[ -z "$RCLONE_BIN" ]] && fail "Failed to extract rclone binary"
  mv "$RCLONE_BIN" "${RCLONE_INSTALL_DIR}/rclone" && chmod +x "${RCLONE_INSTALL_DIR}/rclone"
  rm -rf "${RCLONE_TMP}"
  ok "rclone 115-fork installed to ${RCLONE_INSTALL_DIR}/rclone"

  # Add to shell profile if not already present
  PROFILE_LINE="export PATH=\"\${HOME}/.local/bin:\${PATH}\""
  for PROFILE in "${HOME}/.bashrc" "${HOME}/.bash_profile" "${HOME}/.profile"; do
    if [[ -f "$PROFILE" ]] && ! grep -q '\.local/bin' "$PROFILE"; then
      echo "" >> "$PROFILE"
      echo "# Added by video-fetch installer" >> "$PROFILE"
      echo "$PROFILE_LINE" >> "$PROFILE"
      info "Added ~/.local/bin to PATH in $PROFILE"
      break
    fi
  done
fi

# ── 3. aria2 ─────────────────────────────────────────────────────
if command -v aria2c &>/dev/null; then
  ok "aria2 already installed"
else
  info "Installing aria2 ..."
  if command -v yum &>/dev/null; then
    yum install -y aria2 &>/dev/null
  elif command -v apt-get &>/dev/null; then
    apt-get install -y aria2 &>/dev/null
  else
    fail "Cannot install aria2 — please install it manually and re-run"
  fi
  ok "aria2 installed"
fi

# ── 4. Python 3.12 + p115client ──────────────────────────────────
if python3.12 -c 'import p115client' &>/dev/null 2>&1; then
  ok "p115client already installed"
else
  info "Installing Python 3.12 + p115client ..."
  if ! command -v python3.12 &>/dev/null; then
    if command -v yum &>/dev/null; then
      yum install -y python3.12 &>/dev/null
    elif command -v apt-get &>/dev/null; then
      apt-get install -y python3.12 &>/dev/null
    else
      fail "Cannot install Python 3.12 — please install it manually and re-run"
    fi
  fi
  python3.12 -m ensurepip 2>/dev/null || true
  python3.12 -m pip install -q p115client
  ok "p115client installed"
fi

# ── 5. 115 Pan auth ──────────────────────────────────────────────
echo ''
echo '══════════════════════════════════════'
ok "All dependencies installed"
echo '══════════════════════════════════════'
echo ''

RCLONE_CMD="${RCLONE_INSTALL_DIR}/rclone"
if "${RCLONE_CMD}" listremotes 2>/dev/null | grep -q '115drive'; then
  ok "115 Pan already authorized — skipping login"
else
  info "Starting 115 Pan authorization (TV mode QR scan — does not affect phone/browser sessions)..."
  echo ''
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd)"
  export PATH="${INSTALL_DIR}:${PATH}"
  python3.12 "${SCRIPT_DIR}/115_qrlogin.py"
fi

echo ''
echo '══════════════════════════════════════'
echo '  Installation complete!'
echo ''
echo '  # Offline download via magnet'
echo '  python3.12 scripts/115_offline.py "magnet:?xt=..."'
echo ''
echo '  # Check download tasks'
echo '  python3.12 scripts/115_offline.py --list'
echo ''
echo '  # Download video URL and upload'
echo '  VIDEOFETCH_REMOTE=115drive:云下载 bash scripts/video_fetch.sh <url>'
echo ''
echo "  Note: rclone installed to ${RCLONE_INSTALL_DIR}/rclone"
echo "  Add to PATH permanently: export PATH=\"\${HOME}/.local/bin:\${PATH}\""
echo '══════════════════════════════════════'
echo ''


path: scripts/magnet_search.py
content: #!/usr/bin/env python3
"""
magnet_search.py - Search torrents via Knaben API (primary) + apibay (fallback/secondary)
version: 1.1.0
"""

import sys
import json
import argparse
import subprocess
from pathlib import Path
from typing import Optional

try:
    import urllib.request
    import urllib.parse
except ImportError:
    print("Error: urllib not available")
    sys.exit(1)

# Common Chinese title to English mapping
TITLE_MAP = {
    '唐探1900': 'Detective Chinatown 1900',
    '唐人街探案1900': 'Detective Chinatown 1900',
    '唐人街探案': 'Detective Chinatown',
    '哪吒2': 'Nezha 2 2025',
    '哪吒之魔童闹海': 'Nezha 2 2025',
    '哪吒': 'Ne Zha',
    '流浪地球': 'The Wandering Earth',
    '流浪地球2': 'The Wandering Earth 2',
    '满江红': 'Full River Red',
    '封神': 'Creation of the Gods',
    '长安三万里': 'Chang An',
    '消失的她': 'Lost in the Stars',
    '孤注一掷': 'No More Bets',
    '三体': 'Three Body Problem',
    '美国队长4': 'Captain America Brave New World',
    '勇敢新世界': 'Captain America Brave New World',
}

KNABEN_API = 'https://api.knaben.org/v1'
APIBAY_API = 'https://apibay.org/q.php'


def search_knaben(query: str, size: int = 10, page: int = 0) -> list:
    """Search Knaben API for torrents."""
    payload = json.dumps({
        'search_type': '70%',
        'search_field': 'title',
        'query': query,
        'from': page * size,
        'size': size,
        'orderBy': 'seeders',
        'orderDirection': 'desc'
    }).encode('utf-8')

    req = urllib.request.Request(
        KNABEN_API,
        data=payload,
        headers={
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'User-Agent': 'Mozilla/5.0'
        },
        method='POST'
    )

    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read().decode('utf-8'))
    except Exception as e:
        print(f'[Knaben] 请求失败: {e}')
        return []

    hits = data.get('hits', [])
    results = []
    for h in hits:
        if not h.get('hash'):
            continue
        results.append({
            'name': h.get('title', ''),
            'size_mb': round(h.get('bytes', 0) / 1024 / 1024),
            'seeders': h.get('seeders', 0),
            'hash': h.get('hash', ''),
            'source': 'Knaben',
            'magnet': h.get('magnetUrl') or
                      f"magnet:?xt=urn:btih:{h['hash']}&dn={urllib.parse.quote(h.get('title', ''))}"
        })
    return results


def search_apibay(query: str, size: int = 10) -> list:
    """Search apibay (The Pirate Bay) API for torrents."""
    url = APIBAY_API + '?q=' + urllib.parse.quote(query) + '&cat=0'

    req = urllib.request.Request(
        url,
        headers={'User-Agent': 'Mozilla/5.0'},
        method='GET'
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read().decode('utf-8'))
    except Exception as e:
        print(f'[apibay] 请求失败: {e}')
        return []

    results = []
    for t in data[:size]:
        if t.get('id') == '0':
            continue
        h = t.get('info_hash', '')
        name = t.get('name', '')
        results.append({
            'name': name,
            'size_mb': round(int(t.get('size', 0)) / 1024 / 1024),
            'seeders': int(t.get('seeders', 0)),
            'hash': h,
            'source': 'apibay',
            'magnet': f"magnet:?xt=urn:btih:{h}&dn={urllib.parse.quote(name)}"
        })
    return results


def search(query: str, size: int = 10, page: int = 0, source: str = 'auto') -> list:
    """Search torrents. source: auto | knaben | apibay | both"""
    search_query = TITLE_MAP.get(query, query)
    if search_query != query:
        print(f'[搜索] "{query}" → "{search_query}"')
    else:
        print(f'[搜索] "{search_query}"')

    if source == 'apibay':
        results = search_apibay(search_query, size)
        print(f'[apibay] 找到 {len(results)} 个结果')
        return results

    if source == 'both':
        r1 = search_knaben(search_query, size, page)
        r2 = search_apibay(search_query, size)
        print(f'[Knaben] {len(r1)} 个 | [apibay] {len(r2)} 个')
        # merge, deduplicate by hash
        seen = set()
        merged = []
        for r in r1 + r2:
            h = r['hash'].upper()
            if h not in seen:
                seen.add(h)
                merged.append(r)
        merged.sort(key=lambda r: r['seeders'], reverse=True)
        return merged[:size]

    # auto: try Knaben first, fallback to apibay
    results = search_knaben(search_query, size, page)
    if not results:
        print('[Knaben] 无结果，尝试 apibay...')
        results = search_apibay(search_query, size)
        if results:
            print(f'[apibay] 找到 {len(results)} 个结果')
    return results


def submit_to_115(magnet: str) -> bool:
    """Submit magnet link to 115 offline download."""
    script = Path(__file__).parent / '115_offline.py'
    if not script.exists():
        print(f'[错误] 找不到 115_offline.py: {script}')
        return False
    for py in ['python3.12', 'python3']:
        result = subprocess.run(
            [py, str(script), magnet],
            capture_output=True, text=True
        )
        if result.returncode == 0:
            print(result.stdout.strip())
            return True
        if 'No such file' not in result.stderr and 'not found' not in result.stderr:
            print(result.stdout.strip())
            if result.returncode != 0:
                print(result.stderr.strip())
            return result.returncode == 0
    return False


def print_results(results: list):
    if not results:
        print('[结果] 未找到相关资源')
        return
    print(f'[结果] 找到 {len(results)} 个资源:\n')
    for i, r in enumerate(results, 1):
        size = f"{r['size_mb']}MB" if r['size_mb'] < 1024 else f"{r['size_mb']/1024:.1f}GB"
        src = f"[{r.get('source','?')}]" 
        print(f"  {i}. [{r['seeders']}s] {r['name'][:60]} {src}")
        print(f"     大小: {size} | Hash: {r['hash'][:16]}...")


def best(results: list) -> Optional[dict]:
    """Pick the best result: prefer 1080p, most seeders."""
    if not results:
        return None
    hd = [r for r in results if '1080p' in r['name'] or '1080P' in r['name']]
    pool = hd if hd else results
    return max(pool, key=lambda r: r['seeders'])


def main():
    parser = argparse.ArgumentParser(
        description='magnet_search v1.1.0 - Search & submit to 115',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  python3 scripts/magnet_search.py "Movie Name"                    # 搜索（Knaben优先）
  python3 scripts/magnet_search.py "电影名" --submit               # 自动提交最佳结果到115
  python3 scripts/magnet_search.py "Movie Name" --source apibay   # 仅搜apibay
  python3 scripts/magnet_search.py "Movie Name" --source both     # 两个源合并搜索
  python3 scripts/magnet_search.py "Movie Name" --top 5           # 显示前5个结果
  python3 scripts/magnet_search.py "电影名" --submit --index 2    # 提交第2个结果
        """
    )
    parser.add_argument('query', help='搜索关键词（支持中文和英文）')
    parser.add_argument('--submit', action='store_true', help='自动提交最佳结果到115离线下载')
    parser.add_argument('--index', type=int, default=0, help='提交指定序号的结果（从1开始）')
    parser.add_argument('--top', type=int, default=8, help='显示结果数量（默认8）')
    parser.add_argument('--page', type=int, default=0, help='结果页码（默认0）')
    parser.add_argument('--source', choices=['auto', 'knaben', 'apibay', 'both'],
                        default='auto', help='搜索源: auto(默认)|knaben|apibay|both')
    args = parser.parse_args()

    results = search(args.query, size=args.top, page=args.page, source=args.source)
    print_results(results)

    if not results:
        sys.exit(1)

    if args.submit:
        if args.index > 0:
            if args.index > len(results):
                print(f'[错误] 序号 {args.index} 超出范围（共 {len(results)} 个结果）')
                sys.exit(1)
            chosen = results[args.index - 1]
        else:
            chosen = best(results)

        if chosen:
            print(f'\n[提交] {chosen["name"]} [{chosen.get("source","?")}]')
            submit_to_115(chosen['magnet'])


if __name__ == '__main__':
    main()


path: scripts/quark_login.py
content: #!/usr/bin/env python3
"""
夸克网盘 Cookie 配置向导
用法: python3 quark_login.py
"""
import os
import sys
import requests

COOKIE_DIR = os.path.join(
    os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
    "video-fetch"
)
COOKIE_FILE = os.path.join(COOKIE_DIR, "quark_cookie.txt")

BASE_URL = "https://drive-pc.quark.cn"
USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 "
    "Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch"
)


# Security note:
# - The Quark cookie is stored locally at ~/.config/video-fetch/quark_cookie.txt (chmod 600)
# - The cookie is NEVER transmitted to any third party — only used to authenticate with Quark Pan API
# - The cookie is extracted manually by the user from their own browser session
# - No credentials are logged or printed to console

def verify_cookie(cookie):
    """验证 Cookie 是否有效"""
    s = requests.Session()
    s.headers.update({"User-Agent": USER_AGENT, "Cookie": cookie})
    try:
        r = s.get(f"{BASE_URL}/1/clouddrive/config",
                  params={"pr": "ucpro", "fr": "pc"}, timeout=10)
        data = r.json()
        if data.get("code") == 0:
            # 尝试获取用户信息
            account = data.get("data", {}).get("account", {})
            nickname = account.get("nickname", "") or account.get("phone", "未知用户")
            return True, nickname
        return False, data.get("message", "验证失败")
    except Exception as e:
        return False, str(e)


def save_cookie(cookie):
    os.makedirs(COOKIE_DIR, exist_ok=True)
    with open(COOKIE_FILE, "w") as f:
        f.write(cookie.strip())
    os.chmod(COOKIE_FILE, 0o600)  # 仅当前用户可读


def main():
    print()
    print("══════════════════════════════════════")
    print("  夸克网盘 Cookie 配置向导")
    print("══════════════════════════════════════")
    print()
    print("夸克网盘不支持扫码登录，需要手动提取 Cookie。")
    print("Cookie 有效期通常为数月，无需频繁更新。")
    print()
    print("获取步骤：")
    print("  1. 浏览器打开 https://pan.quark.cn 并登录")
    print("  2. 按 F12 → Network 标签")
    print("  3. 刷新页面，点击任意 quark.cn 请求")
    print("  4. 在 Request Headers 里找到 'cookie' 字段")
    print("  5. 复制完整 cookie 值（很长，包含多个 key=value）")
    print()

    # 检查是否已有 Cookie
    if os.path.exists(COOKIE_FILE):
        print(f"[提示] 已有 Cookie 文件: {COOKIE_FILE}")
        ans = input("是否重新配置? [y/N] ").strip().lower()
        if ans != "y":
            ok, info = verify_cookie(open(COOKIE_FILE).read().strip())
            if ok:
                print(f"✓ Cookie 有效，用户: {info}")
            else:
                print(f"✗ Cookie 已失效: {info}，请重新配置")
                main()
            return

    print("请粘贴 Cookie 内容（回车确认）：")
    print("> ", end="", flush=True)
    cookie = input().strip()

    if not cookie:
        print("未输入 Cookie，退出")
        sys.exit(1)

    print()
    print("验证 Cookie...", flush=True)
    ok, info = verify_cookie(cookie)

    if ok:
        save_cookie(cookie)
        print(f"✓ Cookie 有效，用户: {info}")
        print(f"✓ 已保存到: {COOKIE_FILE}")
        print()
        print("现在可以使用:")
        print("  python3 scripts/quark_offline.py 'magnet:?xt=...'")
    else:
        print(f"✗ Cookie 无效: {info}")
        print("请检查是否复制完整，然后重试")
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/quark_offline.py
content: #!/usr/bin/env python3
"""
夸克网盘离线下载
用法:
  python3 quark_offline.py <magnet_or_url>   # 添加离线任务
  python3 quark_offline.py --list            # 查看任务列表
  python3 quark_offline.py --login           # 显示登录说明

Cookie 配置:
  将夸克网盘 Cookie 写入 ~/.config/video-fetch/quark_cookie.txt
  Cookie 格式: __pus=xxx; __puus=xxx; ...
"""
import sys
import os
import json
import time
import requests

COOKIE_FILE = os.path.join(
    os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
    "video-fetch", "quark_cookie.txt"
)

BASE_URL = "https://drive-pc.quark.cn"
USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 "
    "Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch"
)


def load_cookie():
    if not os.path.exists(COOKIE_FILE):
        print(f"[夸克] 未找到 Cookie 文件: {COOKIE_FILE}")
        print("请运行: python3 quark_offline.py --login 查看配置说明")
        sys.exit(1)
    with open(COOKIE_FILE) as f:
        cookie = f.read().strip()
    if not cookie:
        print(f"[夸克] Cookie 文件为空: {COOKIE_FILE}")
        sys.exit(1)
    return cookie


def get_session():
    cookie = load_cookie()
    s = requests.Session()
    s.headers.update({
        "User-Agent": USER_AGENT,
        "Cookie": cookie,
        "Content-Type": "application/json",
        "Referer": "https://pan.quark.cn/",
    })
    return s


def get_user_info():
    s = get_session()
    r = s.get(f"{BASE_URL}/1/clouddrive/config",
              params={"pr": "ucpro", "fr": "pc", "uc_param_str": ""})
    r.raise_for_status()
    data = r.json()
    if data.get("code") == 0:
        return data.get("data", {})
    return None


def offline_add(url, save_fid="0"):
    s = get_session()
    # 1. 获取任务ID
    payload = {
        "fid": save_fid,
        "fetch_url": url,
        "scene": "manual",
        "uc_param_str": ""
    }
    r = s.post(
        f"{BASE_URL}/1/clouddrive/task",
        params={"pr": "ucpro", "fr": "pc"},
        json=payload
    )
    r.raise_for_status()
    result = r.json()
    if result.get("code") != 0:
        # 尝试离线下载接口
        payload2 = {
            "fid": save_fid,
            "url": url,
        }
        r2 = s.post(
            f"{BASE_URL}/1/clouddrive/offline/download",
            params={"pr": "ucpro", "fr": "pc"},
            json=payload2
        )
        r2.raise_for_status()
        result = r2.json()

    if result.get("code") == 0:
        task_id = result.get("data", {}).get("task_id", "")
        print(f"[夸克离线] 添加成功 ✓")
        print(f"  任务ID: {task_id}")
        print(f"  响应: {result.get('message', 'ok')}")
    else:
        print(f"[夸克离线] 添加失败: code={result.get('code')} msg={result.get('message')}")
        print(f"  完整响应: {result}")
        sys.exit(1)


def offline_list():
    s = get_session()
    r = s.get(
        f"{BASE_URL}/1/clouddrive/offline/download",
        params={"pr": "ucpro", "fr": "pc", "page": 1, "size": 20}
    )
    r.raise_for_status()
    result = r.json()
    if result.get("code") != 0:
        print(f"[夸克离线] 获取列表失败: {result}")
        sys.exit(1)
    tasks = result.get("data", {}).get("task_list", [])
    status_map = {0: "等待", 1: "下载中", 2: "完成", 3: "失败"}
    print(f"[夸克离线] 共 {len(tasks)} 个任务:")
    for t in tasks:
        status = status_map.get(t.get("status", 0), "未知")
        name = t.get("file_name", t.get("url", ""))[:45]
        pct = t.get("percent", 0)
        print(f"  [{status}] {name} - {pct}%")


def show_login_help():
    print("""
[夸克网盘 Cookie 获取方法]

1. 浏览器打开 https://pan.quark.cn 并登录
2. 按 F12 打开开发者工具
3. 切换到 Network 标签，刷新页面
4. 找任意一个 quark.cn 的请求，点击查看 Request Headers
5. 复制 Cookie 字段的完整内容
6. 保存到文件:

mkdir -p ~/.config/video-fetch
echo '<粘贴Cookie内容>' > ~/.config/video-fetch/quark_cookie.txt

注意: Cookie 有效期通常较长，失效后重新获取即可。
""")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法: python3 quark_offline.py <magnet_or_url>")
        print("      python3 quark_offline.py --list")
        print("      python3 quark_offline.py --login")
        sys.exit(1)
    cmd = sys.argv[1]
    if cmd == "--list":
        offline_list()
    elif cmd == "--login":
        show_login_help()
    else:
        fid = sys.argv[2] if len(sys.argv) > 2 else "0"
        offline_add(cmd, fid)


path: scripts/quark_save.py
content: #!/usr/bin/env python3
"""
夸克网盘分享链接转存
用法:
  python3 quark_save.py <share_url> [save_path]     # 转存分享链接
  python3 quark_save.py --list                       # 列出根目录文件
  python3 quark_save.py --login                      # 查看Cookie配置说明

Cookie 配置:
  ~/.config/video-fetch/quark_cookie.txt

支持格式:
  https://pan.quark.cn/s/xxxxxxxx
  https://pan.quark.cn/s/xxxxxxxx?pwd=xxxx  (带提取码)
"""
import sys
import os
import re
import json
import time
import random
import requests

COOKIE_FILE = os.path.join(
    os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
    "video-fetch", "quark_cookie.txt"
)
BASE_URL = "https://drive-pc.quark.cn"
SAVE_URL = "https://drive.quark.cn"
USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)"
    " Chrome/94.0.4606.71 Safari/537.36 Core/1.94.225.400 QQBrowser/12.2.5544.400"
)


def ts13():
    return int(time.time() * 1000)


def load_cookie():
    if not os.path.exists(COOKIE_FILE):
        print(f"[夸克] 未找到 Cookie，请先运行: python3 quark_login.py")
        sys.exit(1)
    return open(COOKIE_FILE).read().strip()


def get_session():
    cookie = load_cookie()
    s = requests.Session()
    s.headers.update({
        "User-Agent": USER_AGENT,
        "Cookie": cookie,
        "Content-Type": "application/json",
        "Referer": "https://pan.quark.cn/",
        "Origin": "https://pan.quark.cn",
        "Accept-Language": "zh-CN,zh;q=0.9",
    })
    return s


def base_params():
    return {
        "pr": "ucpro",
        "fr": "pc",
        "uc_param_str": "",
        "__dt": random.randint(100, 9999),
        "__t": ts13(),
    }


def extract_url(share_url):
    """从分享链接提取 pwd_id 和提取码"""
    pwd_id = share_url.split('?')[0].split('/s/')[-1].split('#')[0]
    match_pwd = re.search(r"[REDACTED_SECRET])", share_url)
    passcode = match_pwd.group(1) if match_pwd else ""
    if not pwd_id or '/' in pwd_id:
        print(f"[夸克转存] 无效的分享链接: {share_url}")
        sys.exit(1)
    return pwd_id, passcode


def get_stoken(s, pwd_id, passcode=""):
    params = base_params()
    r = s.post(
        f"{BASE_URL}/1/clouddrive/share/sharepage/token",
        params=params,
        json={"pwd_id": pwd_id, "passcode": passcode},
        timeout=15
    )
    data = r.json()
    if data.get("status") == 200 and data.get("data"):
        return data["data"]["stoken"]
    print(f"[夸克转存] 获取stoken失败: {data.get('message', data)}")
    sys.exit(1)


def get_detail(s, pwd_id, stoken, pdir_fid="0"):
    file_list = []
    page = 1
    while True:
        params = base_params()
        params.update({
            "pwd_id": pwd_id,
            "stoken": stoken,
            "pdir_fid": pdir_fid,
            "force": "0",
            "_page": str(page),
            "_size": "50",
            "_sort": "file_type:asc,updated_at:desc",
        })
        r = s.get(
            f"{BASE_URL}/1/clouddrive/share/sharepage/detail",
            params=params, timeout=15
        )
        data = r.json()
        _total = data.get("metadata", {}).get("_total", 0)
        _size = data.get("metadata", {}).get("_size", 50)
        _count = data.get("metadata", {}).get("_count", 0)
        items = data.get("data", {}).get("list", [])
        file_list.extend(items)
        if _total < 1 or _total <= _size or _count < _size:
            break
        page += 1
    return file_list


def get_or_create_dir(s, save_path):
    if not save_path or save_path in ("/", "0"):
        return "0"
    r = s.post(
        f"{BASE_URL}/1/clouddrive/file/info/path_list",
        params={"pr": "ucpro", "fr": "pc"},
        json={"file_path": [save_path], "namespace": "0"},
        timeout=10
    )
    data = r.json()
    if data.get("code") == 0 and data.get("data"):
        return data["data"][0]["fid"]
    # 创建目录
    r = s.post(
        f"{BASE_URL}/1/clouddrive/file",
        params={"pr": "ucpro", "fr": "pc"},
        json={"pdir_fid": "0", "file_name": save_path.strip('/'), "dir_path": "", "dir_init_lock": False},
        timeout=10
    )
    data = r.json()
    if data.get("code") == 0:
        return data["data"]["fid"]
    print(f"[夸克转存] 创建目录失败，使用根目录")
    return "0"


def submit_task(s, task_id):
    for i in range(30):
        params = base_params()
        params.update({"task_id": task_id, "retry_index": str(i)})
        r = s.get(
            f"{BASE_URL}/1/clouddrive/task",
            params=params, timeout=10
        )
        data = r.json()
        if data.get("data", {}).get("status") == 2:
            return True
        time.sleep(0.5)
    return False


def save_share(share_url, save_path="/夸克转存"):
    s = get_session()
    pwd_id, passcode = extract_url(share_url)
    print(f"[夸克转存] pwd_id: {pwd_id}")

    stoken = get_stoken(s, pwd_id, passcode)
    file_list = get_detail(s, pwd_id, stoken)

    if not file_list:
        print("[夸克转存] 分享为空或已失效")
        sys.exit(1)

    print(f"[夸克转存] 发现 {len(file_list)} 个文件/文件夹")
    for f in file_list[:5]:
        icon = "📁" if f.get("dir") else "📄"
        print(f"  {icon} {f['file_name']}")
    if len(file_list) > 5:
        print(f"  ... 共 {len(file_list)} 个")

    to_pdir_fid = get_or_create_dir(s, save_path)
    fid_list = [f["fid"] for f in file_list]
    fid_token_list = [f["share_fid_token"] for f in file_list]

    params = base_params()
    r = s.post(
        f"{SAVE_URL}/1/clouddrive/share/sharepage/save",
        params=params,
        json={
            "fid_list": fid_list,
            "fid_token_list": fid_token_list,
            "to_pdir_fid": to_pdir_fid,
            "pwd_id": pwd_id,
            "stoken": stoken,
            "pdir_fid": "0",
            "scene": "link",
        },
        timeout=15
    )
    result = r.json()
    if result.get("code") == 0:
        task_id = result.get("data", {}).get("task_id", "")
        print(f"[夸克转存] 任务提交成功，等待完成...")
        if task_id and submit_task(s, task_id):
            print(f"[夸克转存] ✓ 转存完成，保存到: {save_path}")
        else:
            print(f"[夸克转存] ✓ 转存已提交 (task_id: {task_id})")
    else:
        print(f"[夸克转存] 转存失败: {result.get('message', result)}")
        sys.exit(1)


def list_files():
    s = get_session()
    params = base_params()
    params.update({"pdir_fid": "0", "_page": 1, "_size": 20, "_fetch_total": 1, "_sort": "file_type:asc,updated_at:desc"})
    r = s.get(f"{BASE_URL}/1/clouddrive/file/sort", params=params, timeout=10)
    data = r.json()
    files = data.get("data", {}).get("list", [])
    print(f"[夸克网盘] 根目录 ({len(files)} 个):")
    for f in files:
        icon = "📁" if f.get("dir") else "📄"
        print(f"  {icon} {f['file_name']}")


def show_login_help():
    print("请运行: python3 quark_login.py")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法: python3 quark_save.py <share_url> [save_path]")
        print("      python3 quark_save.py --list")
        print("      python3 quark_save.py --login")
        sys.exit(1)
    cmd = sys.argv[1]
    if cmd == "--list":
        list_files()
    elif cmd == "--login":
        show_login_help()
    else:
        save_path = sys.argv[2] if len(sys.argv) > 2 else "/夸克转存"
        save_share(cmd, save_path)


path: scripts/video_fetch.sh
content: #!/usr/bin/env bash
# video-fetch: download video/torrent and upload to rclone remote
# Usage: video_fetch.sh <url|magnet|.torrent> [remote:path]
# Env: VIDEOFETCH_REMOTE (default: webdav:videos), VIDEOFETCH_TMPDIR (default: /tmp/video-fetch)

set -euo pipefail

INPUT="${1:-}"
REMOTE="${2:-${VIDEOFETCH_REMOTE:-115drive:云下载}}"
TMPDIR="${VIDEOFETCH_TMPDIR:-/tmp/video-fetch}"

if [[ -z "$INPUT" ]]; then
  echo "Usage: video_fetch.sh <url|magnet|.torrent> [remote:path]" >&2
  exit 1
fi

mkdir -p "$TMPDIR"

# ── Detect input type ──────────────────────────────────────────────
is_torrent() {
  [[ "$INPUT" == magnet:* ]] || [[ "$INPUT" == *.torrent ]] || \
  [[ -f "$INPUT" && "$INPUT" == *.torrent ]]
}

if is_torrent; then
  # ── Torrent / Magnet download via aria2c ───────────────────────
  echo "[video-fetch] Torrent mode: $INPUT"
  aria2c \
    --dir="$TMPDIR" \
    --seed-time=0 \
    --max-connection-per-server=4 \
    --split=4 \
    --bt-stop-timeout=300 \
    --console-log-level=notice \
    "$INPUT"
else
  # ── Direct URL download via yt-dlp ────────────────────────────
  echo "[video-fetch] URL mode: $INPUT"
  yt-dlp \
    --format "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" \
    --merge-output-format mp4 \
    --output "$TMPDIR/%(title)s.%(ext)s" \
    --no-playlist \
    --write-info-json \
    "$INPUT"
fi

# ── Find downloaded files ──────────────────────────────────────────
FILES=$(find "$TMPDIR" -maxdepth 2 \
  \( -name '*.mp4' -o -name '*.mkv' -o -name '*.webm' \
     -o -name '*.avi' -o -name '*.mov' -o -name '*.flv' \
     -o -name '*.ts'  -o -name '*.rmvb' \) \
  ! -name '*.part' 2>/dev/null)

if [[ -z "$FILES" ]]; then
  echo "[video-fetch] ERROR: no video file found after download" >&2
  exit 1
fi

# ── Upload via rclone ──────────────────────────────────────────────
echo "[video-fetch] Uploading to $REMOTE ..."
while IFS= read -r FILE; do
  echo "  -> $(basename "$FILE")"
  rclone copy "$FILE" "$REMOTE" --progress
done <<< "$FILES"

# ── Cleanup ────────────────────────────────────────────────────────
echo "[video-fetch] Cleaning up ..."
find "$TMPDIR" -maxdepth 2 \
  \( -name '*.mp4' -o -name '*.mkv' -o -name '*.webm' \
     -o -name '*.avi' -o -name '*.mov' -o -name '*.flv' \
     -o -name '*.ts'  -o -name '*.rmvb' -o -name '*.json' \
     -o -name '*.torrent' \) -delete

echo "[video-fetch] Done. Files uploaded to $REMOTE"


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