Termux之qBittorrent


摘要

一、安装qBittorrent

1.安装 qBittorrent (无头版) 打开 Termux,先确保软件包列表是最新的,然后执行安装命令:

pkg update -y
pkg install qbittorrent -y

2.首次启动与获取临时密码

注意: 新版本的 qBittorrent 已经取消了默认的 adminadmin 密码,首次运行会在屏幕上生成一个随机临时密码。

2.1在 Termux 中直接输入以下命令进行首次启动:

qbittorrent-nox

2.2屏幕上会弹出一长串英文的免责声明。按下键盘上的 y 然后回车同意。

2.3接着,屏幕上会刷出几行日志,请仔细寻找包含以下字眼的两行:

The WebUI administrator username is: admin The WebUI administrator password was set to: (这里会有一串随机字符)

2.4把这串随机字符复制下来,这就是你第一次登录网页端的密码。

注:此时 qBittorrent 已经在前台运行了,千万不要把这个 Termux 窗口关掉。

3.网页端登录与基础设置

3.1打开手机浏览器,访问:http://127.0.0.1:8080

3.2用户名输入 admin,密码粘贴你刚才复制的那串随机字符,点击 Login。

3.3改中文: 点击顶部的齿轮图标 (Options) -> 左侧选择 Web UI -> Language 选 简体中文 -> 划到最底部点击 Save。

3.4改密码: 网页变成中文后,再次进入 设置 -> Web UI -> 在“验证”栏目下,把临时的随机密码改成你自己容易记住的密码,再次点击底部 保存。

4.用 PM2 接管后台运行

现在我们已经配置好了,需要让它在后台默默运行,不占用我们 Termux 的前台窗口。

4.1回到 Termux 界面。

4.2按下 Ctrl + C(按住输入法栏上的 CTRL,再按 C),此时会提示进程已结束,停止刚才的前台运行。

4.3PM2 启动它:

pm2 start qbittorrent-nox --name "qbt"

4.4保存 PM2 的当前状态,确保下次 Termux 重启时它也能跟着启动:

pm2 save

5.安装报错解决方法

5.1这是一个非常经典的“包名陷阱”!别担心,你的网络和镜像源现在完全没问题(提示里已经明确写着 [*] https://mirrors… : ok)。

报错 Unable to locate package qbittorrent 的真正原因其实是包名没对上:

  • 名字差了几个字母:在 Termux 这种纯命令行环境里,并不存在带桌面图形界面的普通版 qbittorrent。官方提供的是专门用于服务器后台挂机的无头版本,它的确切包名叫做 qbittorrent-nox(nox 代表 No X-server,即无图形环境)。

  • 需要扩展软件源:因为这个软件底层依赖了一些 Qt 的核心库,而这些库通常存放在 Termux 的 X11 扩展仓库中。如果只在默认的基础仓库(main)里找,是找不到的。

请按顺序直接复制、粘贴这三行命令并执行,就能完美解决:

# 1. 安装 x11 扩展仓库(如果提示已安装则无视)
pkg install x11-repo -y

# 2. 刷新一下包列表,让系统读取到新仓库的内容
pkg update -y

# 3. 安装正确的包名
pkg install qbittorrent-nox -y

安装完成后,你就可以直接输入 qbittorrent-nox 进行首次启动并获取那个关键的随机密码了。

二、Termux程序与代理的问题

1.由于下载不需要代理,解决方法在flclash中把Termux排除在外也就termux绕行。然后需要代理的脚本通过下面方式启动,直接指向代理软件端口。比如:

http_proxy=http://127.0.0.1:7890 https_proxy=http://127.0.0.1:7890 no_proxy="localhost,127.0.0.1" pm2 start 你的脚本名.py --name "自动采集"

2.termux按需开启与关闭代理

终极解法:制作 proxy 和 unproxy 快捷开关 只要在 Termux 里配置一次,以后你只需要敲五个字母,就能随时切换网络。

第一步:打开配置文件 在 Termux 里输入以下命令,打开你的基础环境变量配置文件(使用内置的 nano 编辑器):

nano ~/.bashrc

第二步:写入开关代码 在打开的界面最下面,粘贴下面这四行代码(假设你的 Flclash 本地端口是 7890,如果是别的请自己改一下数字):

# 开启代理的快捷键
alias proxy="export http_proxy=http://127.0.0.1:7890 https_proxy=http://127.0.0.1:7890; echo '代理已开启 🟢'"

# 关闭代理的快捷键
alias unproxy="unset http_proxy https_proxy; echo '代理已关闭 🔴'"

第三步:保存并生效

2.1按 Ctrl + O(字母 O),然后按回车键保存。

2.2按 Ctrl + X 退出编辑器。

2.3输入 source ~/.bashrc 让刚才的修改立刻生效。

以后你怎么操作?(日常使用场景) 现在你的 Termux 已经拥有了魔法开关。平时它默认是直连的(qBittorrent 正常下 BT)。

场景 1:你需要安装国外的 Python 包或者用 Git 拉取国外的脚本代码 你只需要在敲安装命令前,先输入 proxy。

~ $ proxy
代理已开启 🟢
~ $ pip install you-get   # 此时安装走的是翻墙网络,速度飞快,且不影响后台的 qBT

场景 2:安装完脚本了,你想恢复原本的直连状态测试 你只需要输入 unproxy。

~ $ unproxy
代理已关闭 🔴
~ $ python test.py      # 此时又恢复了真实的局域网环境

为什么这招这么好用? 因为这种环境变量(export)的作用域是“仅限当前终端窗口”。 当你敲下 proxy 时,只有你当前正在操作的这个前台窗口翻墙了。你之前用 pm2 扔到后台挂机的 qBittorrent 属于另外独立的进程,它根本不知道你在这个窗口里开了代理,所以它依然在老老实实地用真实宽带下电影。

通过 PM2 控制后台自动化脚本走代理,通过 proxy 开关控制前台手动安装走代理,这套“内外隔离”的架构跑通后,你的 Termux 服务器网络环境就彻底毕业了。

3.把需要走代理的脚本通过传递–update-env参数达到开启代理端口的方法

“手动一个一个加”才是真正最牛、最安全的解法。因为你的 openlist、nginx、vscode、cloudflared、aria2 本来就跑得好好的,咱们根本不需要去动它们,更不需要删它们。

PM2 其实自带一个“无损注入”的神器参数:–update-env。它可以让你在不删除进程、不知道原先路径的情况下,直接把代理强行塞给指定的几个程序。

咱们现在就用你最踏实的手动一键注入法,只对需要代理的 4 个程序精准开刀:

第一步:在当前窗口拉起代理天网 先把代理变量在当前的黑框框里临时生效:

export http_proxy=http://127.0.0.1:7890 https_proxy=http://127.0.0.1:7890 no_proxy="localhost,127.0.0.1"

第二步:一个一个精准注入(无损重启) 直接利用 –update-env 参数,把刚才的代理变量强行灌进这 4 个需要翻墙的进程里。直接顺次执行这四行:

pm2 restart emby --update-env
pm2 restart tgms --update-env
pm2 restart auto189 --update-env
pm2 restart autotg --update-env

第三步:保存战果

pm2 save

为什么这个“手动注入法”最完美? 3.1绝对安全:你最担心的 openlist、nginx、vscode 还有下载器们,从头到尾连动都没动过,依然保持它们原本最完美的直连状态。

3.2免改路径:PM2 会在原地重启 emby 或 auto189,并把代理变量像打针一样注入进去,不需要你输入任何长串的绝对路径。

弄完之后,你可以敲一下 pm2 describe auto189,拉到最底下的 Environment variables 这一块,你会看到 http_proxy 已经带着 7890 稳稳当当地写进去了。

这次绝对不画饼了,这 5 行命令敲完,这套网络折腾就真正安全落地了!

3.3怎么看真正的代理软件端口? 要揪出它到底有没有成功带上 Flclash 的 7890 端口,不能看 describe 的这个小表格,得看它的完整环境报告。

请在终端里输入这句命令(5 是你 auto189 的进程 ID):

pm2 env 5

这会打印出密密麻麻的一大篇底层信息。你直接往上翻,在里面找 http_proxy 和 https_proxy 这两行。

  • 如果能看到 http://127.0.0.1:7890,说明它已经稳稳当当地带上 Flclash 的车票在跑了。

  • 如果里面依然找不到,说明刚才的注入没生效。你就把刚才那三步(先 export,再 pm2 restart auto189 –update-env,最后 pm2 save)连着敲一遍,接着再用 pm2 env 5 就能亲眼看到它了。

三、qbt_delivery.py

1.网页端bt的相关设置

1.1左边栏里的分类设置与autotg.py一样,也是分类目录映射关系。

如:国漫、华语剧、欧美剧、华语电影、欧美电影等。

1.2左边栏里的标签对应剧名文件夹里的版本号以区别

如:HDR、SDR、HQ、HFR、DV等。

1.3bt的设置选项修改

  • 高级:

    libtorrent 相关

    • 验证 HTTPS tracker 证书: (?)
    • 服务器端请求伪造(SSRF)攻击缓解: (?)

    取消勾选

  • BitTorrent:

    隐私

    • 启用 DHT (去中心化网络) 以找到更多用户
    • 启用用户交换 (PeX) 以找到更多用户
    • 启用本地用户发现以找到更多用户

    取消勾选

  • RSS:

    RSS Torrent 自动下载器

    勾选

1.4RSS订阅

  • 在bitTorrent种子发布网站获取RSS链接

  • 添加到RSS订阅里,这里会获取到Torrent列表

  • 在右边的RSS下载器里添加下载规则

  • 电视剧分集下载规则

    • 添加规则名称如莫离追更

    • 规则定义里勾选使用正则表达式

      必须包含:The First Jasmine.*E(3[1-9]|[4-9]\d)

      (剧名.E31-99)

      或者是:The First Jasmine.*E([3-9]\d)

      (这就代表 3 开头到 9 开头的所有两位数,也就是 30 到 99 集,把 30 也包进去了)。

      指定分类如华语剧

      添加标签如HFR

      对以下订阅源应用规则勾选订阅源

1.5配置 qBittorrent 接力开关

  • 代码搞定后,去 qBittorrent 的网页端,只需要做这一个动作:

  • 点击网页端顶部的 齿轮(选项卡)。

  • 左侧点击 下载 (Downloads),拉到页面最底下。

  • 勾选 “Torrent 完成时运行外部程序”。

  • 在文本框里,完整复制并粘贴这行命令(注意后面加入了 %G 标签参数):

python /data/data/com.termux/files/home/189py/qbt_delivery.py "%N" "%F" "%L" "%G"
  • 点击最下方的 “保存”。

2.脚本之单天翼上传

import os
import re
import sys
import time
import json
import asyncio
from urllib.parse import quote
import httpx

# =================================================================
# ⚙️ 核心网关与路径映射配置
# =================================================================
OLIST_URL = "http://127.0.0.1:5244"
OLIST_TOKEN = "openlist-a87614da-32dd-4b80-9150-6447de823da8f33x53ymkrx0aPKG0HUcsFHmjFRYTKFhSADLRhoQLkXa7ogaiByhWRNEXCjpblp9"
STEWARD_BASE_URL = "http://127.0.0.1:5000" 
TMDB_API_KEY = "9c88e18e43543c8ff195c631aaa0d2fa"

STAGING_BASE_DIR = "/storage/emulated/0/Download/189cas"
TG_SETTINGS_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tg_settings.json")

# 🔔 【新增配置】独立推送网关与局部代理
PUSHPLUS_TOKEN = "" 
TG_BOT_TOKEN = "7548615667:AAHn0ls4aBPKBPI2-gpwykwVdEKd0ywOlsc"
TG_CHAT_ID = "-1002906711199"
# 🎯 局部代理:已为你修正为测试成功的 FlClash 端口 7890
PROXY_URL = "http://127.0.0.1:7890" 

def get_mount_root():
    if os.path.exists(TG_SETTINGS_DB):
        try:
            with open(TG_SETTINGS_DB, "r", encoding="utf-8") as f:
                return json.load(f).get("mount_root", "/f180/177_cas")
        except: pass
    return "/f180/177_cas"

async def notify_steward_log(msg, level="INFO"):
    """安全桥梁:走打更人本地Web中枢上报,规避SQLite数据库锁死"""
    print(f"[{level}] {msg}")
    try:
        # 本地汇报,绝对不走代理
        async with httpx.AsyncClient(timeout=2.0) as client:
            await client.post(f"{STEWARD_BASE_URL}/api/remote_log", json={"level": level, "msg": msg})
    except Exception: pass

async def send_push_notification(title, content):
    """独立的微信/TG消息推送通道,只推送核心成功/失败结果"""
    # 1. 微信 PushPlus 推送 (国内网络,无需代理)
    if PUSHPLUS_TOKEN:
        try:
            url = "http://www.pushplus.plus/send"
            data = {"token": PUSHPLUS_TOKEN, "title": title, "content": content, "template": "html"}
            async with httpx.AsyncClient(timeout=10.0) as client:
                await client.post(url, json=data)
        except: pass
        
    # 2. TG 推送 (需要翻墙,注入局部代理)
    if TG_BOT_TOKEN and TG_CHAT_ID:
        try:
            # 💡 核心修复:TG 的 HTML 模式不支持 <br> 标签,将其替换为标准的 \n 换行符
            tg_content = content.replace("<br>", "\n")
            url = f"https://api.telegram.org/bot{TG_BOT_TOKEN}/sendMessage"
            data = {"chat_id": TG_CHAT_ID, "text": f"{title}\n\n{tg_content}", "parse_mode": "HTML"}
            # 这里挂载代理,让 TG 消息强行穿过 FlClash 发出去
            async with httpx.AsyncClient(timeout=10.0, proxy=PROXY_URL if PROXY_URL else None) as client:
                resp = await client.post(url, json=data)
                if resp.status_code != 200:
                    await notify_steward_log(f"⚠️ [TG推送失败] 状态码: {resp.status_code}, 详情: {resp.text}", level="WARNING")
        except Exception as e:
            await notify_steward_log(f"⚠️ [TG推送异常] 网络或代理出错: {e}", level="WARNING")

# =================================================================
# 🧠 智能属性提取引擎
# =================================================================
def get_hdr_sdr_tag(torrent_name):
    t = torrent_name.upper()
    if re.search(r'(DV|DOVI|DOLBY VISION|HDR10\+|HDR10|HDR)', t): return "HDR"
    if re.search(r'(SDR)', t): return "SDR"
    return ""

def extract_pure_episode(text, drama_anchor=None):
    if drama_anchor:
        try: text = re.compile(re.escape(drama_anchor), re.IGNORECASE).sub(' ', text)
        except: pass
    m = re.search(r'(?i)E(?:P)?0*(\d+)', text)
    if m: return int(m.group(1))
    m = re.search(r'第\s*(\d+)\s*[集话期更]', text)
    if m: return int(m.group(1))
    return None

async def fetch_tmdb_year(title):
    if not TMDB_API_KEY: return time.strftime("%Y")
    clean_q = re.sub(r'S\d+$|\s+\d+$', '', title).strip()
    url = f"https://api.themoviedb.org/3/search/multi?api_key={TMDB_API_KEY}&language=zh-CN&query={quote(clean_q)}&page=1"
    try:
        # 🛡️ 这里也挂载局部代理,确保 TMDB 搜刮即使在直连失效时依然坚挺
        async with httpx.AsyncClient(timeout=5.0, proxy=PROXY_URL if PROXY_URL else None) as client:
            res = await client.get(url)
            results = res.json().get("results")
            if results:
                item = results[0]
                year = (item.get("first_air_date") or item.get("release_date") or "")[:4]
                if year: return year
    except: pass
    return time.strftime("%Y")

# =================================================================
# 🛡️ 猎犬级 CAS 强力镜像下发引擎 (死磕到底)
# =================================================================
async def bg_fetch_cas_task(cas_target_full, final_cas_path, sub_path, cas_file_name):
    await notify_steward_log(f"🔎 [CAS嗅探启动] 正在捕获云端特征码: `{cas_file_name}`")
    await asyncio.sleep(15) 
    get_info_url = f"{OLIST_URL}/api/fs/get"
    headers_get = {"Authorization": OLIST_TOKEN, "Content-Type": "application/json"}
    
    max_attempts = 45 
    cas_downloaded = False
    
    for attempt in range(max_attempts):
        try:
            # 本地获取 CAS,走直连
            async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
                resp = await client.post(get_info_url, json={"path": cas_target_full}, headers=headers_get)
                resp_data = resp.json()
                
                if resp_data.get("code") == 200:
                    raw_url = resp_data["data"]["raw_url"]
                    resp_dl = await client.get(raw_url)
                    
                    if resp_dl.status_code == 200:
                        temp_cas_path = final_cas_path + ".tmp"
                        with open(temp_cas_path, "wb") as f:
                            f.write(resp_dl.content)
                        os.rename(temp_cas_path, final_cas_path)
                        
                        await notify_steward_log(f"🔗 [CAS镜像成功] ➔ `{sub_path}/{cas_file_name}` (耗时: {(attempt+1) * 10 + 15}秒)")
                        cas_downloaded = True
                        break
        except: pass
        await asyncio.sleep(10)
        
    if not cas_downloaded:
        await notify_steward_log(f"🚨🚨 [CAS拉取彻底失败] 降级放弃!目标: `{cas_file_name}`", level="CRITICAL")

# =================================================================
# 🚀 核心装卸接力赛
# =================================================================
async def main():
    if len(sys.argv) < 4:
        print("⚠️ 语法规范: python qbt_delivery.py [种子名] [保存路径] [分类] [可选:标签]")
        return

    torrent_name = sys.argv[1]
    save_path = sys.argv[2]
    category = sys.argv[3]
    custom_tag = sys.argv[4].strip() if len(sys.argv) > 4 else ""

    # 1. 基础剧名洗白 (修复剧名提取导致的空名字和非法空格Bug)
    pure_drama_name = re.sub(r'^\[.*?\]|\(.*?\)', '', torrent_name).strip()
    # 🔥 核心修复点:删掉残留的引导点,防止切分后变成空字符串
    pure_drama_name = pure_drama_name.lstrip('.').strip() 
    pure_drama_name = pure_drama_name.split('.')[0].split(' ')[0].strip()
    pure_drama_name = re.sub(r'第.*?季|S\d+', '', pure_drama_name, flags=re.IGNORECASE).strip()

    m_season = re.search(r'(?i)S(\d+)', torrent_name)
    folder_season = int(m_season.group(1)) if m_season else 1
    
    # =================================================================
    # 🎯 核心升级:优先从种子原名提取准确年份,防止 TMDB 同名误判
    # =================================================================
    year = ""
    # 找寻 19xx 或 20xx 这种标准的四位年份,并且排除掉分辨率(如1080没在这个正则里, 2160也不会被匹配)
    year_match = re.search(r'\b(19\d{2}|20\d{2})\b', torrent_name)
    
    if year_match:
        year = year_match.group(1)
    else:
        # 只有当种子名字里实在没有年份时,才去 TMDB 盲猜兜底
        year = await fetch_tmdb_year(pure_drama_name)
    current_mount = get_mount_root()
    is_movie = "电影" in category or category in ["演唱会", "纪录片"]

    # 2. 剧名文件夹标准化决策 (坚如磐石的地基 + 自定义后缀)
    # 增加双重保险,如果极特殊情况连 pure_drama_name 也没了,用年份或默认名兜底
    clean_base = pure_drama_name.replace(" ", ".") if pure_drama_name else "Unknown.Drama"
    base_folder = f"{clean_base} ({year})" if year else clean_base
    
    if custom_tag:
        folder_name = f"{base_folder} {custom_tag}"
    else:
        folder_name = base_folder

    # 3. 🎯 核心升级:扫描捕获所有的视频实体文件
    video_tasks = []
    if os.path.isdir(save_path):
        for root, dirs, files in os.walk(save_path):
            for f in files:
                if f.lower().endswith(('.mp4', '.mkv', '.ts', '.flv')):
                    video_tasks.append((os.path.join(root, f), f))
        # 排序:确保第1集先传
        video_tasks.sort(key=lambda x: x[1])
    else:
        if save_path.lower().endswith(('.mp4', '.mkv', '.ts', '.flv')):
            video_tasks.append((save_path, os.path.basename(save_path)))

    if not video_tasks:
        await notify_steward_log(f"⚠️ [交接终止] 未在路径 `{save_path}` 下发现任何有效视频文件。")
        return

    await notify_steward_log(f"📦 [大包交接启动] 监测到当前种子共包含 {len(video_tasks)} 个剧集文件,开启串行排队搬运...")

    # 4. 🔄 串行循环:一集一集扎实推进,稳过新手考核
    for actual_video_path, actual_video_name in video_tasks:
        if not os.path.exists(actual_video_path):
            continue
            
        total_bytes = os.path.getsize(actual_video_path)
        
        ep_num = extract_pure_episode(actual_video_name, drama_anchor=pure_drama_name)
        if ep_num is None:
            ep_num = extract_pure_episode(torrent_name, drama_anchor=pure_drama_name)

        if is_movie:
            target_dir = f"{current_mount}/{category}/{folder_name}"
        else:
            if ep_num is None:
                await notify_steward_log(f"⚠️ [单集跳过] 文件 `{actual_video_name}` 无法识别集数,做跳过处理。", level="WARNING")
                continue
            target_dir = f"{current_mount}/{category}/{folder_name}/Season {folder_season}"

        target_full = f"{target_dir}/{actual_video_name}".replace("//", "/")
        
        # ========================================================
        # 🛑 核心新增:智能查重,已传过的坚决跳过
        # ========================================================
        cas_file_name = f"{actual_video_name}.cas"
        cas_target_full = f"{target_full}.cas"
        sub_path = target_dir.replace(current_mount, "", 1)
        local_cas_dir = f"{STAGING_BASE_DIR}{sub_path}".replace("//", "/")
        final_cas_path = os.path.join(local_cas_dir, cas_file_name)
        
        if os.path.exists(final_cas_path):
            await notify_steward_log(f"⏭️ [智能跳过] 侦测到本地已存在镜像 `{cas_file_name}`,该集已上传过,执行跳过处理。")
            continue 
        # ========================================================

        await notify_steward_log(f"🚚 [队列流转] 正在原样运送: `{actual_video_name}` ➔ 云端")
        
        put_url = f"{OLIST_URL}/api/fs/put"
        headers = {
            "Authorization": OLIST_TOKEN,
            "File-Path": quote(target_full),
            "Content-Length": str(total_bytes),
            "Content-Type": "application/octet-stream"
        }

        # ========================================================
        # 🛡️ 核心新增:带推送的异步后台重试机制 (最多试5次)
        # ========================================================
        max_upload_retries = 5
        upload_success = False
        
        for attempt in range(max_upload_retries):
            try:
                with open(actual_video_path, "rb") as f_in:
                    async def file_iter():
                        while True:
                            chunk = f_in.read(2 * 1024 * 1024)
                            if not chunk: break
                            yield chunk

                    async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=1800.0, write=60.0, pool=None)) as client:
                        resp = await client.put(put_url, content=file_iter(), headers=headers)
                        
                if resp.json().get("code") == 200:
                    upload_success = True
                    os.makedirs(local_cas_dir, exist_ok=True)
                    
                    # 🎉 【关键修改】:使用 create_task 让推送在后台独立运行,绝不卡死主线程
                    success_msg = f"<b>{actual_video_name}</b><br>在第 {attempt + 1} 次尝试后成功传至云端!"
                    asyncio.create_task(send_push_notification("✅ PT入库成功", success_msg))
                    
                    # 就算上面的推送失败了,也绝对不影响下面拉取 CAS
                    await bg_fetch_cas_task(cas_target_full, final_cas_path, sub_path, cas_file_name)
                    break 
                else:
                    err_json = resp.json().get('message')
                    await notify_steward_log(f"⚠️ [上传遭拒] `{actual_video_name}` 第 {attempt+1} 次失败: {err_json}", level="WARNING")
                    
            except Exception as e:
                await notify_steward_log(f"⚠️ [网络异常] `{actual_video_name}` 第 {attempt+1} 次崩溃: {e}", level="WARNING")
                
            if attempt < max_upload_retries - 1:
                wait_sec = 2 ** (attempt + 1)
                await notify_steward_log(f"⏳ 正在后台打盹等待 {wait_sec} 秒后进行第 {attempt+2} 次重试...")
                await asyncio.sleep(wait_sec)

        if not upload_success:
            err_msg = f"<b>{actual_video_name}</b><br>经过 {max_upload_retries} 次重试依旧崩溃!请速查日志,疑似网盘断开或网络阻断。"
            await notify_steward_log(f"❌ [彻底失败] {err_msg}", level="ERROR")
            # 失败推送同样丢进后台
            asyncio.create_task(send_push_notification("🚨 PT入库彻底失败", err_msg))
        # ========================================================
            
    await notify_steward_log(f"🏁 [大包搬运完毕] 该种子的所有有效增量视频文件已全部完成云端接力!")

if __name__ == "__main__":
    asyncio.run(main())

3.脚本之双端口双上传

5244、5255、189、139

import os
import re
import sys
import time
import json
import asyncio
from urllib.parse import quote
import httpx

# =================================================================
# ⚙️ 核心网关与路径映射配置 (天翼主盘)
# =================================================================
OLIST_URL = "http://127.0.0.1:5244"
OLIST_TOKEN = "openlist-4ba3bee7-2112-42bd-92e7-6f3a7c67a83a7CIOwhmfYpB9Hi8HX1NYfRJUn1iWvyPnyuoOUPC5Fqn5FGxTwNuKHwRSYCT6OZC2"
STAGING_BASE_DIR = "/storage/emulated/0/Download/189cas"

# =================================================================
# ⚙️ 独立网关与路径映射配置 (移动备盘 - 双传专属)
# =================================================================
MOBILE_OLIST_URL = "http://127.0.0.1:5255"
MOBILE_OLIST_TOKEN = "openlist-335c8982-5c97-40b4-824e-117636cfc94eBHbD3HZOWdNeMUWOiXFUxJGtWXOh0P1jxqH2jAInjHvF5umwVU4If8ifEnrIkjMl"
MOBILE_MOUNT_ROOT = "/139/139cas"
MOBILE_STAGING_BASE_DIR = "/storage/emulated/0/Download/139cas"

# =================================================================
# 🔔 基础通知与推送网关
# =================================================================
STEWARD_BASE_URL = "http://192.168.0.199:5000" 
TMDB_API_KEY = "9c88e18e43543c8ff195c631aaa0d2fa"

TG_SETTINGS_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tg_settings.json")

PUSHPLUS_TOKEN = "" 
TG_BOT_TOKEN = "7548615667:AAHn0ls4aBPKBPI2-gpwykwVdEKd0ywOlsc"
TG_CHAT_ID = "-1002906711199"
PROXY_URL = "http://127.0.0.1:7890" 

def get_mount_root():
    if os.path.exists(TG_SETTINGS_DB):
        try:
            with open(TG_SETTINGS_DB, "r", encoding="utf-8") as f:
                return json.load(f).get("mount_root", "/135/189cas")
        except: pass
    return "/135/189cas"

async def notify_steward_log(msg, level="INFO"):
    """安全桥梁:走打更人本地Web中枢上报,规避SQLite数据库锁死"""
    print(f"[{level}] {msg}")
    try:
        async with httpx.AsyncClient(timeout=2.0) as client:
            await client.post(f"{STEWARD_BASE_URL}/api/remote_log", json={"level": level, "msg": msg})
    except Exception: pass

async def send_push_notification(title, content):
    """独立的微信/TG消息推送通道,只推送核心成功/失败结果"""
    if PUSHPLUS_TOKEN:
        try:
            url = "http://www.pushplus.plus/send"
            data = {"token": PUSHPLUS_TOKEN, "title": title, "content": content, "template": "html"}
            async with httpx.AsyncClient(timeout=10.0) as client:
                await client.post(url, json=data)
        except: pass
        
    if TG_BOT_TOKEN and TG_CHAT_ID:
        try:
            tg_content = content.replace("<br>", "\n")
            url = f"https://api.telegram.org/bot{TG_BOT_TOKEN}/sendMessage"
            data = {"chat_id": TG_CHAT_ID, "text": f"{title}\n\n{tg_content}", "parse_mode": "HTML"}
            async with httpx.AsyncClient(timeout=10.0, proxy=PROXY_URL if PROXY_URL else None) as client:
                resp = await client.post(url, json=data)
                if resp.status_code != 200:
                    await notify_steward_log(f"⚠️ [TG推送失败] 状态码: {resp.status_code}, 详情: {resp.text}", level="WARNING")
        except Exception as e:
            await notify_steward_log(f"⚠️ [TG推送异常] 网络或代理出错: {e}", level="WARNING")

async def trigger_strm_sync(drive, folder_path):
    """🌟 新增:通知打更人 (5000端口) 局部扫描并生成 STRM"""
    url = f"{STEWARD_BASE_URL}/api/sync"
    params = {"drive": str(drive), "path": folder_path}
    try:
        async with httpx.AsyncClient(timeout=5.0) as client:
            resp = await client.get(url, params=params)
            if resp.status_code == 200:
                await notify_steward_log(f"🎬 [STRM触发-{drive}] 已成功通知中枢更新目录: `{folder_path}`")
            else:
                await notify_steward_log(f"⚠️ [STRM触发-{drive}] 通知失败,状态码: {resp.status_code}", level="WARNING")
    except Exception as e:
        await notify_steward_log(f"⚠️ [STRM触发-{drive}] 请求异常: {e}", level="WARNING")

# =================================================================
# 🧠 智能属性提取引擎
# =================================================================
def get_hdr_sdr_tag(torrent_name):
    t = torrent_name.upper()
    if re.search(r'(DV|DOVI|DOLBY VISION|HDR10\+|HDR10|HDR)', t): return "HDR"
    if re.search(r'(SDR)', t): return "SDR"
    return ""

def extract_pure_episode(text, drama_anchor=None):
    if drama_anchor:
        try: text = re.compile(re.escape(drama_anchor), re.IGNORECASE).sub(' ', text)
        except: pass
    m = re.search(r'(?i)E(?:P)?0*(\d+)', text)
    if m: return int(m.group(1))
    m = re.search(r'第\s*(\d+)\s*[集话期更]', text)
    if m: return int(m.group(1))
    return None

async def fetch_tmdb_year(title):
    if not TMDB_API_KEY: return time.strftime("%Y")
    clean_q = re.sub(r'S\d+$|\s+\d+$', '', title).strip()
    url = f"https://api.themoviedb.org/3/search/multi?api_key={TMDB_API_KEY}&language=zh-CN&query={quote(clean_q)}&page=1"
    try:
        async with httpx.AsyncClient(timeout=5.0, proxy=PROXY_URL if PROXY_URL else None) as client:
            res = await client.get(url)
            results = res.json().get("results")
            if results:
                item = results[0]
                year = (item.get("first_air_date") or item.get("release_date") or "")[:4]
                if year: return year
    except: pass
    return time.strftime("%Y")

# =================================================================
# 🛡️ 猎犬级 CAS 强力镜像下发引擎 (支持双网盘独立拉取)
# =================================================================
async def bg_fetch_cas_task(cas_target_full, final_cas_path, sub_path, cas_file_name, olist_url, olist_token, disk_name):
    await notify_steward_log(f"🔎 [CAS嗅探启动-{disk_name}] 正在捕获云端特征码: `{cas_file_name}`")
    await asyncio.sleep(15) 
    get_info_url = f"{olist_url}/api/fs/get"
    headers_get = {"Authorization": olist_token, "Content-Type": "application/json"}
    
    max_attempts = 45 
    cas_downloaded = False
    
    for attempt in range(max_attempts):
        try:
            async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
                resp = await client.post(get_info_url, json={"path": cas_target_full}, headers=headers_get)
                resp_data = resp.json()
                
                if resp_data.get("code") == 200:
                    raw_url = resp_data["data"]["raw_url"]
                    resp_dl = await client.get(raw_url)
                    
                    if resp_dl.status_code == 200:
                        temp_cas_path = final_cas_path + ".tmp"
                        with open(temp_cas_path, "wb") as f:
                            f.write(resp_dl.content)
                        os.rename(temp_cas_path, final_cas_path)
                        
                        await notify_steward_log(f"🔗 [CAS镜像成功-{disk_name}] ➔ `{sub_path}/{cas_file_name}` (耗时: {(attempt+1) * 10 + 15}秒)")
                        cas_downloaded = True
                        break
        except: pass
        await asyncio.sleep(10)
        
    if not cas_downloaded:
        await notify_steward_log(f"🚨🚨 [CAS拉取彻底失败-{disk_name}] 降级放弃!目标: `{cas_file_name}`", level="CRITICAL")

# =================================================================
# 🚀 核心装卸接力赛
# =================================================================
async def main():
    if len(sys.argv) < 4:
        print("⚠️ 语法规范: python qbt_delivery.py [种子名] [保存路径] [分类] [可选:标签]")
        return

    torrent_name = sys.argv[1]
    save_path = sys.argv[2]
    category = sys.argv[3]
    custom_tag = sys.argv[4].strip() if len(sys.argv) > 4 else ""

    # ========================================================
    # 🚦 双传暗号拦截与逗号深度清洗机制
    # ========================================================
    is_double_upload = False
    if "双传" in custom_tag:
        is_double_upload = True
        custom_tag = custom_tag.replace("双传", "")
        
    # 彻底清理可能残留的逗号(中/英文)以及多余的连续空格,保证后缀名绝对纯净
    # 例如:"HQ,双传" 会被洗成 "HQ";"HQ,双传,1080p" 会被洗成 "HQ 1080p"
    custom_tag = custom_tag.replace(",", " ").replace(",", " ")
    custom_tag = " ".join(custom_tag.split())

    # 1. 基础剧名洗白
    cn_bracket = re.search(r'\[([\u4e00-\u9fa5]+.*?)\]|【([\u4e00-\u9fa5]+.*?)】', torrent_name)
    if cn_bracket:
        pure_drama_name = cn_bracket.group(1) or cn_bracket.group(2)
    else:
        cn_front = re.search(r'([\u4e00-\u9fa5]+)', torrent_name.split('.')[0])
        if cn_front:
            pure_drama_name = cn_front.group(1)
        else:
            clean_name = re.sub(r'^\[.*?\]|\(.*?\)', '', torrent_name).strip().lstrip('.')
            pure_drama_name = clean_name.split('.')[0].split(' ')[0].strip()

    pure_drama_name = re.sub(r'第.*?季|S\d+', '', pure_drama_name, flags=re.IGNORECASE).strip()
    m_season = re.search(r'(?i)S(\d+)', torrent_name)
    folder_season = int(m_season.group(1)) if m_season else 1
    
    year = ""
    year_match = re.search(r'\b(19\d{2}|20\d{2})\b', torrent_name)
    if year_match:
        year = year_match.group(1)
    else:
        year = await fetch_tmdb_year(pure_drama_name)
        
    current_mount = get_mount_root()
    is_movie = "电影" in category or category in ["演唱会", "纪录片"]

    # 2. 剧名文件夹标准化决策
    clean_base = pure_drama_name.replace(" ", ".") if pure_drama_name else "Unknown.Drama"
    base_folder = f"{clean_base} ({year})" if year else clean_base
    
    if custom_tag:
        folder_name = f"{base_folder} {custom_tag}"
    else:
        folder_name = base_folder

    # 3. 扫描捕获视频实体文件
    video_tasks = []
    if os.path.isdir(save_path):
        for root, dirs, files in os.walk(save_path):
            for f in files:
                if f.lower().endswith(('.mp4', '.mkv', '.ts', '.flv')):
                    video_tasks.append((os.path.join(root, f), f))
        video_tasks.sort(key=lambda x: x[1])
    else:
        if save_path.lower().endswith(('.mp4', '.mkv', '.ts', '.flv')):
            video_tasks.append((save_path, os.path.basename(save_path)))

    if not video_tasks:
        await notify_steward_log(f"⚠️ [交接终止] 未在路径 `{save_path}` 下发现任何有效视频文件。")
        return

    # ========================================================
    # 🏁 阶段一:全力保障天翼主盘 (极速串行,不受其他盘干扰)
    # ========================================================
    await notify_steward_log(f"📦 [主盘交接启动] 共 {len(video_tasks)} 个视频,优先全力送入天翼...")

    for actual_video_path, actual_video_name in video_tasks:
        if not os.path.exists(actual_video_path):
            continue
            
        total_bytes = os.path.getsize(actual_video_path)
        ep_num = extract_pure_episode(actual_video_name, drama_anchor=pure_drama_name)
        if ep_num is None:
            ep_num = extract_pure_episode(torrent_name, drama_anchor=pure_drama_name)

        if is_movie:
            target_dir = f"{current_mount}/{category}/{folder_name}"
        else:
            if ep_num is None:
                await notify_steward_log(f"⚠️ [单集跳过] 文件 `{actual_video_name}` 无法识别集数。", level="WARNING")
                continue
            target_dir = f"{current_mount}/{category}/{folder_name}/Season {folder_season}"

        target_full = f"{target_dir}/{actual_video_name}".replace("//", "/")
        
        # [天翼盘] 本地 CAS 检查
        cas_file_name = f"{actual_video_name}.cas"
        cas_target_full = f"{target_full}.cas"
        sub_path = target_dir.replace(current_mount, "", 1)
        local_cas_dir = f"{STAGING_BASE_DIR}{sub_path}".replace("//", "/")
        final_cas_path = os.path.join(local_cas_dir, cas_file_name)
        
        if os.path.exists(final_cas_path):
            await notify_steward_log(f"⏭️ [天翼跳过] 侦测到本地已存在镜像 `{cas_file_name}`,执行跳过处理。")
            continue 

        await notify_steward_log(f"🚚 [主盘流转] 正在原样运送: `{actual_video_name}` ➔ 天翼云端 (5244)")
        
        put_url = f"{OLIST_URL}/api/fs/put"
        headers = {
            "Authorization": OLIST_TOKEN,
            "File-Path": quote(target_full),
            "Content-Length": str(total_bytes),
            "Content-Type": "application/octet-stream"
        }

        max_upload_retries = 5
        upload_success = False
        
        for attempt in range(max_upload_retries):
            try:
                with open(actual_video_path, "rb") as f_in:
                    async def file_iter():
                        while True:
                            chunk = f_in.read(2 * 1024 * 1024)
                            if not chunk: break
                            yield chunk

                    # 🛡️ 核心修改:read=None (无限期耐心等待,防止139等慢速网盘超时卡死报错)
                    async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=None, write=120.0, pool=None)) as client:
                        resp = await client.put(put_url, content=file_iter(), headers=headers)
                        
                if resp.json().get("code") == 200:
                    upload_success = True
                    os.makedirs(local_cas_dir, exist_ok=True)
                    
                    success_msg = f"<b>{actual_video_name}</b><br>在第 {attempt + 1} 次尝试后成功传至云端!"
                    asyncio.create_task(send_push_notification("✅ PT入库成功", success_msg))
                    
                    # 拉取天翼的CAS
                    await bg_fetch_cas_task(cas_target_full, final_cas_path, sub_path, cas_file_name, OLIST_URL, OLIST_TOKEN, "天翼")
                    break 
                else:
                    err_json = resp.json().get('message')
                    await notify_steward_log(f"⚠️ [天翼拒收] `{actual_video_name}` 第 {attempt+1} 次失败: {err_json}", level="WARNING")
                    
            except Exception as e:
                await notify_steward_log(f"⚠️ [网络异常] `{actual_video_name}` 第 {attempt+1} 次崩溃: {e}", level="WARNING")
                
            if attempt < max_upload_retries - 1:
                wait_sec = 300 * (attempt + 1)
                await notify_steward_log(f"⏳ [防风控保护] 天翼通道后台打盹 {wait_sec // 60} 分钟后重试...")
                await asyncio.sleep(wait_sec)

        if not upload_success:
            err_msg = f"<b>{actual_video_name}</b><br>天翼主盘经过 {max_upload_retries} 次重试依旧崩溃!"
            await notify_steward_log(f"❌ [彻底失败] {err_msg}", level="ERROR")
            asyncio.create_task(send_push_notification("🚨 PT入库彻底失败", err_msg))

    await notify_steward_log(f"🏁 [天翼主盘完毕] 该种子的所有有效增量视频文件已全部完成天翼入库!")


    # ========================================================
    # 🏁 阶段二:移动备盘独立双传队列 (慢火细熬,绝不拖累主盘)
    # ========================================================
    if is_double_upload:
        await notify_steward_log(f"🚀 [备盘交接启动] 开始将刚才的剧集排队备份至移动云盘 (慢慢传不着急)...")
        
        for actual_video_path, actual_video_name in video_tasks:
            if not os.path.exists(actual_video_path):
                continue
                
            total_bytes = os.path.getsize(actual_video_path)
            ep_num = extract_pure_episode(actual_video_name, drama_anchor=pure_drama_name)
            if ep_num is None:
                ep_num = extract_pure_episode(torrent_name, drama_anchor=pure_drama_name)

            if is_movie:
                m_target_dir = f"{MOBILE_MOUNT_ROOT}/{category}/{folder_name}"
            else:
                if ep_num is None:
                    continue 
                m_target_dir = f"{MOBILE_MOUNT_ROOT}/{category}/{folder_name}/Season {folder_season}"

            m_target_full = f"{m_target_dir}/{actual_video_name}".replace("//", "/")
            
            # [移动盘] 本地 CAS 检查
            cas_file_name = f"{actual_video_name}.cas"
            m_cas_target_full = f"{m_target_full}.cas"
            m_sub_path = m_target_dir.replace(MOBILE_MOUNT_ROOT, "", 1)
            m_local_cas_dir = f"{MOBILE_STAGING_BASE_DIR}{m_sub_path}".replace("//", "/")
            m_final_cas_path = os.path.join(m_local_cas_dir, cas_file_name)
            
            if os.path.exists(m_final_cas_path):
                await notify_steward_log(f"⏭️ [移动跳过] 侦测到本地已存在移动镜像 `{cas_file_name}`,执行跳过处理。")
                continue

            await notify_steward_log(f"🚚 [备盘流转] 正在原样运送: `{actual_video_name}` ➔ 移动云端 (5255)")
            
            m_put_url = f"{MOBILE_OLIST_URL}/api/fs/put"
            m_headers = {
                "Authorization": MOBILE_OLIST_TOKEN,
                "File-Path": quote(m_target_full),
                "Content-Length": str(total_bytes),
                "Content-Type": "application/octet-stream"
            }
            
            m_upload_success = False
            for m_attempt in range(max_upload_retries):
                try:
                    with open(actual_video_path, "rb") as f_in_m:
                        async def file_iter_m():
                            while True:
                                chunk_m = f_in_m.read(2 * 1024 * 1024)
                                if not chunk_m: break
                                yield chunk_m
                                
                        # 🛡️ 核心修改:read=None (移动盘特供,无限期等待响应)
                        async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=None, write=120.0, pool=None)) as m_client:
                            m_resp = await m_client.put(m_put_url, content=file_iter_m(), headers=m_headers)
                            
                    if m_resp.json().get("code") == 200:
                        m_upload_success = True
                        os.makedirs(m_local_cas_dir, exist_ok=True)
                        await notify_steward_log(f"✅ [双传完毕] `{actual_video_name}` 移动云盘接收成功!")
                        # 拉取移动盘专属的 CAS
                        await bg_fetch_cas_task(m_cas_target_full, m_final_cas_path, m_sub_path, cas_file_name, MOBILE_OLIST_URL, MOBILE_OLIST_TOKEN, "移动")
                        
                        # 🌟 新增:触发移动盘 STRM 局部生成
                        await trigger_strm_sync("139", m_target_dir)
                        break
                    else:
                        m_err_json = m_resp.json().get('message')
                        await notify_steward_log(f"⚠️ [移动盘拒收] `{actual_video_name}` 第 {m_attempt+1} 次失败: {m_err_json}", level="WARNING")
                except Exception as e:
                    await notify_steward_log(f"⚠️ [移动盘异常] `{actual_video_name}` 第 {m_attempt+1} 次崩溃: {e}", level="WARNING")
                    
                if m_attempt < max_upload_retries - 1:
                    m_wait_sec = 300 * (m_attempt + 1)
                    await notify_steward_log(f"⏳ [防风控保护-移动] 等待 {m_wait_sec // 60} 分钟后重试...")
                    await asyncio.sleep(m_wait_sec)
                    
            if not m_upload_success:
                await notify_steward_log(f"❌ [双传失败] `{actual_video_name}` 移动盘最终上传失败。", level="ERROR")
                
        await notify_steward_log(f"🏁 [移动备盘完毕] 该种子的所有增量视频也已全部完成移动云盘备份!")

if __name__ == "__main__":
    asyncio.run(main())

4.脚本之单端口双上传

import os
import re
import sys
import time
import json
import asyncio
import shutil
from urllib.parse import quote
import httpx

# =================================================================
# ⚙️ 核心网关与路径映射配置 (统一 5244 端口)
# =================================================================
OLIST_URL = "http://127.0.0.1:5244"
OLIST_TOKEN = "openlist-a87614da-32dd-4b80-9150-6447de823da8f33x53ymkrx0aPKG0HUcsFHmjFRYTKFhSADLRhoQLkXa7ogaiByhWRNEXCjpblp9"

# 天翼主盘物理归档特征码存放地
STAGING_BASE_DIR = "/storage/emulated/0/Download/189cas"

# 移动备盘独立配置 (路径依然保留,上传统一走 5244 端口进行 140 占位调度)
MOBILE_MOUNT_ROOT = "/139/139cas"
MOBILE_STAGING_BASE_DIR = "/storage/emulated/0/Download/139cas"

# =================================================================
# 🔔 基础通知与推送网关
# =================================================================
STEWARD_BASE_URL = "http://127.0.0.1:5000" 
RELAY_BASE_URL = "http://127.0.0.1:5555"  # 🌟 新增:远端 5555 中继器地址 (如果不在这台机器请自行修改 IP)
TMDB_API_KEY = "9c88e18e43543c8ff195c631aaa0d2fa"

TG_SETTINGS_DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tg_settings.json")

PUSHPLUS_TOKEN = "" 
TG_BOT_TOKEN = "7548615667:AAHn0ls4aBPKBPI2-gpwykwVdEKd0ywOlsc"
TG_CHAT_ID = "-1002906711199"
PROXY_URL = "http://127.0.0.1:7890" 

def get_mount_root():
    if os.path.exists(TG_SETTINGS_DB):
        try:
            with open(TG_SETTINGS_DB, "r", encoding="utf-8") as f:
                return json.load(f).get("mount_root", "/135/189cas")
        except: pass
    return "/135/189cas"

async def notify_steward_log(msg, level="INFO"):
    """安全桥梁:走打更人本地Web中枢上报,规避SQLite数据库锁死"""
    print(f"[{level}] {msg}")
    try:
        async with httpx.AsyncClient(timeout=2.0) as client:
            await client.post(f"{STEWARD_BASE_URL}/api/remote_log", json={"level": level, "msg": msg})
    except Exception: pass

async def send_push_notification(title, content):
    """独立的微信/TG消息推送通道,只推送核心成功/失败结果"""
    if PUSHPLUS_TOKEN:
        try:
            url = "http://www.pushplus.plus/send"
            data = {"token": PUSHPLUS_TOKEN, "title": title, "content": content, "template": "html"}
            async with httpx.AsyncClient(timeout=10.0) as client:
                await client.post(url, json=data)
        except: pass
        
    if TG_BOT_TOKEN and TG_CHAT_ID:
        try:
            tg_content = content.replace("<br>", "\n")
            url = f"https://api.telegram.org/bot{TG_BOT_TOKEN}/sendMessage"
            data = {"chat_id": TG_CHAT_ID, "text": f"{title}\n\n{tg_content}", "parse_mode": "HTML"}
            async with httpx.AsyncClient(timeout=10.0, proxy=PROXY_URL if PROXY_URL else None) as client:
                resp = await client.post(url, json=data)
                if resp.status_code != 200:
                    await notify_steward_log(f"⚠️ [TG推送失败] 状态码: {resp.status_code}, 详情: {resp.text}", level="WARNING")
        except Exception as e:
            await notify_steward_log(f"⚠️ [TG推送异常] 网络或代理出错: {e}", level="WARNING")

async def trigger_strm_sync(drive, folder_path):
    """通知管家 (5000端口) 局部扫描并生成 STRM"""
    url = f"{STEWARD_BASE_URL}/api/sync"
    params = {"drive": str(drive), "path": folder_path}
    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            resp = await client.get(url, params=params)
            if resp.status_code == 200:
                await notify_steward_log(f"🎬 [STRM同步成功-{drive}] 目录: `{folder_path}`")
            else:
                await notify_steward_log(f"⚠️ [STRM同步异常-{drive}] HTTP {resp.status_code}", level="WARNING")
    except Exception as e:
        await notify_steward_log(f"⚠️ [STRM同步失败-{drive}]: {e}", level="WARNING")

async def trigger_189_local_script(folder_path):
    """🌟 修正:通知远端 5555 端口中继器去接管 189 盘后续工作"""
    url = f"{RELAY_BASE_URL}/force_harvest"
    params = {"path": folder_path}
    try:
        # 🛡️ 核心修复:增加 proxy=None 和 trust_env=False,绝对防止请求被翻墙软件劫持
        async with httpx.AsyncClient(timeout=10.0, proxy=None, trust_env=False) as client:
            resp = await client.get(url, params=params)
            if resp.status_code == 200:
                await notify_steward_log(f"🚀 [189后续触发] 成功通知 5555 中继器接管目录: `{folder_path}`")
            else:
                await notify_steward_log(f"⚠️ [189后续触发异常] HTTP {resp.status_code}", level="WARNING")
    except Exception as e:
        await notify_steward_log(f"⚠️ [189后续触发失败]: {e}", level="WARNING")

# =================================================================
# 🧠 智能属性提取引擎
# =================================================================
def get_hdr_sdr_tag(torrent_name):
    t = torrent_name.upper()
    if re.search(r'(DV|DOVI|DOLBY VISION|HDR10\+|HDR10|HDR)', t): return "HDR"
    if re.search(r'(SDR)', t): return "SDR"
    return ""

def extract_pure_episode(text, drama_anchor=None):
    if drama_anchor:
        try: text = re.compile(re.escape(drama_anchor), re.IGNORECASE).sub(' ', text)
        except: pass
    m = re.search(r'(?i)E(?:P)?0*(\d+)', text)
    if m: return int(m.group(1))
    m = re.search(r'第\s*(\d+)\s*[集话期更]', text)
    if m: return int(m.group(1))
    return None

async def fetch_tmdb_year(title):
    if not TMDB_API_KEY: return time.strftime("%Y")
    clean_q = re.sub(r'S\d+$|\s+\d+$', '', title).strip()
    url = f"https://api.themoviedb.org/3/search/multi?api_key={TMDB_API_KEY}&language=zh-CN&query={quote(clean_q)}&page=1"
    try:
        async with httpx.AsyncClient(timeout=5.0, proxy=PROXY_URL if PROXY_URL else None) as client:
            res = await client.get(url)
            results = res.json().get("results")
            if results:
                item = results[0]
                year = (item.get("first_air_date") or item.get("release_date") or "")[:4]
                if year: return year
    except: pass
    return time.strftime("%Y")

# =================================================================
# 🛡️ 猎犬级 CAS 强力镜像下发引擎 (仅用于天翼主盘同步拉取)
# =================================================================
async def bg_fetch_cas_task(cas_target_full, final_cas_path, sub_path, cas_file_name, olist_url, olist_token, disk_name):
    await notify_steward_log(f"🔎 [CAS嗅探启动-{disk_name}] 正在捕获云端特征码: `{cas_file_name}`")
    await asyncio.sleep(15) 
    get_info_url = f"{olist_url}/api/fs/get"
    headers_get = {"Authorization": olist_token, "Content-Type": "application/json"}
    
    max_attempts = 45 
    cas_downloaded = False
    
    for attempt in range(max_attempts):
        try:
            async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
                resp = await client.post(get_info_url, json={"path": cas_target_full}, headers=headers_get)
                resp_data = resp.json()
                
                if resp_data.get("code") == 200:
                    raw_url = resp_data["data"]["raw_url"]
                    resp_dl = await client.get(raw_url)
                    
                    if resp_dl.status_code == 200:
                        temp_cas_path = final_cas_path + ".tmp"
                        with open(temp_cas_path, "wb") as f:
                            f.write(resp_dl.content)
                        os.rename(temp_cas_path, final_cas_path)
                        
                        await notify_steward_log(f"🔗 [CAS镜像成功-{disk_name}] ➔ `{sub_path}/{cas_file_name}` (耗时: {(attempt+1) * 10 + 15}秒)")
                        cas_downloaded = True
                        break
        except: pass
        await asyncio.sleep(10)
        
    if not cas_downloaded:
        await notify_steward_log(f"🚨🚨 [CAS拉取彻底失败-{disk_name}] 降级放弃!目标: `{cas_file_name}`", level="CRITICAL")

# =================================================================
# 🚀 核心装卸接力赛
# =================================================================
async def main():
    if len(sys.argv) < 4:
        print("⚠️ 语法规范: python qbt_delivery.py [种子名] [保存路径] [分类] [可选:标签]")
        return

    torrent_name = sys.argv[1]
    save_path = sys.argv[2]
    category = sys.argv[3]
    custom_tag = sys.argv[4].strip() if len(sys.argv) > 4 else ""

    # ========================================================
    # 🚦 双传暗号拦截与逗号深度清洗机制
    # ========================================================
    is_double_upload = False
    if "双传" in custom_tag:
        is_double_upload = True
        custom_tag = custom_tag.replace("双传", "")
        
    custom_tag = custom_tag.replace(",", " ").replace(",", " ")
    custom_tag = " ".join(custom_tag.split())

    # ========================================================
    # 🎯 提取优化:有中文只用中文,没中文才保留英文
    # ========================================================
    cleaned_search = re.sub(r'\[剧集\]|【剧集】|\[更新\]|【更新】|\[高清.*?\]|【高清制作.*?】', '', torrent_name)
    cn_match = re.search(r'([\u4e00-\u9fa5][\u4e00-\u9fa50-9:·!,—、\s]*)', cleaned_search)
    
    if cn_match and cn_match.group(1).strip():
        pure_drama_name = cn_match.group(1).strip()
    else:
        clean_name = re.sub(r'^\[.*?\]|【.*?】|\(.*?\)', '', torrent_name).strip().lstrip('.')
        pure_drama_name = clean_name.split('.')[0].strip()
        
    pure_drama_name = re.sub(r'第.*?季|S\d+|Season\s*\d+', '', pure_drama_name, flags=re.IGNORECASE).strip()

    m_season = re.search(r'(?i)S(\d+)', torrent_name)
    folder_season = int(m_season.group(1)) if m_season else 1
    
    year = ""
    year_match = re.search(r'\b(19\d{2}|20\d{2})\b', torrent_name)
    if year_match:
        year = year_match.group(1)
    else:
        year = await fetch_tmdb_year(pure_drama_name)
        
    current_mount = get_mount_root()
    is_movie = "电影" in category or category in ["演唱会", "纪录片"]

    # 2. 剧名文件夹标准化决策
    clean_base = pure_drama_name.replace(" ", ".") if pure_drama_name else "Unknown.Drama"
    base_folder = f"{clean_base} ({year})" if year else clean_base
    
    if custom_tag:
        folder_name = f"{base_folder} {custom_tag}"
    else:
        folder_name = base_folder

    # 3. 扫描捕获视频实体文件
    video_tasks = []
    if os.path.isdir(save_path):
        for root, dirs, files in os.walk(save_path):
            for f in files:
                if f.lower().endswith(('.mp4', '.mkv', '.ts', '.flv')):
                    video_tasks.append((os.path.join(root, f), f))
        video_tasks.sort(key=lambda x: x[1])
    else:
        if save_path.lower().endswith(('.mp4', '.mkv', '.ts', '.flv')):
            video_tasks.append((save_path, os.path.basename(save_path)))

    if not video_tasks:
        await notify_steward_log(f"⚠️ [交接终止] 未在路径 `{save_path}` 下发现任何有效视频文件。")
        return

    # ========================================================
    # 🏁 阶段一:全力保障天翼主盘 (极速串行,不受其他盘干扰)
    # ========================================================
    await notify_steward_log(f"📦 [主盘交接启动] 共 {len(video_tasks)} 个视频,优先全力送入天翼...")

    for actual_video_path, actual_video_name in video_tasks:
        if not os.path.exists(actual_video_path):
            continue
            
        total_bytes = os.path.getsize(actual_video_path)
        ep_num = extract_pure_episode(actual_video_name, drama_anchor=pure_drama_name)
        if ep_num is None:
            ep_num = extract_pure_episode(torrent_name, drama_anchor=pure_drama_name)

        if is_movie:
            target_dir = f"{current_mount}/{category}/{folder_name}"
        else:
            if ep_num is None:
                await notify_steward_log(f"⚠️ [单集跳过] 文件 `{actual_video_name}` 无法识别集数。", level="WARNING")
                continue
            target_dir = f"{current_mount}/{category}/{folder_name}/Season {folder_season}"

        target_full = f"{target_dir}/{actual_video_name}".replace("//", "/")
        
        # [天翼盘] 本地 CAS 检查
        cas_file_name = f"{actual_video_name}.cas"
        cas_target_full = f"{target_full}.cas"
        sub_path = target_dir.replace(current_mount, "", 1)
        local_cas_dir = f"{STAGING_BASE_DIR}{sub_path}".replace("//", "/")
        final_cas_path = os.path.join(local_cas_dir, cas_file_name)
        
        if os.path.exists(final_cas_path):
            await notify_steward_log(f"⏭️ [天翼跳过] 侦测到本地已存在镜像 `{cas_file_name}`,执行跳过处理。")
            # 💡 补丁:即使跳过上传,也要强制去拉响 5555 管家的警报!
            await trigger_189_local_script(local_cas_dir)
            continue 

        await notify_steward_log(f"🚚 [主盘流转] 正在原样运送: `{actual_video_name}` ➔ 天翼云端 (5244)")
        
        put_url = f"{OLIST_URL}/api/fs/put"
        headers = {
            "Authorization": OLIST_TOKEN,
            "File-Path": quote(target_full),
            "Content-Length": str(total_bytes),
            "Content-Type": "application/octet-stream"
        }

        max_upload_retries = 5
        upload_success = False
        
        for attempt in range(max_upload_retries):
            try:
                with open(actual_video_path, "rb") as f_in:
                    async def file_iter():
                        while True:
                            chunk = f_in.read(2 * 1024 * 1024)
                            if not chunk: break
                            yield chunk

                    async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=None, write=120.0, pool=None)) as client:
                        resp = await client.put(put_url, content=file_iter(), headers=headers)
                        
                if resp.json().get("code") == 200:
                    upload_success = True
                    os.makedirs(local_cas_dir, exist_ok=True)
                    
                    success_msg = f"<b>{actual_video_name}</b><br>在第 {attempt + 1} 次尝试后成功传至云端!"
                    asyncio.create_task(send_push_notification("✅ PT入库成功", success_msg))
                    
                    await bg_fetch_cas_task(cas_target_full, final_cas_path, sub_path, cas_file_name, OLIST_URL, OLIST_TOKEN, "天翼")
                    
                    # 🌟 修正:传完并拿到 CAS 后,把本地真实的 CAS 文件夹路径喂给 5555 脚本
                    await trigger_189_local_script(local_cas_dir)
                    
                    break 
                else:
                    err_json = resp.json().get('message')
                    await notify_steward_log(f"⚠️ [天翼遭拒] `{actual_video_name}` 第 {attempt+1} 次失败: {err_json}", level="WARNING")
                    
            except Exception as e:
                await notify_steward_log(f"⚠️ [网络异常] `{actual_video_name}` 第 {attempt+1} 次崩溃: {e}", level="WARNING")
                
            if attempt < max_upload_retries - 1:
                wait_sec = 300 * (attempt + 1)
                await notify_steward_log(f"⏳ [防风控保护] 天翼通道后台打盹 {wait_sec // 60} 分钟后重试...")
                await asyncio.sleep(wait_sec)

        if not upload_success:
            err_msg = f"<b>{actual_video_name}</b><br>天翼主盘经过 {max_upload_retries} 次重试依旧崩溃!"
            await notify_steward_log(f"❌ [彻底失败] {err_msg}", level="ERROR")
            asyncio.create_task(send_push_notification("🚨 PT入库彻底失败", err_msg))

    await notify_steward_log(f"🏁 [天翼主盘完毕] 该种子的所有有效增量视频文件已全部完成天翼入库!")


    # ========================================================
    # 🏁 阶段二:移动备盘独立双传队列 (140占位 + 本地CAS秒算 + 狸猫换太子)
    # ========================================================
    if is_double_upload:
        await notify_steward_log(f"🚀 [备盘战术启动] 拦截到双传信号,启动140极速占位及骤死级分步重试流...")
        
        for actual_video_path, actual_video_name in video_tasks:
            if not os.path.exists(actual_video_path):
                continue
                
            total_bytes = os.path.getsize(actual_video_path)
            ep_num = extract_pure_episode(actual_video_name, drama_anchor=pure_drama_name)
            if ep_num is None:
                ep_num = extract_pure_episode(torrent_name, drama_anchor=pure_drama_name)

            if is_movie:
                m_target_dir = f"{MOBILE_MOUNT_ROOT}/{category}/{folder_name}"
            else:
                if ep_num is None:
                    continue 
                m_target_dir = f"{MOBILE_MOUNT_ROOT}/{category}/{folder_name}/Season {folder_season}"

            m_target_full = f"{m_target_dir}/{actual_video_name}".replace("//", "/")
            
            # 本地 139cas 特征码真实落盘路径
            cas_file_name = f"{actual_video_name}.cas"
            m_cas_target_full = f"{m_target_full}.cas"
            m_sub_path = m_target_dir.replace(MOBILE_MOUNT_ROOT, "", 1).lstrip("/")
            m_local_cas_dir = f"{MOBILE_STAGING_BASE_DIR}/{m_sub_path}".replace("//", "/")
            m_final_cas_path = os.path.join(m_local_cas_dir, cas_file_name)
            
            # 查重:若本地已存该集移动指纹,代表双传已圆满,直接跳过
            if os.path.exists(m_final_cas_path):
                await notify_steward_log(f"⏭️ [移动跳过] 侦测到本地已存在移动镜像 `{cas_file_name}`,不再重复生成。")
                # 💡 补丁:即使跳过移动双传,也要强制去拉响 5000 管家的警报!
                await trigger_strm_sync("139", m_target_dir)
                continue

            # ========================================================
            # 🚦 独立五轨级步骤链与就地重试状态机
            # ========================================================
            step_139 = 1
            max_step_retries = 5
            step_retry_count = 0
            
            while step_139 <= 5:
                try:
                    # ----------------------------------------------------
                    # Step 1: 140 极速占位大文件上传 (走5244主端口)
                    # ----------------------------------------------------
                    if step_139 == 1:
                        await notify_steward_log(f"🚚 [139战术-步骤1] 正在原样运送大包 ➔ 140占位目录...")
                        temp_cloud_dir = "/140/139cas"
                        temp_cloud_path = f"{temp_cloud_dir}/{actual_video_name}".replace("//", "/")
                        
                        put_url = f"{OLIST_URL}/api/fs/put"
                        m_headers = {
                            "Authorization": OLIST_TOKEN,
                            "File-Path": quote(temp_cloud_path),
                            "Content-Length": str(total_bytes),
                            "Content-Type": "application/octet-stream"
                        }
                        
                        with open(actual_video_path, "rb") as f_in_m:
                            async def file_iter_m():
                                while True:
                                    chunk_m = f_in_m.read(2 * 1024 * 1024)
                                    if not chunk_m: break
                                    yield chunk_m
                                    
                            async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=None, write=120.0, pool=None)) as m_client:
                                m_resp = await m_client.put(put_url, content=file_iter_m(), headers=m_headers)
                                
                        if m_resp.json().get("code") == 200:
                            await notify_steward_log(f"✅ [139战术-步骤1] 140占位大包落盘成功!")
                            step_139 = 2
                            step_retry_count = 0  # 步进成功,清空重试计数
                        else:
                            m_err_json = m_resp.json().get('message')
                            raise Exception(f"140占位区因接口问题拒收: {m_err_json}")

                    # ----------------------------------------------------
                    # Step 2: 唤醒本地子进程,计算秒级指纹 (.cas)
                    # ----------------------------------------------------
                    elif step_139 == 2:
                        await notify_steward_log(f"⚙️ [139战术-步骤2] 呼叫本地 cas_server.py 提取微型指纹...")
                        
                        cas_script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cas_server.py")
                        cmd_cas = [
                            "python3", cas_script_path,
                            "--cli", "--file", actual_video_path,
                            "--cloud", "139", "--category", m_sub_path
                        ]
                        
                        proc_cas = await asyncio.create_subprocess_exec(*cmd_cas, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
                        stdout_data, stderr_data = await proc_cas.communicate()
                        
                        out_log = stdout_data.decode('utf-8', errors='ignore').strip()
                        err_log = stderr_data.decode('utf-8', errors='ignore').strip()
                        
                        if err_log or "error" in out_log.lower() or "failed" in out_log.lower():
                            raise Exception(f"本地哈希碰撞服务器报错: {err_log or out_log}")
                            
                        await notify_steward_log(f"✅ [139战术-步骤2] 本地特征码全量解析完毕!")
                        step_139 = 3
                        step_retry_count = 0

                    # ----------------------------------------------------
                    # Step 3: 上传微型特征码到 139 真正归档路径 (5244端口)
                    # ----------------------------------------------------
                    elif step_139 == 3:
                        await notify_steward_log(f"📤 [139战术-步骤3] 正在投递微型特征指纹 ➔ 139真实归档区...")
                        
                        # 兼容多重可能的输出地址,确保提取到生成的 .cas
                        local_cas_final_path = m_final_cas_path
                        if not os.path.exists(local_cas_final_path):
                            alt_path = actual_video_path + ".cas"
                            if os.path.exists(alt_path):
                                local_cas_final_path = alt_path
                            else:
                                alt_path_2 = os.path.splitext(actual_video_path)[0] + ".cas"
                                if os.path.exists(alt_path_2):
                                    local_cas_final_path = alt_path_2
                                else:
                                    raise Exception("本地CAS散列文件不存在,指纹疑似被系统吞掉")
                                    
                        cas_bytes = os.path.getsize(local_cas_final_path)
                        put_url = f"{OLIST_URL}/api/fs/put"
                        headers_cas = {
                            "Authorization": OLIST_TOKEN,
                            "File-Path": quote(m_cas_target_full),
                            "Content-Length": str(cas_bytes),
                            "Content-Type": "application/octet-stream"
                        }
                        
                        async with httpx.AsyncClient(timeout=30.0) as h:
                            with open(local_cas_final_path, "rb") as f_cas:
                                cas_resp = await h.put(put_url, content=f_cas.read(), headers=headers_cas)
                                
                        if cas_resp.status_code == 200 and cas_resp.json().get("code") == 200:
                            # 补充落盘迁移,确保本地检查文件不丢失
                            if local_cas_final_path != m_final_cas_path:
                                os.makedirs(os.path.dirname(m_final_cas_path), exist_ok=True)
                                shutil.copy(local_cas_final_path, m_final_cas_path)
                                
                            await notify_steward_log(f"✅ [139战术-步骤3] 微型特征码落盘,移动端成功秒传挂载!")
                            step_139 = 4
                            step_retry_count = 0
                        else:
                            raise Exception(f"云端拒绝接收指纹包: {cas_resp.json().get('message')}")

                    # ----------------------------------------------------
                    # Step 4: 毁灭占位资产(大包无缝斩杀,过河拆桥)
                    # ----------------------------------------------------
                    elif step_139 == 4:
                        await notify_steward_log(f"🗑️ [139战术-步骤4] 狸猫换太子完成!正在斩杀 140 占位大包...")
                        
                        remove_url = f"{OLIST_URL}/api/fs/remove"
                        remove_payload = {"names": [actual_video_name], "dir": "/140/139cas"}
                        
                        async with httpx.AsyncClient(timeout=30.0) as h:
                            rem_resp = await h.post(remove_url, json=remove_payload, headers={"Authorization": OLIST_TOKEN, "Content-Type": "application/json"})
                            
                        if rem_resp.status_code == 200 and rem_resp.json().get("code") == 200:
                            await notify_steward_log(f"✅ [139战术-步骤4] 140 空间白嫖成功,占位数据已全部抹除。")
                            step_139 = 5
                            step_retry_count = 0
                        else:
                            raise Exception(f"占位删除请求失效: {rem_resp.text}")

                    # ----------------------------------------------------
                    # Step 5: 吹哨调度打更人管家更新本地 STRM
                    # ----------------------------------------------------
                    elif step_139 == 5:
                        await notify_steward_log(f"🎬 [139战术-步骤5] 正在发起管家局部刷新,请求生成本地strm...")
                        await trigger_strm_sync("139", m_target_dir)
                        step_139 = 6  # 突破循环,全线大圆满

                except Exception as step_error:
                    step_retry_count += 1
                    await notify_steward_log(f"⚠️ [139战术-故障] 步骤 {step_139} 遭遇突发异常: {step_error}", level="WARNING")
                    
                    if step_retry_count >= max_step_retries:
                        await notify_steward_log(f"❌ [139战术-终止] 步骤 {step_139} 达到重试上限,放弃该视频所有后续流转!", level="ERROR")
                        break  # 跳出当前视频的 step 循环
                        
                    wait_sec = 300 * step_retry_count
                    await notify_steward_log(f"⏳ [步骤级防御保护] 阻断 139 全盘重跑,在后台打盹 {wait_sec // 60} 分钟后,仅从【步骤 {step_139}】继续就地死磕...")
                    await asyncio.sleep(wait_sec)

        await notify_steward_log(f"🏁 [移动备盘完毕] 该种子的所有有效备份增量也已通过140无缝入库!")

if __name__ == "__main__":
    asyncio.run(main())

也可以看看