秒传cas、189/139双端播放
1.pm2管理
cd ~/189py && pm2 start auto189.py --name "auto189" --interpreter python
cd ~/189py && pm2 start casplay.py --name "casplay" --interpreter python
cd ~/189py && pm2 start autotg.py --name "autotg" --interpreter python
cd ~/189py && pm2 start cas_server.py --name "casplay" --interpreter python
2.脚本
import base64, json, time, random, hashlib, hmac, urllib.parse, threading, uuid, os, requests, logging, subprocess, math
import socket, re, functools
import urllib3
import urllib.request, urllib.error, http.cookiejar, string
urllib3.util.connection.HAS_IPV6 = False
from collections import deque
from flask import Flask, request, redirect, render_template_string, jsonify
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad
# ==========================================
# 🏠 局域网探针与辅助函数
# ==========================================
old_getaddrinfo = socket.getaddrinfo
def new_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
responses = old_getaddrinfo(host, port, family, type, proto, flags)
if host == '::': return responses
return [res for res in responses if res[0] == socket.AF_INET]
socket.getaddrinfo = new_getaddrinfo
def get_lan_server_ip(req):
host = req.headers.get('X-Forwarded-Host', req.host).split(':')[0]
if re.match(r'^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|127\.0\.0\.1)', host):
client_ip = req.headers.get('X-Forwarded-For', req.remote_addr)
if client_ip:
client_ip = client_ip.split(',')[0].strip()
if not re.match(r'^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|127\.0\.0\.1)', client_ip):
return None
return host
return None
def format_size(size_in_bytes):
try:
size = float(size_in_bytes)
if size < 1024 * 1024 * 1024: return f"{size / (1024 * 1024):.2f} MB"
else: return f"{size / (1024 * 1024 * 1024):.2f} GB"
except: return "未知大小"
def truncate_url(url):
return url[:80] + '...[已折叠]' if url and len(url) > 80 else url
# ==========================================
# 🧩 终极 CAS 解析引擎 (严谨防错版:囊括所有老文件变体)
# ==========================================
def parse_cas_content(cas_str):
if not cas_str: return {}
try:
# 第一步:解除 URL 编码,并暴烈剥离首尾的空格、换行以及不可见的 BOM 头
cas_str = urllib.parse.unquote(cas_str).strip().lstrip('\ufeff')
# 🚨 终极拦截:如果内容其实是个 HTML 报错网页(当初生成文件时网盘就死机了)
if cas_str.startswith('<'):
logger.error(f"❌ [CAS 引擎拦截]: 发现死胎文件!这不是数据,而是一张网页报错单!截取: {cas_str[:80]}...")
return {}
# 第二步:【绝对真理判定】别管长啥样,先试着当 JSON 解码
try:
return json.loads(cas_str)
except:
pass # 如果不是 JSON,说明是老版本的 Base64 密文,继续往下走
# 第三步:【情况 B - Base64 密文处理】
# 1. 必须先把 URL 传输中丢失变成“空格”的加号还原!
cas_b64 = cas_str.replace(' ', '+')
# 2. 格式兼容:把 URL 安全模式的字符转回标准 Base64 字符
cas_b64 = cas_b64.replace('-', '+').replace('_', '/')
# 3. 精准杀毒:剔除所有混入的换行符(\n)、回车符(\r)等乱码杂质
cas_b64 = re.sub(r'[^A-Za-z0-9+/=]', '', cas_b64)
# 4. 完美补齐:靠严格的模运算自己把 '=' 补到完美倍数
cas_b64 = cas_b64.rstrip('=')
cas_b64 += "=" * ((4 - len(cas_b64) % 4) % 4)
# 第四步:解密成 UTF-8 文本并读取为字典
return json.loads(base64.b64decode(cas_b64).decode('utf-8'))
except Exception as e:
# 加上 repr(),让任何隐形导致崩溃的字符(如 \n, \r, \xe0)在日志里现出原形!
logger.error(f"❌ [CAS 引擎解析失败]: {e} | 异常数据原形: {repr(cas_str[:80])}")
return {}
# ==========================================
# 🛡️ 智能防刷墙 (严格并发锁)
# ==========================================
anti_scan_history = {}
anti_scan_lock = threading.Lock()
def is_allowed_by_anti_scan(client_ip, f_md5):
if not client_ip: return True
now = time.time()
with anti_scan_lock:
if client_ip not in anti_scan_history: anti_scan_history[client_ip] = []
history = [req for req in anti_scan_history[client_ip] if now - req[0] < 5]
unique_md5s = set(req[1] for req in history)
if f_md5 not in unique_md5s:
if len(unique_md5s) >= 1:
anti_scan_history[client_ip] = history
return False
else: history.append((now, f_md5))
else: history.append((now, f_md5))
anti_scan_history[client_ip] = history
return True
# ==========================================
# ⚙️ 默认系统配置 (主服务)
# ==========================================
DEFAULT_CONFIG = {
"server_host": "https://play.363689.xyz",
"delete_delay": 600,
"shield_delay": 2700,
"cloud_strategy": "hash",
"force_mode_b": "false",
"openlist_host": "http://127.0.0.1:5244",
"openlist_token": "",
# === 🌟 V10 终极融合:4大统一战车卡槽 ===
"accounts": [{}, {}, {}, {}],
"mode_a_channel": "mix_f2p",
"local_cas_source_dir": "/storage/emulated/0/Download/cas_source",
"network_cas_path": "/177/177-秒传",
"local_strm_dir": "/storage/emulated/0/Download/cas_strm_modeA",
"network_cas_path_native": "/177/177-原生直连",
"local_strm_dir_native": "/storage/emulated/0/Download/cas_strm_modeB",
"network_media_path": "/177/177-常规视频",
"local_strm_dir_media": "/storage/emulated/0/Download/cas_strm_media",
"network_cas_path_139": "/139/139-秒传",
"local_strm_dir_139": "/storage/emulated/0/Download/cas_strm_139",
"yun139_auth": "",
"yun139_host": "https://caiyun.feixin.10086.cn:7071",
"openlist_host_139": "http://127.0.0.1:5255",
"openlist_token_139": "",
"local_cas_source_dir_139": "/storage/emulated/0/Download/cas_source_139",
"yun139_phone": "",
"yun139_password": "",
"yun139_token": "",
"yun139_mail_cookie": "",
"local_strm_dir_139_native": "/storage/emulated/0/Download/cas_strm_139_native",
"yun139_control_host": "https://personal-kd-njs.yun.139.com",
"yun139_link_expire": 7200,
"delete_delay_139": 14400,
"yun139_temp_folder_id": "",
"pushplus_token": "",
"tg_bot_token": "",
"tg_chat_id": "",
"tg_proxy": "http://127.0.0.1:7890"
}
EMBY_HOST = "http://127.0.0.1:8096"
API_KEY_LINUX = "751c095055f8493d8e63eb755369b9aa"
API_KEY_APP = "66644805d4bc45ea91b2a5e5eca22105"
app_main = Flask('cas_server_5000')
app_302 = Flask('nginx_302_5001')
upload_cache = {}
native_link_cache = {}
cache_lock = threading.Lock()
print_throttle_cache = {}
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_DIR = os.path.join(BASE_DIR, "db")
os.makedirs(DB_DIR, exist_ok=True)
def get_db_path(): return os.path.join(DB_DIR, "config.json")
# ==========================================
# 🔔 统一看板日志与推送系统
# ==========================================
log_buffer = deque(maxlen=150)
class MemoryHandler(logging.Handler):
def emit(self, record):
msg = self.format(record)
log_buffer.append({'time': time.strftime("%H:%M:%S"), 'level': record.levelname, 'msg': f"● {msg}"})
logger = logging.getLogger('CAS_Server')
logger.setLevel(logging.INFO)
mem_handler = MemoryHandler()
mem_handler.setFormatter(logging.Formatter('%(message)s'))
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
logger.addHandler(mem_handler)
logger.addHandler(stream_handler)
logging.getLogger('werkzeug').setLevel(logging.ERROR)
@app_main.route('/api/remote_log', methods=['POST'])
def receive_remote_log():
try:
data = request.json
log_buffer.append({'time': time.strftime("%H:%M:%S"), 'level': data.get('level', 'INFO'), 'msg': f"● [远程] {data.get('msg', '')}"})
return "OK", 200
except: return "Error", 400
def send_push(title, content):
def _do_push():
cfg = read_config()
if cfg.get('pushplus_token'):
try: requests.get(f"http://www.pushplus.plus/send?token={cfg['pushplus_token']}&title={urllib.parse.quote(title)}&content={urllib.parse.quote(content)}&template=html", timeout=5)
except: pass
if cfg.get('tg_bot_token') and cfg.get('tg_chat_id'):
tg_proxy = cfg.get('tg_proxy', '').strip()
proxy_config = {"http": tg_proxy, "https": tg_proxy} if tg_proxy else None
try:
requests.post(f"https://api.telegram.org/bot{cfg['tg_bot_token']}/sendMessage", data={"chat_id": cfg['tg_chat_id'], "text": f"🚨 <b>{title}</b>\n\n{content.replace('<br>', '\n')}", "parse_mode": "HTML"}, proxies=proxy_config, timeout=5)
except Exception as e:
logger.error(f"❌ TG推送失败: {e} (当前代理: {tg_proxy or '直连'})")
threading.Thread(target=_do_push, daemon=True).start()
# ==========================================
# 🔑 天翼云独立鉴权引擎 (统一双轨版)
# ==========================================
def rsaEncrpt(password, public_key):
rsakey = RSA.importKey(public_key)
cipher = PKCS1_v1_5.new(rsakey)
return cipher.encrypt(password.encode()).hex()
def get_session_key_via_api(session_obj, source="未知来源"):
try:
url = "https://cloud.189.cn/v2/getUserBriefInfo.action"
headers = {"Accept": "application/json;charset=UTF-8", "Referer": "https://cloud.189.cn/"}
res = session_obj.get(url, headers=headers, timeout=10).json()
sk = res.get("sessionKey")
if sk: logger.info(f"[凭证更新] 成功获取 SESSION_KEY ({sk[-4:]})")
return sk
except Exception as e:
logger.error(f"提取 sessionKey 失败: {e}")
return None
class Cloud189AuthEngine:
def __init__(self):
self.session = requests.session()
self.session.headers = {'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json;charset=UTF-8"}
def getEncrypt(self): return self.session.post("https://open.e.189.cn/api/logbox/config/encryptConf.do", data={'appId': 'cloud'}, timeout=15).json()['data']['pubKey']
def getRedirectURL(self):
rsp = self.session.get('https://cloud.189.cn/api/portal/loginUrl.action?redirectURL=https://cloud.189.cn/web/redirect.html?returnURL=/main.action', timeout=15)
return urllib.parse.parse_qs(urllib.parse.urlparse(rsp.url).query)
def do_login_and_get_key(self, username, password, slot_name="卡槽自愈"):
encryptKey = self.getEncrypt()
query = self.getRedirectURL()
resData = self.session.post('https://open.e.189.cn/api/logbox/oauth2/appConf.do', data={"version": '2.0', "appKey": 'cloud'}, headers={"Referer": 'https://open.e.189.cn/', "lt": query["lt"][0], "REQID": query["reqId"][0]}, timeout=15).json()
keyData = f"-----BEGIN PUBLIC KEY-----\n{encryptKey}\n-----END PUBLIC KEY-----"
data = {"appKey": 'cloud', "version": '2.0', "accountType": '01', "mailSuffix": '@189.cn', "returnUrl": resData['data']['returnUrl'], "paramId": resData['data']['paramId'], "clientType": '1', "isOauth2": "false", "userName": f"{{NRP}}{rsaEncrpt(username, keyData)}", "password": f"{{NRP}}{rsaEncrpt(password, keyData)}"}
result = self.session.post('https://open.e.189.cn/api/logbox/oauth2/loginSubmit.do', data=data, headers={'Referer': 'https://open.e.189.cn/', 'lt': query["lt"][0], 'REQID': query["reqId"][0]}, timeout=15).json()
if result['result'] == 0:
self.session.get(result['toUrl'], headers={"Host": 'cloud.189.cn'}, timeout=15)
sk = get_session_key_via_api(self.session, slot_name)
if sk:
cookie_str = "; ".join([f"{c.name}={c.value}" for c in self.session.cookies])
return sk, cookie_str
else: raise Exception("接口未返回 sessionKey")
else: raise Exception(result['msg'])
# 🌟 全局缓存:记录文件最后修改时间和凭证
cookie_cache = {"sk": "", "cookie_str": "", "mtime": 0}
def get_auto189_credentials():
global cookie_cache
cookie_file = os.path.join(DB_DIR, "cookies.json")
if not os.path.exists(cookie_file):
return "", ""
current_mtime = os.path.getmtime(cookie_file)
if cookie_cache["mtime"] == current_mtime and cookie_cache["sk"]:
return cookie_cache["sk"], cookie_cache["cookie_str"]
session = requests.Session()
try:
with open(cookie_file, 'r', encoding='utf-8') as f:
cookie_dict = json.load(f)
session.cookies.update(cookie_dict)
sk = get_session_key_via_api(session, "外部同步大号") or ""
if sk:
cookie_str = "; ".join([f"{k}={v}" for k, v in cookie_dict.items()])
cookie_cache["sk"] = sk
cookie_cache["cookie_str"] = cookie_str
cookie_cache["mtime"] = current_mtime
return sk, cookie_str
else:
if os.path.exists(cookie_file): os.remove(cookie_file)
logger.warning("❌ [外部凭证] 新 Cookie 无效已被天翼云拒绝,已粉碎文件等待外部脚本重建!")
return "", ""
except Exception as e:
logger.error(f"❌ [外部凭证] 读取异常: {e}")
return "", ""
def save_config(cfg):
cfg_path = get_db_path()
with open(cfg_path, 'w', encoding='utf-8') as f: json.dump(cfg, f, ensure_ascii=False, indent=4)
def read_config():
cfg_path = get_db_path()
cfg = DEFAULT_CONFIG.copy()
try:
if os.path.exists(cfg_path):
with open(cfg_path, 'r', encoding='utf-8') as f: cfg.update(json.load(f))
except: pass
return cfg
def refresh_account_logic(slot_idx, cfg):
if slot_idx < len(cfg.get('accounts', [])):
acc = cfg['accounts'][slot_idx]
user, pwd = acc.get('username'), acc.get('password')
if slot_idx == 3 and not pwd:
logger.info("[凭证获取] 卡槽 4 尝试从外部 cookies.json 读取最高权限...")
sk, cookie = get_auto189_credentials()
if sk and cookie:
acc['session_key'] = sk
acc['cookie'] = cookie
save_config(cfg)
logger.info("[自愈成功] 卡槽 4 从外部读取 Cookie 满血复活!")
send_push("✅ 大号自愈成功", "卡槽 4 成功从外部读取到最新 Cookie 凭证!")
return sk, cookie
else:
logger.error("[凭证失败] 卡槽 4 外部 Cookie 提取失败或已过期!")
send_push("❌ 大号自愈失败", "卡槽 4 外部 Cookie 提取失败或已过期!")
return None, None
if user and pwd:
logger.info(f"[自愈启动] 统一卡槽 {slot_idx+1} 开始账号密码重登...")
send_push("🔄 双轨自愈启动", f"检测到卡槽 {slot_idx+1} 凭证失效,正在执行自动重登...")
try:
auth = Cloud189AuthEngine()
sk, cookie = auth.do_login_and_get_key(user, pwd, f"卡槽{slot_idx+1}")
if sk and cookie:
acc['session_key'] = sk
acc['cookie'] = cookie
save_config(cfg)
logger.info(f"[自愈成功] 统一卡槽 {slot_idx+1} 满血复活!")
send_push("✅ 自愈成功", f"统一卡槽 {slot_idx+1} 账号重登成功,双轨满血复活!")
return sk, cookie
except Exception as e:
logger.error(f"[自愈失败] 卡槽 {slot_idx+1}: {e}")
send_push("❌ 自愈失败", f"统一卡槽 {slot_idx+1} 自动重登失败: {e}")
return None, None
# ==========================================
# 🧹 全量清空逻辑
# ==========================================
def force_clear_all_worker():
logger.info("[全量清理] 开始横扫天翼云矩阵...")
cfg = read_config()
with cache_lock: upload_cache.clear()
for i, acc in enumerate(cfg.get('accounts', [])):
fam_id = acc.get('family_id')
fold_id = acc.get('family_folder_id')
per_id = acc.get('personal_folder_id')
sk = acc.get('session_key')
cookie = acc.get('cookie')
if not sk or not cookie:
sk, cookie = refresh_account_logic(i, cfg)
if sk and fam_id and fold_id:
try:
items = family_client.get_family_items(fam_id, fold_id, sk)
del_count = sum([family_client.delete_item(fam_id, item['fileId'], sk) for item in items])
if del_count > 0: family_client.empty_family_recycle(fam_id, sk)
except: pass
if sk and cookie and per_id:
try:
items = personal_client.get_personal_items(per_id, cookie)
del_count = sum([personal_client.delete_item(item['fileId'], cookie) for item in items])
if del_count > 0: personal_client.empty_personal_recycle(sk, cookie)
except: pass
logger.info("[清理完毕] 全区垃圾回收作业圆满完成!")
@app_main.route('/api/clear_all', methods=['POST'])
def api_clear_all():
threading.Thread(target=force_clear_all_worker, daemon=True).start()
return "✅ 清空指令下发成功", 200
@app_main.route('/api/clear_139', methods=['POST'])
def api_clear_139():
threading.Thread(target=cloud139_native.empty_recycle_bin, daemon=True).start()
return "✅ 139清空指令下发成功", 200
# ==========================================
# 🖥️ ADMIN 界面与配置路由
# ==========================================
@app_main.route('/admin/config', methods=['POST'])
def update_global_config():
old_cfg = read_config()
cfg = DEFAULT_CONFIG.copy()
for k, v in old_cfg.items(): cfg[k] = v
accounts = []
for i in range(4):
user = request.form.get(f'acc_user_{i}', '').strip()
pwd = request.form.get(f'acc_pwd_{i}', '').strip()
fam_id = request.form.get(f'acc_fam_id_{i}', '').strip()
fam_fd = request.form.get(f'acc_fam_fd_{i}', '').strip()
per_fd = request.form.get(f'acc_per_fd_{i}', '').strip()
old_acc = old_cfg.get('accounts', [{},{},{},{}])[i] if i < len(old_cfg.get('accounts', [])) else {}
sk, cookie = old_acc.get('session_key', ''), old_acc.get('cookie', '')
if user != old_acc.get('username', '') or pwd != old_acc.get('password', ''):
sk, cookie = "", ""
accounts.append({
"username": user, "password": pwd, "family_id": fam_id,
"family_folder_id": fam_fd, "personal_folder_id": per_fd,
"session_key": sk, "cookie": cookie
})
cfg['accounts'] = accounts
cfg['cloud_strategy'] = request.form.get('cloud_strategy', 'hash')
cfg['mode_a_channel'] = request.form.get('mode_a_channel', 'mix_f2p')
cfg['force_mode_b'] = request.form.get('force_mode_b', 'false')
cfg['tg_proxy'] = request.form.get('tg_proxy', '').strip()
for k in cfg.keys():
if k not in ['accounts', 'cloud_strategy', 'mode_a_channel', 'force_mode_b', 'tg_proxy'] and k in request.form:
val = request.form.get(k, '').strip()
if k in ['delete_delay', 'shield_delay', 'delete_delay_139', 'yun139_link_expire']:
cfg[k] = int(val) if val else 600
else:
cfg[k] = val
save_config(cfg)
try:
if 'family_client' in globals():
family_client.rsa_keys.clear()
except: pass
try:
if 'personal_client' in globals():
personal_client.rsa_keys.clear()
except: pass
logger.info(f"[系统配置] V10 矩阵重组!四核心配置均已保存。")
return redirect("/admin?msg=矩阵重组完成!配置实时生效")
ADMIN_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>💖 追剧管家 V10 (真融合版)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f4f6f9; margin: 0; padding: 20px; color: #333; }
.container { max-width: 950px; margin: 0 auto; }
.header { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 5px solid #2563eb; }
.card { background: #fff; padding: 25px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); margin-bottom: 20px; }
h2 { margin: 0; color: #1e293b; } h3 { margin-top: 0; color: #334155; font-size: 1.1rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 10px; margin-bottom: 15px; }
h4 { color: #475569; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px dashed #cbd5e1; }
.badge { background: #2563eb; color: white; padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; letter-spacing: 1px; }
label { display: block; margin-bottom: 6px; font-weight: 600; color: #64748b; font-size: 12px; }
input, select { width: 100%; padding: 10px; margin-bottom: 15px; border: 1px solid #cbd5e1; border-radius: 6px; box-sizing: border-box; background: #f8fafc; transition: all 0.3s; font-size: 13px; }
input:focus, select:focus { border-color: #2563eb; outline: none; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); background: #fff; }
button { background: #2563eb; color: white; border: none; padding: 10px 18px; border-radius: 6px; cursor: pointer; font-weight: bold; transition: 0.2s; }
button:hover { background: #1d4ed8; transform: translateY(-1px); }
.btn-purple { background: #8b5cf6; width: 100%; } .btn-purple:hover { background: #7c3aed; }
.btn-green { background: #10b981; width: 100%; } .btn-green:hover { background: #059669; }
.btn-blue { background: #0ea5e9; width: 100%; } .btn-blue:hover { background: #0284c7; }
.btn-orange { background: #f97316; width: 100%; } .btn-orange:hover { background: #ea580c; }
.grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 20px; }
.status-grid { display: grid; grid-template-columns: 0.8fr 1.2fr 1fr; gap: 20px; align-items: center; }
.status-msg { padding: 12px; border-radius: 6px; margin-bottom: 20px; background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; text-align: center; font-weight: bold; }
.cloud-box { border: 1px solid #bfdbfe; padding: 15px; border-radius: 8px; margin-bottom: 15px; background: #eff6ff;}
.cloud-title { font-size: 13px; font-weight: bold; color: #2563eb; margin-bottom: 10px;}
.path-box { background: #f1f5f9; padding: 15px; border-radius: 8px; margin-bottom: 15px; border: 1px solid #e2e8f0; }
.mac-window { background: #1e293b; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); overflow: hidden; margin-bottom: 20px; }
.mac-header { background: #0f172a; padding: 10px 15px; display: flex; gap: 8px; align-items: center; }
.mac-btn { width: 12px; height: 12px; border-radius: 50%; }
.btn-close { background: #ef4444; } .btn-min { background: #f59e0b; } .btn-max { background: #10b981; }
.mac-title { color: #64748b; font-size: 12px; margin-left: 10px; font-weight: bold; letter-spacing: 1px; }
.console { background: #1e293b; color: #cbd5e1; padding: 15px; height: 350px; overflow-y: auto; overflow-x: hidden; font-family: 'Consolas', 'Courier New', monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.log-time { color: #64748b; margin-right: 8px; display: inline-block; vertical-align: top; }
.log-msg { display: inline; }
.log-INFO { color: #34d399; } .log-WARNING { color: #fbbf24; } .log-ERROR { color: #f87171; font-weight: bold; }
.log-SUCCESS { color: #10b981; font-weight: bold; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } .status-grid { grid-template-columns: 1fr; gap: 15px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>💖 追剧管家 V10 <span style="font-size:12px; color:#2563eb;">统一滑点矩阵版</span></h2>
<span class="badge">SYSTEM ONLINE</span>
</div>
{% if msg %}<div class="status-msg">{{ msg }}</div>{% endif %}
<div class="mac-window">
<div class="mac-header">
<div class="mac-btn btn-close"></div><div class="mac-btn btn-min"></div><div class="mac-btn btn-max"></div>
<div class="mac-title">追剧控制台 - 实时运行日志</div>
</div>
<div class="console" id="logBox">Loading terminal...</div>
</div>
<div class="card">
<h3>📊 核心控制 & 凭证监控</h3>
<div class="status-grid">
<p style="color:#64748b; margin:0;">秒传库:<br><b style="color:#1e293b; font-size:1.8rem;">{{ cache_count }}</b> <span style="font-size:12px;">部剧集</span></p>
<div style="color:#64748b; margin:0; font-size: 13px; line-height: 1.8; border-left: 2px solid #e2e8f0; padding-left: 15px;">
<b>🔑 四核驱动引擎凭证状态:</b><br>
{% for i in range(4) %}
{% set acc = cfg.accounts[i] if cfg.get('accounts') and i < cfg.accounts|length else {} %}
{% set sk = acc.get('session_key', '') %}
统一卡槽 {{ i+1 }}:
{% if sk %}<span style="color:#10b981; font-weight:bold;">已就绪 (尾号{{ sk[-4:] }})</span>
{% else %}<span style="color:#f43f5e; font-weight:bold;">等待唤醒...</span>{% endif %}<br>
{% endfor %}
<br>
<b>🟠 移动云139:</b>
{% if cfg.openlist_host_139 %}<span style="color:#10b981; font-weight:bold;">配置已连接</span>
{% else %}<span style="color:#f43f5e; font-weight:bold;">未配置接口</span>{% endif %}
</div>
<div style="display:flex; flex-direction:column; gap:8px;">
<button type="button" onclick="syncOpenList('189', 'cas')" class="btn-purple" style="height:38px;">🔄 云端同步 (模式A: 稳健秒传)</button>
<button type="button" onclick="syncOpenList('189', 'cas_native')" class="btn-green" style="height:38px;">⚡ 云端同步 (模式B: 虚空直通)</button>
<button type="button" onclick="syncOpenList('139', 'cas')" class="btn-orange" style="height:38px;">🟠 云端同步 (移动云139 老模式)</button>
<button type="button" onclick="syncOpenList('139', 'cas_native')" style="background:#f59e0b; color:white; border:none; border-radius:6px; height:38px; cursor:pointer; font-weight:bold; transition: 0.2s;">🟠 云端同步 (139 原生直连)</button>
<button type="button" onclick="syncOpenList('direct', 'media')" class="btn-blue" style="height:38px;">🎬 云端同步 (常规真实视频)</button>
<button type="button" onclick="syncLocalCas()" style="background:#8b5cf6; color:white; border:none; border-radius:6px; height:38px; cursor:pointer; font-weight:bold; transition: 0.2s;" onmouseover="this.style.background='#7c3aed'" onmouseout="this.style.background='#8b5cf6'">🗂️ 批量扫描本地 CAS</button>
<button type="button" onclick="clearAllCache()" style="background:#ef4444; color:white; border:none; border-radius:6px; height:38px; cursor:pointer; font-weight:bold; transition: 0.2s;">🗑️ 一键清空189家庭/个人回收站</button>
<button type="button" onclick="clear139Recycle()" style="background:#ea580c; color:white; border:none; border-radius:6px; height:38px; cursor:pointer; font-weight:bold; transition: 0.2s; margin-top:5px;">🟠 一键清空139回收站</button>
</div>
</div>
</div>
<div class="card">
<h3>⚙️ 综合配置中心</h3>
<form method="POST" action="/admin/config">
<div style="margin-bottom: 20px; padding: 15px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px;">
<h4 style="color:#d97706; margin-top:0; border-bottom:none;">⚡ 模式 A:四核矩阵攻击队列 (容灾滑点)</h4>
<p style="font-size:12px; color:#b45309; margin-top:0; margin-bottom:10px;">当某个账号家庭云报错(如流量超标/null),系统将根据此队列无缝退守至下一个可用云盘!</p>
<select name="mode_a_channel" style="border-color:#fcd34d;">
<option value="mix_f2p" {% if cfg.get('mode_a_channel', 'mix_f2p') == 'mix_f2p' %}selected{% endif %}>🔵🟣 混合双打:优先打空所有家庭云,无缝退守个人云</option>
<option value="mix_p2f" {% if cfg.get('mode_a_channel') == 'mix_p2f' %}selected{% endif %}>🟣🔵 混合双打:优先打空所有个人云,无缝退守家庭云</option>
<option value="family" {% if cfg.get('mode_a_channel') == 'family' %}selected{% endif %}>🔵 纯净模式:仅在 4 个家庭云间穿梭</option>
<option value="personal" {% if cfg.get('mode_a_channel') == 'personal' %}selected{% endif %}>🟣 纯净模式:仅在 4 个个人云间穿梭</option>
</select>
</div>
<div style="margin-bottom: 20px; padding: 15px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px;">
<h4 style="color:#0369a1; margin-top:0; border-bottom:none;">⚡ 全局强制原生直通 (无视播放器选择)</h4>
<label style="display:flex; align-items:center; font-size:14px; color:#0c4a6e; cursor:pointer;">
<input type="checkbox" name="force_mode_b" value="true" {% if cfg.get('force_mode_b', '') | string | lower == 'true' %}checked{% endif %} style="width:20px; height:20px; margin-right:10px; margin-bottom:0;">
勾选此项,强行将所有189播放请求跃迁为原生直连 (模式B)!(关闭则遵循STRM参数)
</label>
</div>
<div style="margin-bottom: 20px;">
<label>首发节点分配策略 (Hash推荐)</label>
<select name="cloud_strategy">
<option value="hash" {% if cfg.cloud_strategy == 'hash' %}selected{% endif %}>🔗 剧名哈希绑定 (多卡槽容灾滑点)</option>
<option value="random" {% if cfg.cloud_strategy == 'random' %}selected{% endif %}>🎲 完全随机散列 (多卡槽容灾滑点)</option>
<option value="slot1" {% if cfg.cloud_strategy == 'slot1' %}selected{% endif %}>🥇 优先【卡槽 1】(失败无缝退守其他号)</option>
<option value="slot2" {% if cfg.cloud_strategy == 'slot2' %}selected{% endif %}>🥈 优先【卡槽 2】(失败无缝退守其他号)</option>
<option value="slot3" {% if cfg.cloud_strategy == 'slot3' %}selected{% endif %}>🥉 优先【卡槽 3】(失败无缝退守其他号)</option>
<option value="slot4" {% if cfg.cloud_strategy == 'slot4' %}selected{% endif %}>💎 优先【卡槽 4 大号】(失败无缝退守其他号)</option>
</select>
</div>
<div class="grid">
<!-- ================= 左侧栏 ================= -->
<div>
<h3 style="margin-top:0; border:none;">❇️ 189卡槽区</h3>
{% for i in range(4) %}
{% set acc = cfg.accounts[i] if cfg.get('accounts') and i < cfg.accounts|length else {} %}
<div class="cloud-box">
<div class="cloud-title">📌 核心装甲槽位 {{ i + 1 }} {% if i == 3 %}(支持外部Cookie免密注入大号){% endif %}</div>
<div style="display:flex; gap:10px;">
<input type="text" name="acc_user_{{ i }}" value="{{ acc.get('username', '') }}" placeholder="天翼云账号">
<input type="password" name="acc_pwd_{{ i }}" value="{{ acc.get('password', '') }}" placeholder="天翼云密码{% if i == 3 %} (留空则读取 cookies.json){% endif %}">
</div>
<div style="display:flex; gap:10px;">
<input type="text" name="acc_fam_id_{{ i }}" value="{{ acc.get('family_id', '') }}" placeholder="家庭云 Family ID">
<input type="text" name="acc_fam_fd_{{ i }}" value="{{ acc.get('family_folder_id', '') }}" placeholder="家庭云 目标目录 ID">
</div>
<input type="text" name="acc_per_fd_{{ i }}" value="{{ acc.get('personal_folder_id', '') }}" placeholder="个人云 目标目录 ID (不需要则留空)" style="margin-bottom:0;">
<input type="hidden" name="acc_sk_{{ i }}" value="{{ acc.get('session_key', '') }}">
<input type="hidden" name="acc_cookie_{{ i }}" value="{{ acc.get('cookie', '') }}">
</div>
{% endfor %}
<h3 style="margin-top:20px; border:none;">📁 绝对物理隔离路径设置</h3>
<div class="path-box" style="background:#fffbeb; border-color:#fde68a;">
<h4 style="color:#d97706;">📁 本地原始 CAS 库 (其他脚本下载存放处)</h4>
<label>本地 CAS 源目录</label>
<input type="text" name="local_cas_source_dir" value="{{ cfg.local_cas_source_dir }}" required style="margin-bottom:0;">
</div>
<div class="path-box" style="background:#f0fdf4; border-color:#bbf7d0;">
<h4 style="color:#166534;">🌐 189 模式A (家庭云稳健秒传)</h4>
<label>云端 CAS 库扫描源目录</label>
<input type="text" name="network_cas_path" value="{{ cfg.network_cas_path }}" required>
<label>模式A 本地 STRM 独立保存路径</label>
<input type="text" name="local_strm_dir" value="{{ cfg.local_strm_dir }}" required style="margin-bottom:0;">
</div>
<div class="path-box" style="border-color: #10b981; background: #ecfdf5;">
<h4 style="color:#059669;">⚡ 189 模式B (极速虚空直通)</h4>
<label>云端临时挂载目录 (虚空造物目标路径)</label>
<input type="text" name="network_cas_path_native" value="{{ cfg.network_cas_path_native }}" required>
<label>模式B 本地 STRM 独立保存路径</label>
<input type="text" name="local_strm_dir_native" value="{{ cfg.local_strm_dir_native }}" required style="margin-bottom:0;">
</div>
<div class="path-box" style="border-color: #0ea5e9; background: #f0f9ff;">
<h4 style="color:#0284c7;">🎬 189 常规媒体 (真实视频解析)</h4>
<label>云端常规视频库 扫描源目录</label>
<input type="text" name="network_media_path" value="{{ cfg.network_media_path }}" required>
<label>常规媒体 本地 STRM 独立保存路径</label>
<input type="text" name="local_strm_dir_media" value="{{ cfg.local_strm_dir_media }}" required style="margin-bottom:0;">
</div>
</div>
<!-- ================= 右侧栏 ================= -->
<div>
<h3 style="margin-top:0; border:none;">🌐 全局基础与 API</h3>
<div class="path-box">
<label>基础外网域名 (Server Host)</label>
<input type="text" name="server_host" value="{{ cfg.server_host }}" required>
<label>189 OpenList 接口地址 (例如 5244)</label>
<input type="text" name="openlist_host" value="{{ cfg.openlist_host }}" required>
<label>189 OpenList 授权 Token</label>
<input type="password" name="openlist_token" value="{{ cfg.openlist_token }}" placeholder="填入189 OpenList Token">
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<div style="flex: 1;"><label>绝对销毁倒计时 (秒)</label><input type="number" name="delete_delay" value="{{ cfg.delete_delay }}" style="margin-bottom:0;" required></div>
<div style="flex: 1;"><label>预加载长效护盾 (秒)</label><input type="number" name="shield_delay" value="{{ cfg.shield_delay }}" style="margin-bottom:0;" required></div>
</div>
<div style="margin-bottom: 0;">
<label style="display:inline-block; font-weight:bold;">🛡️ 网关直链保鲜期 (秒):</label>
<input type="number" name="link_expire" value="{{ cfg.get('link_expire', 120) }}" style="width:80px; padding:3px;">
</div>
</div>
<h3 style="margin-top:20px; border:none;">📱 消息推送</h3>
<div class="path-box">
<label>PushPlus Token (微信推送)</label><input type="text" name="pushplus_token" value="{{ cfg.pushplus_token }}" placeholder="留空则不推送">
<label>Telegram Bot Token</label><input type="text" name="tg_bot_token" value="{{ cfg.tg_bot_token }}">
<label>Telegram Chat ID</label><input type="text" name="tg_chat_id" value="{{ cfg.tg_chat_id }}">
<label>Telegram 代理地址 (Nekobox / Clash 等)</label><input type="text" name="tg_proxy" value="{{ cfg.get('tg_proxy', '') }}" placeholder="例如: http://127.0.0.1:7890 (留空则直连)" style="margin-bottom:0;">
</div>
<h3 style="margin-top:20px; border:none;"><span style="color:#ea580c;">🟠</span> 139 移动云盘设置</h3>
<div class="path-box" style="border-color: #f97316; background: #fff7ed;">
<label style="color:#2563eb;"><b>【139 独立原生直连】</b></label>
<label>🌐 139 控制面网关域名 (用于彻底删除/清空回收站)</label>
<input type="text" name="yun139_control_host" value="{{ cfg.yun139_control_host }}" placeholder="例如: https://personal-kd-njs.yun.139.com" style="margin-bottom:10px; border: 1px solid #f97316;">
<label>139 本地原始 CAS 库 (上游下载的 cas 存放处)</label>
<input type="text" name="local_cas_source_dir_139" value="{{ cfg.local_cas_source_dir_139 }}" style="margin-bottom:10px; border: 1px solid #f59e0b;">
<label>💣 139 后台物理销毁延时 (秒,默认 14400)</label>
<input type="number" name="delete_delay_139" value="{{ cfg.delete_delay_139 }}" placeholder="例如:14400" style="margin-bottom:10px; border: 1px solid #ef4444;">
<label>🛡️ 139 直链保鲜期 (秒,原生与老模式通用)</label>
<input type="number" name="yun139_link_expire" value="{{ cfg.get('yun139_link_expire', 7200) }}" placeholder="例如:7200" style="margin-bottom:10px; border: 1px solid #10b981;">
<label>📁 139 临时缓存隔离目录 ID (选填,强烈建议)</label>
<span style="font-size:12px; color:#666; display:block; margin-bottom:5px;">网页登录移动云盘,点进你新建的文件夹,URL 里的 catalogID= 后面的那一长串字母数字就是它。留空则默认存放根目录。</span>
<input type="text" name="yun139_temp_folder_id" value="{{ cfg.yun139_temp_folder_id }}" placeholder="例如:00019C0000000000000..." style="margin-bottom:10px; border: 1px solid #10b981;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px;">
<label style="margin-bottom: 0;">🍪 139 邮箱 Cookie (长期免维护必备)</label>
<span onclick="const t = document.getElementById('cookie_textarea'); t.style.display = t.style.display === 'none' ? 'block' : 'none';" style="cursor: pointer; color: #ea580c; font-size: 12px; font-weight: bold; padding: 3px 8px; background: #ffedd5; border: 1px solid #fdba74; border-radius: 4px; transition: 0.2s;">[ 展开 / 折叠 ]</span>
</div>
<span style="font-size:12px; color:#666; display:block; margin-bottom:5px;">登录 mail.10086.cn 后抓取 Cookie (包含 Os_SSo_Sid 和 RMKEY)。配合手机号+密码可实现永久免维护。</span>
<textarea id="cookie_textarea" name="yun139_mail_cookie" rows="3" placeholder="格式如: key1=value1; key2=value2;" style="width:100%; padding:10px; border: 1px solid #f97316; border-radius:6px; margin-bottom:10px; box-sizing:border-box; resize: vertical; display: {{ 'none' if cfg.yun139_mail_cookie else 'block' }};">{{ cfg.yun139_mail_cookie }}</textarea>
<input type="text" name="yun139_phone" value="{{ cfg.yun139_phone }}" placeholder="139 手机号">
<input type="password" name="yun139_password" value="{{ cfg.yun139_password }}" placeholder="139 密码">
<input type="password" name="yun139_token" value="{{ cfg.yun139_token }}" placeholder="139 Base64 Token (备用,留空即可)">
<label>139 原生直连本地 STRM 独立保存路径</label>
<input type="text" name="local_strm_dir_139_native" value="{{ cfg.local_strm_dir_139_native }}" style="margin-bottom:15px; border: 1px solid #f59e0b;">
<hr style="border:0; border-top:1px dashed #fdba74; margin-bottom:15px;">
<label>139 OpenList 接口 (老模式用)</label>
<input type="text" name="openlist_host_139" value="{{ cfg.openlist_host_139 }}">
<label>139 OpenList 授权 Token</label>
<input type="password" name="openlist_token_139" value="{{ cfg.openlist_token_139 }}">
<label>云端 139 CAS 扫描源目录</label>
<input type="text" name="network_cas_path_139" value="{{ cfg.network_cas_path_139 }}">
<label>139 老模式本地 STRM 独立保存路径</label>
<input type="text" name="local_strm_dir_139" value="{{ cfg.local_strm_dir_139 }}" style="margin-bottom:0;">
</div>
</div>
</div>
<button type="submit" style="width:100%; margin-top:15px; font-size:16px;">💾 写入配置并重启四核矩阵引擎</button>
</form>
</div><div style="height: 40px;"></div>
</div>
<script>
function syncOpenList(driveType, fileType='cas') {
let url = '/api/sync?drive=' + driveType + '&type=' + fileType;
fetch(url).then(r => alert('同步指令已下发!请看上方日志。'));
}
function syncLocalCas() {
if(confirm("将扫描本地配置的 CAS 目录,生成双轨 STRM,确认执行?")) {
fetch('/api/sync_local').then(r => alert('本地扫描指令已下发!请看上方日志。'));
}
}
function clear139Recycle() {
if(confirm('⚠️ 确定要清空 139 移动云盘回收站吗?')) {
fetch('/api/clear_139', {method: 'POST'}).then(r => alert('🟠 139 回收站清理指令已下发!请看上方日志。'));
}
}
function clearAllCache() { if(confirm('⚠️ 确定要清空吗?')) { fetch('/api/clear_all', {method: 'POST'}).then(r => alert('🚀 核弹已发射!')); } }
function fetchLogs() {
fetch('/admin/logs').then(r => r.json()).then(logs => {
const box = document.getElementById('logBox');
const oldScrollTop = box.scrollTop, oldScrollHeight = box.scrollHeight, clientHeight = box.clientHeight;
box.innerHTML = logs.map(l => `<span class="log-time">[${l.time}]</span><span class="log-msg ${l.msg.includes('✅') ? 'log-SUCCESS' : 'log-' + l.level}">${l.msg}</span><br>`).join('');
if (oldScrollHeight - clientHeight - oldScrollTop < 30) { box.scrollTop = box.scrollHeight; } else { box.scrollTop = oldScrollTop; }
});
}
setInterval(fetchLogs, 2000); fetchLogs();
</script>
</body>
</html>
"""
@app_main.route('/admin')
def admin_index():
cfg = read_config()
with cache_lock: count = len(upload_cache)
return render_template_string(ADMIN_HTML, cfg=cfg, cache_count=count, msg=request.args.get('msg'))
@app_main.route('/admin/logs')
def get_logs(): return jsonify(list(log_buffer))
# ==========================================
# ☁️ 天翼云核心功能类 (家庭云)
# ==========================================
class TianyiFinalUploader:
def __init__(self):
self.rsa_keys = {}
self.session = requests.Session()
def get_base_headers(self, session_key):
return {'User-Agent': 'ecloud/10.2.1 (Windows NT 10.0; Win64; x64)', 'Cookie': f"SESSION_KEY={session_key}; cookieUserSession={session_key}", 'Accept': 'application/json;charset=UTF-8', 'clientType': 'TELEMAC'}
def _random_string(self, length=16): return ''.join(random.choices('0123456789abcdef', k=length))
def _get_timestamp(self): return str(int(time.time() * 1000))
def _get_slice_size(self, file_size):
try: size = int(file_size)
except: return '10485760'
D = 10485760
if size > D * 2 * 999: return str(max(math.ceil(size / 1999 / D), 5) * D)
elif size > D * 999: return str(D * 2)
return str(D)
def get_rsa_key(self, session_key):
if session_key in self.rsa_keys: return self.rsa_keys[session_key]
url = f"https://cloud.189.cn/api/security/generateRsaKey.action?sessionKey={urllib.parse.quote(session_key)}"
for _ in range(3):
try:
res = self.session.get(url, headers=self.get_base_headers(session_key), timeout=10).json()
if 'pubKey' in res:
self.rsa_keys[session_key] = res
return res
if str(res.get('res_code')) == '111' or 'Session' in str(res): raise Exception("公钥获取拦截_AUTH_FAIL")
except Exception as e:
if "AUTH_FAIL" in str(e): raise e
time.sleep(2)
raise Exception("无法获取公钥_AUTH_FAIL")
def build_request(self, params, request_uri, req_id, session_key):
rsa_key = self.get_rsa_key(session_key)
uuid_key = self._random_string(16)
ts = self._get_timestamp()
p_str = '&'.join([f"{k}={v}" for k, v in params.items()])
cipher = AES.new(uuid_key.encode('utf-8'), AES.MODE_ECB)
enc_p = cipher.encrypt(pad(p_str.encode('utf-8'), 16)).hex().upper()
rsa_cipher = PKCS1_v1_5.new(RSA.import_key(f"-----BEGIN PUBLIC KEY-----\n{rsa_key['pubKey']}\n-----END PUBLIC KEY-----"))
enc_t = base64.b64encode(rsa_cipher.encrypt(uuid_key.encode('utf-8'))).decode('utf-8')
sign = hmac.new(uuid_key.encode('utf-8'), f"SessionKey={session_key}&Operate=GET&RequestURI={request_uri}&Date={ts}¶ms={enc_p}".encode('utf-8'), hashlib.sha1).hexdigest().upper()
h = self.get_base_headers(session_key)
h.update({'X-Request-Date': ts, 'X-Request-ID': req_id, 'SessionKey': session_key, 'EncryptionText': enc_t, 'PkId': rsa_key['pkId'], 'Signature': sign})
return f"https://upload.cloud.189.cn{request_uri}?params={enc_p}", h
def get_family_items(self, family_id, folder_id, session_key):
all_items = []
params = {"familyId": family_id, "folderId": folder_id, "pageSize": 60, "sessionKey": session_key}
res = self.session.get("https://cloud.189.cn/api/open/family/file/listFiles.action", params=params, headers=self.get_base_headers(session_key), timeout=10).json()
if str(res.get('res_code')) == '111' or 'Session' in str(res): raise Exception("接口返回111_AUTH_FAIL")
for f in res.get('fileListAO', {}).get('fileList', []): all_items.append({'fileName': f['name'], 'fileId': f['id']})
return all_items
def delete_item(self, family_id, file_id, session_key):
url = "https://cloud.189.cn/api/open/family/file/deleteFile.action"
p = {"familyId": family_id, "fileId": file_id, "sessionKey": session_key}
try: return self.session.post(url, params=p, headers=self.get_base_headers(session_key), timeout=10).status_code == 200
except: return False
def empty_family_recycle(self, family_id, session_key):
url = "https://cloud.189.cn/api/open/batch/createBatchTask.action"
payload = {"type": "EMPTY_RECYCLE", "taskInfos": "[]", "targetFolderId": "", "familyId": family_id, "sessionKey": session_key}
try:
res = self.session.post(url, data=payload, headers=self.get_base_headers(session_key), timeout=10).json()
if str(res.get("res_code")) == "0": return True
except: pass
return False
def rapid_upload(self, family_id, parent_folder_id, md5, size, smd5, safe_name, session_key):
req_id = str(uuid.uuid4())
slice_size = self._get_slice_size(size)
init_p = {'familyId': family_id, 'parentFolderId': parent_folder_id, 'fileName': urllib.parse.quote(safe_name), 'fileSize': str(size), 'sliceSize': slice_size, 'fileMd5': md5, 'sliceMd5': smd5, 'lazyCheck': '1', 'opertype': '3'}
url, h = self.build_request(init_p, '/family/initMultiUpload', req_id, session_key)
res = self.session.get(url, headers=h).json()
if res.get('code') != 'SUCCESS':
msg_str = str(res.get('msg', ''))
if any(k in msg_str.lower() for k in ['session', 'privatekey', '111']): raise Exception(f"秒传初始化拒绝_AUTH_FAIL: {msg_str}")
raise Exception(f"秒传初始化失败: {msg_str}")
up_id = res['data']['uploadFileId']
ck_p = {'familyId': family_id, 'fileMd5': md5, 'sliceMd5': smd5, 'uploadFileId': up_id}
url, h = self.build_request(ck_p, '/family/checkTransSecond', req_id, session_key)
if not self.session.get(url, headers=h).json().get('data', {}).get('fileDataExists'): raise Exception("云端无此文件")
cm_p = {'familyId': family_id, 'uploadFileId': up_id, 'fileMd5': md5, 'sliceMd5': smd5, 'lazyCheck': '1', 'opertype': '3'}
url, h = self.build_request(cm_p, '/family/commitMultiUploadFile', req_id, session_key)
commit_res = self.session.get(url, headers=h).json()
file_info = commit_res.get('file')
if not file_info: raise Exception(f"秒传确认失败: {commit_res.get('msg', '未知错误')}")
return file_info['userFileId']
def get_download_url(self, family_id, file_id, session_key, client_ua):
url = "https://cloud.189.cn/api/open/family/file/getFileDownloadUrl.action"
params = {"familyId": family_id, "fileId": file_id, "sessionKey": session_key}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/118.0.0.0 Safari/537.36",
"Cookie": f"SESSION_KEY={session_key}; cookieUserSession={session_key}",
"Accept": "application/json;charset=UTF-8"
}
try:
res = self.session.get(url, params=params, headers=headers, timeout=10).json()
if 'fileDownloadUrl' in res:
api_url = res['fileDownloadUrl'].replace('&', '&')
unwrap_headers = {
"User-Agent": client_ua if client_ua else headers["User-Agent"],
"Cookie": f"SESSION_KEY={session_key}; cookieUserSession={session_key}",
"Accept-Encoding": "identity"
}
unwrap_res = requests.get(api_url, headers=unwrap_headers, allow_redirects=False, timeout=10)
status_code = unwrap_res.status_code
if status_code in [301, 302, 303, 307, 308]: return unwrap_res.headers.get('Location')
elif status_code == 200: return api_url
else: raise Exception(f"底层破冰失败 (HTTP {status_code})")
raise Exception(f"提取网关链接失败: {res.get('msg', res)}")
except Exception as e: raise e
family_client = TianyiFinalUploader()
# ==========================================
# 🛡️ 139 AES Crypto 底层 (对齐 139 官方加密)
# ==========================================
SBOX = (
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16)
INV_SBOX = [0]*256
for _i, _v in enumerate(SBOX): INV_SBOX[_v] = _i
INV_SBOX = tuple(INV_SBOX)
RCON = (0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36,0x6c,0xd8,0xab,0x4d)
def _xtime(a): return (((a << 1) ^ 0x1b) & 0xff) if (a & 0x80) else (a << 1)
def _mul(a, b):
r = 0
for _ in range(8):
if b & 1: r ^= a
a = _xtime(a)
b >>= 1
return r
def _expand_key(key):
nk = len(key) // 4
nr = nk + 6
kc = [list(key[i:i+4]) for i in range(0, len(key), 4)]
for i in range(nk, 4 * (nr + 1)):
t = list(kc[i-1])
if i % nk == 0:
t = [SBOX[b] for b in (t[1:] + t[:1])]
t[0] ^= RCON[i//nk - 1]
elif nk > 6 and i % nk == 4:
t = [SBOX[b] for b in t]
kc.append([kc[i-nk][j] ^ t[j] for j in range(4)])
return [sum(kc[r*4:(r+1)*4], []) for r in range(nr + 1)], nr
def _shift_rows(s):
s[1],s[5],s[9],s[13] = s[5],s[9],s[13],s[1]
s[2],s[6],s[10],s[14] = s[10],s[14],s[2],s[6]
s[3],s[7],s[11],s[15] = s[15],s[3],s[7],s[11]
def _inv_shift_rows(s):
s[1],s[5],s[9],s[13] = s[13],s[1],s[5],s[9]
s[2],s[6],s[10],s[14] = s[10],s[14],s[2],s[6]
s[3],s[7],s[11],s[15] = s[7],s[11],s[15],s[3]
def _mix_columns(s):
for c in range(4):
a = s[c*4:c*4+4]
t = a[0]^a[1]^a[2]^a[3]
u = a[0]
s[c*4+0] ^= t ^ _xtime(a[0]^a[1])
s[c*4+1] ^= t ^ _xtime(a[1]^a[2])
s[c*4+2] ^= t ^ _xtime(a[2]^a[3])
s[c*4+3] ^= t ^ _xtime(a[3]^u)
def _inv_mix_columns(s):
for c in range(4):
a = s[c*4:c*4+4]
s[c*4+0] = _mul(a[0],14)^_mul(a[1],11)^_mul(a[2],13)^_mul(a[3],9)
s[c*4+1] = _mul(a[0],9)^_mul(a[1],14)^_mul(a[2],11)^_mul(a[3],13)
s[c*4+2] = _mul(a[0],13)^_mul(a[1],9)^_mul(a[2],14)^_mul(a[3],11)
s[c*4+3] = _mul(a[0],11)^_mul(a[1],13)^_mul(a[2],9)^_mul(a[3],14)
def _aes_block(block, rks, nr, decrypt=False):
s = list(block)
if not decrypt:
for i in range(16): s[i] ^= rks[0][i]
for r in range(1, nr):
for i in range(16): s[i] = SBOX[s[i]]
_shift_rows(s); _mix_columns(s)
for i in range(16): s[i] ^= rks[r][i]
for i in range(16): s[i] = SBOX[s[i]]
_shift_rows(s)
for i in range(16): s[i] ^= rks[nr][i]
else:
for i in range(16): s[i] ^= rks[nr][i]
for r in range(nr-1, 0, -1):
_inv_shift_rows(s)
for i in range(16): s[i] = INV_SBOX[s[i]]
for i in range(16): s[i] ^= rks[r][i]
_inv_mix_columns(s)
_inv_shift_rows(s)
for i in range(16): s[i] = INV_SBOX[s[i]]
for i in range(16): s[i] ^= rks[0][i]
return bytes(s)
def aes_cbc_encrypt(data, key, iv):
rks, nr = _expand_key(key)
pad_len = 16 - len(data) % 16
data += bytes([pad_len]) * pad_len
out = b""
prev = iv
for i in range(0, len(data), 16):
blk = bytes(a ^ b for a, b in zip(data[i:i+16], prev))
prev = _aes_block(blk, rks, nr)
out += prev
return out
def aes_cbc_decrypt(data, key, iv):
rks, nr = _expand_key(key)
out = b""
prev = iv
for i in range(0, len(data), 16):
blk = _aes_block(data[i:i+16], rks, nr, decrypt=True)
out += bytes(a ^ b for a, b in zip(blk, prev))
prev = data[i:i+16]
pad_len = out[-1]
return out[:-pad_len]
def aes_ecb_decrypt(data, key):
rks, nr = _expand_key(key)
out = b"".join(_aes_block(data[i:i+16], rks, nr, decrypt=True) for i in range(0, len(data), 16))
pad_len = out[-1]
return out[:-pad_len]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): return None
# ==========================================
# 🟠 移动云盘 139 原生直连引擎 (带缓存与异步延时销毁)
# ==========================================
class Cloud139NativeEngine:
KEY1 = bytes.fromhex("73634235495062495331515373756c734e7253306c673d3d")
KEY2 = bytes.fromhex("7150714477323633586746674c337538")
CLIENT_KEY = "l3TryM&Q+X7@dzwk)qP"
HEADERS_BASE = {
"Accept": "application/json, text/plain, */*",
"Cms-Device": "default",
"mcloud-channel": "1000101",
"mcloud-client": "10701",
"mcloud-version": "7.14.0",
"Origin": "https://yun.139.com",
"Referer": "https://yun.139.com/w/",
"x-DeviceInfo": "||9|7.14.0|chrome|120.0.0.0|||windows 10||zh-CN|||",
"x-huawei-channelSrc": "10000034",
"x-inner-ntwk": "2",
"x-m4c-caller": "PC",
"x-m4c-src": "10002",
"x-SvcType": "1",
"Inner-Hcy-Router-Https": "1",
}
PERSONAL_HEADERS = {
"Caller": "web",
"Mcloud-Route": "001",
"X-Yun-Api-Version": "v1",
"X-Yun-App-Channel": "10000034",
"X-Yun-Channel-Source": "10000034",
"X-Yun-Client-Info": "||9|7.14.0|chrome|120.0.0.0|||windows 10||zh-CN|||dW5kZWZpbmVk||",
"X-Yun-Module-Type": "100",
"X-Yun-SvcType": "1",
}
def __init__(self):
self.TOKEN_CACHE = os.path.join(DB_DIR, ".cas139_token.json")
self.url_cache = {}
def _md5(self, s): return hashlib.md5(s.encode()).hexdigest()
def _sha1_hex(self, s): return hashlib.sha1(s.encode()).hexdigest()
def sorted_json(self, obj): return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def cache_token(self, auth, account):
try:
with open(self.TOKEN_CACHE, "w") as f: json.dump({"auth": auth, "account": account, "saved": int(time.time())}, f)
except OSError: pass
def load_cached_token(self):
try:
d = json.load(open(self.TOKEN_CACHE))
auth = d["auth"]
decoded = base64.b64decode(auth).decode()
parts = decoded.split(":")
if len(parts) < 3: return None
toks = parts[2].split("|")
if len(toks) < 4: return None
exp = int(toks[3]) // 1000
if exp > time.time(): return auth
except: pass
return None
def login(self, account, password):
import requests
logger.info("[139引擎] 开始执行 139 SSO 鉴权流程 (1:1 复刻 OpenList 驱动逻辑)...")
cfg = read_config()
mail_cookie_str = cfg.get("yun139_mail_cookie", "").strip()
sid = ""
rmkey = ""
# 1. 完全复刻 AList extractFastLoginCookies 逻辑,只提取不发多余请求
if mail_cookie_str:
for part in mail_cookie_str.split(";"):
part = part.strip()
if part.startswith("Os_SSo_Sid="):
sid = part[11:]
elif part.startswith("Os_SSO_Sid="):
sid = part[11:]
elif part.startswith("RMKEY="):
rmkey = part[6:]
if sid and rmkey:
logger.info(f"[139引擎] 🍪 成功从邮箱 Cookie 提取极速免签金牌 (sid: {sid[:6]}..., rmkey: {rmkey[:6]}...)")
# 2. 密码强登逻辑 (仅在未提供完整 Cookie 时触发,极易被风控)
if not sid or not rmkey:
logger.warning("[139引擎] ⚠️ 未提取到完整的 sid 或 rmkey,尝试账号密码直登 (此方式极易触发 139 滑块拦截)...")
import http.cookiejar
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
b64_acc = base64.b64encode(account.encode()).decode()
cguid = str(int(time.time() * 1000))
default_page = f"https://mail.10086.cn/default.html?s=1&v=0&u={b64_acc}&m=1&ec=S001&resource=indexLogin&clientid=1003&auto=on&cguid={cguid}"
try: opener.open(urllib.request.Request(default_page, headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read()
except: pass
mail_cookies = "; ".join(f"{c.name}={c.value}" for c in jar)
form_data = urllib.parse.urlencode({
"UserName": account, "passOld": "", "auto": "on",
"Password": self._sha1_hex("fetion.com.cn:" + password),
"webIndexPagePwdLogin": "1", "pwdType": "1",
"clientId": "1003", "authType": "2",
})
req = urllib.request.Request("https://mail.10086.cn/Login/Login.ashx", data=form_data.encode(), method="POST", headers={
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": mail_cookies, "Referer": default_page,
"Origin": "https://mail.10086.cn", "User-Agent": "Mozilla/5.0"
})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): return None
no_redirect = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), NoRedirect())
try:
resp = no_redirect.open(req, timeout=30)
location = resp.headers.get("Location", "")
except urllib.error.HTTPError as e:
location = e.headers.get("Location", "")
sid_m = re.search(r"sid=([^&]+)", location)
sid = sid_m.group(1) if sid_m else ""
if not sid:
for c in jar:
if c.name in ("Os_SSo_Sid", "Os_SSO_Sid"): sid = c.value
if not sid:
raise Exception("密码登录被官方风控拦截。请必须去浏览器重新登录 mail.10086.cn,抓取最新 Cookie 填入后台!")
for c in jar:
if c.name == "RMKEY": rmkey = c.value
if not sid or not rmkey:
raise Exception("鉴权参数缺失:未获取到 sid 或 rmkey。")
# 3. 复刻 OpenList 兑换 artifact 的精准发包 (application/xml)
logger.info("[139引擎] 准备使用 sid 和 RMKEY 兑换 artifact 票据...")
art_url = f"https://smsrebuild1.mail.10086.cn/setting/s?func={urllib.parse.quote('umc:getArtifact')}&sid={sid}&cguid={str(int(time.time() * 1000))}"
xml_body = b"<object><int name=\"appId\">16</int><int name=\"envAuth\">1</int></object>"
try:
art_resp = requests.post(art_url, data=xml_body, headers={
"Content-Type": "application/xml",
"Cookie": f"RMKEY={rmkey}",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"
}, timeout=15)
art_body = art_resp.text
except Exception as e:
raise Exception(f"获取 artifact 网络异常: {e}")
m = re.search(r'"artifact"\s*:\s*"([^"]+)"', art_body) or \
re.search(r'artifact\s*[:=]\s*"([^"]+)"', art_body) or \
re.search(r'<string[^>]*name="artifact"[^>]*>\s*([^<]+)\s*</string>', art_body)
if not m:
if "FA_INVALID_SESSION" in art_body:
logger.error("[139引擎] 🚨 致命错误:官方返回会话已过期 (User Session Expired)!")
raise Exception("邮箱 Cookie 已过期!必须重新抓取全新的 Cookie!")
else:
logger.error(f"[139引擎] ❌ 兑换 artifact 失败,官方返回: {art_body[:200]}")
raise Exception("未取到 artifact,请检查日志。")
artifact = m.group(1).strip()
# 5. 兑换最终 authToken (1:1 严格对齐 OpenList 源码参数)
logger.info("[139引擎] 拿到 artifact,正在兑换最终云盘 Authorization...")
body = {
"clientkey_decrypt": self.CLIENT_KEY,
"clienttype": "886",
"cpid": "507",
"dycpwd": artifact,
"extInfo": {"ifOpenAccount": "0"},
"loginMode": "0",
"msisdn": str(account).strip(),
"pintype": "13",
"secinfo": self._sha1_hex("fetion.com.cn:" + artifact).upper(),
"version": "20250901",
}
iv = os.urandom(16)
payload = base64.b64encode(iv + aes_cbc_encrypt(self.sorted_json(body).encode(), self.KEY1, iv)).decode()
# 💡 核心修复:完全抄袭 OpenList util.go 里的 ssoLoginHeaders 设备指纹特征,一字不差!
final_resp = requests.post("https://user-njs.yun.139.com/user/thirdlogin", data=payload.encode(), headers={
"hcy-cool-flag": "1",
"x-huawei-channelSrc": "10246600",
"x-sdk-channelSrc": "", # 漏掉的
"x-MM-Source": "0", # 漏掉的
"x-UserAgent": "android|23116PN5BC|android15|1.2.6|||1440x3200|10246600", # 注意:没有横杠!
"x-DeviceInfo": "4|127.0.0.1|5|1.2.6|Xiaomi|23116PN5BC||02-00-00-00-00-00|android 15|1440x3200|android|||", # 漏掉的最重要的设备指纹!
"Content-Type": "text/plain;charset=UTF-8",
"Accept-Encoding": "gzip",
"User-Agent": "okhttp/3.12.2"
}, timeout=30)
txt = final_resp.text.strip()
if txt.startswith("{"):
layer1 = json.loads(txt)
else:
blob = base64.b64decode(txt)
layer1 = json.loads(aes_cbc_decrypt(blob[16:], self.KEY1, blob[:16]))
if not layer1.get("data"):
logger.error(f"[139引擎] ❌ 兑换 AuthToken 被官方拒绝,返回内容: {layer1}")
raise Exception(f"兑换最终凭证失败: {layer1.get('msg', layer1.get('message', '未知错误'))}")
inner = aes_ecb_decrypt(bytes.fromhex(layer1["data"]), self.KEY2)
final_data = json.loads(inner)
auth_token = final_data.get("authToken", "")
if not auth_token: raise Exception("最后一步兑换 authToken 失败!")
account_ret = final_data.get("account", account)
auth = base64.b64encode(f"pc:{account_ret}:{auth_token}".encode()).decode()
self.cache_token(auth, account_ret)
logger.info(f"[139引擎] ✅ 鉴权圆满成功,云盘持久化通行证已生成并缓存!")
return auth
def ensure_auth(self):
cfg = read_config()
if cfg.get("yun139_token"): return cfg["yun139_token"].strip()
cached = self.load_cached_token()
if cached: return cached
acc, pwd = cfg.get("yun139_phone"), cfg.get("yun139_password")
if not (acc and pwd): raise Exception("未配置 139 凭证")
return self.login(acc, pwd)
def cal_sign(self, body, ts, rand_str):
enc = urllib.parse.quote(body, safe="~!*'()-._")
joined = "".join(sorted(enc))
b64 = base64.b64encode(joined.encode()).decode()
return self._md5(self._md5(b64) + self._md5(f"{ts}:{rand_str}")).upper()
def get_account(self, auth):
return base64.b64decode(auth).decode("utf-8", "replace").split(":")[1]
def cloud_request(self, url, body_obj, auth, extra_headers=None, timeout=30):
body = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False)
ts = time.strftime("%Y-%m-%d %H:%M:%S")
rand = "".join(random.choices(string.ascii_letters + string.digits, k=16))
headers = dict(self.HEADERS_BASE)
headers["Content-Type"] = "application/json;charset=UTF-8"
auth_clean = auth.strip()
headers["Authorization"] = auth_clean if auth_clean.startswith("Basic") else "Basic " + auth_clean
headers["mcloud-sign"] = f"{ts},{rand},{self.cal_sign(body, ts, rand)}"
if extra_headers: headers.update(extra_headers)
req = urllib.request.Request(url, data=body.encode("utf-8"), headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
raise Exception(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:300]}")
if isinstance(resp, dict) and resp.get("success") is False:
msg = f"code={resp.get('code')} message={resp.get('message')}"
raise Exception(f"API返回失败: {msg}")
return resp
def get_personal_host(self, auth):
resp = self.cloud_request("https://user-njs.yun.139.com/user/route/qryRoutePolicy", {
"userInfo": {"userType": 1, "accountType": 1, "accountName": self.get_account(auth)},
"modAddrType": 1}, auth)
data = resp.get("data") or {}
for pol in data.get("routePolicyList", []):
if pol.get("modName") == "personal": return pol["httpsUrl"].rstrip("/")
raise Exception(f"路由策略响应异常")
def make_part_infos(self, size):
part_size = 512 * 1024 * 1024 if size // (1024 ** 3) > 30 else 100 * 1024 * 1024
n = (size + part_size - 1) // part_size
return [{"partNumber": i + 1, "partSize": min(part_size, size - i * part_size), "parallelHashCtx": {"partOffset": i * part_size}} for i in range(min(n, 100))]
def _async_delete(self, file_id, delay, name):
import time
try:
m, s = divmod(int(delay), 60)
time_str = f"{m} 分 {s} 秒" if m > 0 else f"{s} 秒"
logger.info(f"[139销毁队列] 💣 注入成功: {name} (ID: {file_id}) 将在 {time_str} 后执行永久删除")
time.sleep(delay)
auth = self.ensure_auth()
cfg = read_config()
control_host = cfg.get("yun139_control_host", "").strip() or "https://personal-kd-njs.yun.139.com"
delete_url = control_host.rstrip('/') + "/hcy/file/batchDelete"
body = {"fileIds": [str(file_id)]}
resp = self.cloud_request(delete_url, body, auth, self.PERSONAL_HEADERS)
logger.info(f"[139销毁回执] 永久删除原始响应: {resp}")
if resp.get("success") or resp.get("code") == "0":
logger.info(f"[139销毁成功] 💥 永久删除成功,网盘空间已即时真实释放: {name}")
else:
logger.warning(f"[139销毁异常] {name} 销毁失败: {resp}")
except Exception as e:
logger.error(f"[139销毁报错] {name}: {e}")
def empty_recycle_bin(self):
try:
logger.info(f"[139清理] 🟠 开始执行移动云回收站一键清空任务...")
auth = self.ensure_auth()
cfg = read_config()
control_host = cfg.get("yun139_control_host", "").strip() or "https://personal-kd-njs.yun.139.com"
clear_url = control_host.rstrip('/') + "/hcy/recyclebin/clear"
resp = self.cloud_request(clear_url, {}, auth, self.PERSONAL_HEADERS)
if resp.get("success") or resp.get("code") == "0":
logger.info(f"[139清理成功] 💥 139 回收站已彻底清空,空间全部真实释放!")
else:
logger.warning(f"[139清理异常] 清空失败: {resp}")
except Exception as e:
logger.error(f"[139清理报错]: {e}")
def get_direct_link(self, sha256_hash, file_size, file_name, parent_id="/"):
import threading
cfg = read_config()
try: delay_sec = int(cfg.get('delete_delay_139', 14400))
except: delay_sec = 14400
try: cache_ttl = int(cfg.get('yun139_link_expire', 7200))
except: cache_ttl = 7200
custom_folder_id = cfg.get("yun139_temp_folder_id", "").strip()
if custom_folder_id:
parent_id = custom_folder_id
now = time.time()
if sha256_hash in self.url_cache:
cached_url, exp = self.url_cache[sha256_hash]
if now < exp:
logger.info(f"[139引擎] ⚡ 命中本地内存直链缓存,免查官方 API: {file_name}")
return cached_url
auth = self.ensure_auth()
try: host = self.get_personal_host(auth)
except:
os.path.exists(self.TOKEN_CACHE) and os.remove(self.TOKEN_CACHE)
auth = self.ensure_auth()
host = self.get_personal_host(auth)
body = {
"contentHash": sha256_hash, "contentHashAlgorithm": "SHA256", "contentType": "application/octet-stream", "parallelUpload": False,
"partInfos": self.make_part_infos(file_size), "size": file_size, "parentFileId": parent_id, "name": file_name, "type": "file", "fileRenameMode": "auto_rename",
}
resp = self.cloud_request(host + "/file/create", body, auth, self.PERSONAL_HEADERS)
data = resp.get("data", {})
fid = ""
if data.get("rapidUpload") and not data.get("partInfos"):
fid = data.get("fileId", "")
elif data.get("exist"):
fid = data.get("fileId", "")
if not fid: raise Exception("云端已存在该文件但未返回 fileId")
if not fid: raise Exception("秒传未命中,CAS已失效或服务端要求真实切片上传")
dl_resp = self.cloud_request(host + "/file/getDownloadUrl", {"fileId": str(fid)}, auth, self.PERSONAL_HEADERS)
dl_data = dl_resp.get("data")
final_url = ""
if isinstance(dl_data, str) and dl_data.startswith("http"): final_url = dl_data
elif isinstance(dl_data, dict):
for k in ("downloadUrl", "downloadURL", "url", "fileUrl", "cdnUrl"):
if dl_data.get(k):
final_url = dl_data[k]
break
if not final_url: raise Exception("秒传成功,但无法解析下载链接")
self.url_cache[sha256_hash] = (final_url, now + cache_ttl)
if delay_sec > 0:
threading.Thread(target=self._async_delete, args=(fid, delay_sec, file_name), daemon=True).start()
return final_url
cloud139_native = Cloud139NativeEngine()
# ==========================================
# ☁️ 天翼云核心功能类 (个人云专属)
# ==========================================
class TianyiPersonalUploader:
def __init__(self):
self.rsa_keys = {}
self.session = requests.Session()
self.app_key = '600100422'
def _md5(self, text): return hashlib.md5(text.encode('utf-8')).hexdigest().upper()
def _random_string(self, length=16): return ''.join(random.choices('0123456789abcdef', k=length))
def _get_timestamp(self): return str(int(time.time() * 1000))
def _encode_uri(self, text): return urllib.parse.quote(text, safe='~()*!.\'-_')
def _get_slice_size(self, file_size):
try: size = int(file_size)
except: return '10485760'
D = 10485760
if size > D * 2 * 999: return str(max(math.ceil(size / 1999 / D), 5) * D)
elif size > D * 999: return str(D * 2)
return str(D)
def get_base_headers(self, cookie):
return {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Cookie': cookie, 'Accept': 'application/json;charset=UTF-8'}
def get_rsa_key(self, session_key, cookie):
if session_key in self.rsa_keys: return self.rsa_keys[session_key]
ts = self._get_timestamp()
sign = self._md5(f"AppKey={self.app_key}&Timestamp={ts}")
url = f"https://cloud.189.cn/api/security/generateRsaKey.action?sessionKey={urllib.parse.quote(session_key)}"
h = self.get_base_headers(cookie)
h.update({'Sign-Type': '1', 'Signature': sign, 'Timestamp': ts, 'AppKey': self.app_key, 'SessionKey': session_key})
try:
res = self.session.get(url, headers=h, timeout=10).json()
if 'pubKey' in res:
self.rsa_keys[session_key] = res
return res
raise Exception(f"获取个人云公钥失败: {res}")
except Exception as e:
raise e
def build_request(self, params, uri, req_id, session_key, cookie):
rsa = self.get_rsa_key(session_key, cookie)
ukey, ts = self._random_string(16), self._get_timestamp()
p_str = '&'.join([f"{k}={v}" for k, v in params.items()])
enc_p = AES.new(ukey.encode('utf-8'), AES.MODE_ECB).encrypt(pad(p_str.encode('utf-8'), 16)).hex().upper()
rsa_c = PKCS1_v1_5.new(RSA.import_key(f"-----BEGIN PUBLIC KEY-----\n{rsa['pubKey']}\n-----END PUBLIC KEY-----"))
enc_t = base64.b64encode(rsa_c.encrypt(ukey.encode('utf-8'))).decode('utf-8')
sign = hmac.new(ukey.encode('utf-8'), f"SessionKey={session_key}&Operate=GET&RequestURI={uri}&Date={ts}¶ms={enc_p}".encode('utf-8'), hashlib.sha1).hexdigest().upper()
h = self.get_base_headers(cookie)
h.update({'X-Request-Date': ts, 'X-Request-ID': req_id, 'SessionKey': session_key, 'EncryptionText': enc_t, 'PkId': rsa['pkId'], 'Signature': sign})
return f"https://upload.cloud.189.cn{uri}?params={enc_p}", h
def get_personal_items(self, folder_id, cookie):
url = f"https://cloud.189.cn/api/open/file/listFiles.action?folderId={folder_id}&pageNum=1&pageSize=1000"
try:
res = self.session.get(url, headers=self.get_base_headers(cookie), timeout=10).json()
ao = res.get('fileListAO', {})
return [{'fileName': f['name'], 'fileId': f['id'], 'isFolder': 'id' in f and 'fileId' not in f} for f in ao.get('folderList', []) + ao.get('fileList', [])]
except: return []
def delete_item(self, file_id, cookie):
url = f"https://cloud.189.cn/api/open/file/deleteFile.action?fileId={file_id}"
try: return self.session.get(url, headers=self.get_base_headers(cookie), timeout=10).status_code == 200
except: return False
def empty_personal_recycle(self, session_key, cookie):
url = "https://cloud.189.cn/api/open/batch/createBatchTask.action"
payload = {"type": "EMPTY_RECYCLE", "taskInfos": "[]", "targetFolderId": "", "sessionKey": session_key}
try:
res = self.session.post(url, data=payload, headers=self.get_base_headers(cookie), timeout=10).json()
if str(res.get("res_code")) == "0": return True
except: pass
return False
def rapid_upload_personal(self, folder_id, md5, size, smd5, safe_name, session_key, cookie):
f_md5, s_md5 = str(md5).upper(), str(smd5).upper()
items = self.get_personal_items(folder_id, cookie)
for i in items:
if i['fileName'] == safe_name or f_md5 in i['fileName']: return i['fileId']
req_id = str(uuid.uuid4())
slice_size = self._get_slice_size(size)
init_p = {'parentFolderId': folder_id, 'fileName': self._encode_uri(safe_name), 'fileSize': str(size), 'sliceSize': slice_size, 'fileMd5': f_md5, 'sliceMd5': s_md5, 'lazyCheck': '1', 'opertype': '3'}
url, h = self.build_request(init_p, '/person/initMultiUpload', req_id, session_key, cookie)
res = self.session.get(url, headers=h, timeout=10).json()
if res.get('code') == 'SUCCESS':
up_id = res['data']['uploadFileId']
ck_p = {'fileMd5': f_md5, 'sliceMd5': s_md5, 'uploadFileId': up_id}
url, h = self.build_request(ck_p, '/person/checkTransSecond', req_id, session_key, cookie)
if self.session.get(url, headers=h, timeout=10).json().get('data', {}).get('fileDataExists'):
cm_p = {'uploadFileId': up_id, 'fileMd5': f_md5, 'sliceMd5': s_md5, 'lazyCheck': '1', 'opertype': '3'}
url, h = self.build_request(cm_p, '/person/commitMultiUploadFile', req_id, session_key, cookie)
cm_res = self.session.get(url, headers=h, timeout=10).json()
fid = cm_res.get('file', {}).get('id') or cm_res.get('file', {}).get('userFileId')
if fid: return fid
raise Exception(f"个人云秒传失败 (响应信息): {res}")
def get_direct_url(self, file_id, session_key, cookie, client_ua=""):
url = "https://cloud.189.cn/api/portal/getFileInfo.action"
params = {"fileId": str(file_id), "noCache": str(random.random())}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Cookie": cookie, "Accept": "application/json;charset=UTF-8", "Referer": "https://cloud.189.cn/"
}
try:
res = self.session.get(url, params=params, headers=headers, timeout=10).json()
down_url = res.get('downloadUrl') or res.get('fileDownloadUrl')
if down_url:
api_url = down_url.replace('&', '&')
if api_url.startswith('//'): api_url = 'https:' + api_url
elif not api_url.startswith('http'): api_url = 'https://' + api_url
unwrap_headers = {
"User-Agent": client_ua if client_ua else headers["User-Agent"],
"Cookie": cookie, "Accept-Encoding": "identity"
}
unwrap_res = requests.get(api_url, headers=unwrap_headers, allow_redirects=False, timeout=10)
status_code = unwrap_res.status_code
gateway_url = None
if status_code in [301, 302, 303, 307, 308]:
loc = unwrap_res.headers.get('Location')
if loc and loc.startswith("http://"): loc = loc.replace("http://", "https://", 1)
gateway_url = loc
elif status_code == 200:
if api_url.startswith("http://"): api_url = api_url.replace("http://", "https://", 1)
gateway_url = api_url
else: raise Exception(f"底层破冰失败 (HTTP {status_code})")
if gateway_url:
try:
probe_res = requests.get(gateway_url, headers=unwrap_headers, allow_redirects=False, timeout=5)
if probe_res.status_code in [301, 302, 303, 307, 308]:
deep_loc = probe_res.headers.get('Location')
if deep_loc: logger.info(f"[无痕探测] 个人云已分配物理节点: {urllib.parse.urlparse(deep_loc).netloc}")
except: pass
return gateway_url
raise Exception(f"提取个人云直链失败(OpenList逻辑): {res}")
except Exception as e: raise e
personal_client = TianyiPersonalUploader()
# ==========================================
# 🧹 虚空造物清理工
# ==========================================
def delayed_delete_openlist_file(host, token, dir_path, file_name, delay=120):
time.sleep(delay)
try:
headers = {"Authorization": token} if token else {}
payload = {"dir": dir_path, "names": [file_name]}
requests.post(f"{host}/api/fs/remove", json=payload, headers=headers, timeout=10)
except: pass
def cleanup_worker(name, f_md5, fam_id, fold_id, session_key):
with cache_lock:
if f_md5 not in upload_cache: return
expire_time = upload_cache[f_md5]['expire']
expire_str = time.strftime("%H:%M:%S", time.localtime(expire_time))
logger.info(f"[定时销毁] 预定于 {expire_str} 执行家庭云清理任务。")
while True:
with cache_lock:
if f_md5 not in upload_cache: return
expire_time = upload_cache[f_md5]['expire']
now = time.time()
if now >= expire_time: break
sleep_time = expire_time - now
if sleep_time > 0: time.sleep(sleep_time + 1)
try:
items = family_client.get_family_items(fam_id, fold_id, session_key)
real_fid = next((i['fileId'] for i in items if f_md5 in i['fileName'] or i['fileName'] == name), None)
if real_fid and family_client.delete_item(fam_id, real_fid, session_key):
time.sleep(2)
if family_client.empty_family_recycle(fam_id, session_key):
logger.info(f"[执行销毁] 家庭云文件已清除。")
except: pass
with cache_lock:
if f_md5 in upload_cache: del upload_cache[f_md5]
def personal_cleanup_worker(file_id, session_key, cookie, f_md5):
with cache_lock:
if f_md5 not in upload_cache: return
expire_time = upload_cache[f_md5]['expire']
expire_str = time.strftime("%H:%M:%S", time.localtime(expire_time))
logger.info(f"[定时销毁] 预定于 {expire_str} 执行个人云清理任务。")
while True:
with cache_lock:
if f_md5 not in upload_cache: return
expire_time = upload_cache[f_md5]['expire']
now = time.time()
if now >= expire_time: break
sleep_time = expire_time - now
if sleep_time > 0: time.sleep(sleep_time + 1)
try:
if personal_client.delete_item(file_id, cookie):
time.sleep(2)
if personal_client.empty_personal_recycle(session_key, cookie): logger.info(f"[执行销毁] 个人云文件已清除。")
except: pass
with cache_lock:
if f_md5 in upload_cache: del upload_cache[f_md5]
@app_main.route('/play', methods=['GET', 'HEAD'])
def play():
cas = request.args.get('cas')
drive_type = request.args.get('drive', '189').strip()
file_path_param = request.args.get('path', '').strip()
show_name_from_url = request.args.get('show', '').strip()
client_ua = request.headers.get('User-Agent', '')
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
if client_ip: client_ip = client_ip.split(',')[0].strip()
cfg = read_config()
if str(cfg.get('force_mode_b', 'false')).lower() == 'true' and cas and drive_type == '189':
drive_type = '189_native'
range_header = request.headers.get('Range', '')
if request.method == 'GET' and range_header and drive_type not in ['189_native', '139_native', '139', 'direct']:
match = re.match(r'bytes=\d+-(\d+)', range_header)
if match:
end_byte = int(match.group(1))
if 1 < end_byte < 2 * 1024 * 1024:
logger.warning(f"[防刷拦截] 拒绝极小预读嗅探 (Range: {range_header}),保护云盘!")
return "Sniff Blocked", 403
# ==========================================
# ⚡ 模式 B:189 原生虚空直连解析
# ==========================================
if drive_type == '189_native':
if not cas: return "❌ 缺失 cas 核心代码", 400
ol_host = cfg.get('openlist_host', 'http://127.0.0.1:5244').rstrip('/')
ol_token = cfg.get('openlist_token', '')
headers = {"Authorization": ol_token} if ol_token else {}
try:
j = parse_cas_content(cas)
if not j: return "❌ CAS 解析失败", 400
f_md5 = j.get('md5') or j.get('fileMd5') or j.get('fileMD5')
safe_name = j.get('name') or j.get('fileName') or "unknown.mp4"
cas_payload = base64.b64encode(json.dumps(j, ensure_ascii=False).encode('utf-8')).decode('utf-8')
with cache_lock:
if f_md5 in native_link_cache:
cached_url, expire_time = native_link_cache[f_md5]
if time.time() < expire_time: return redirect(cached_url)
else: del native_link_cache[f_md5]
logger.info(f"========== 🕵️♂️ 189原生直连(模式B) 链路日志 START ==========")
logger.info(f"▶️ [1] 请求原生直通: {safe_name}")
target_dir = cfg.get('network_cas_path_native', '/177/177-原生直连').rstrip('/')
temp_file_name = f"temp_play_{f_md5}.cas"
target_path = f"{target_dir}/{temp_file_name}"
put_headers = headers.copy()
put_headers.update({"File-Path": urllib.parse.quote(target_path, safe='/'), "Content-Length": str(len(cas_payload.encode('utf-8'))), "Content-Type": "application/octet-stream"})
logger.info(f"☁️ [2] 虚空造物:正在向云端动态写入临时伪装凭证...")
put_res = requests.put(f"{ol_host}/api/fs/put", data=cas_payload.encode('utf-8'), headers=put_headers, timeout=10)
if put_res.status_code != 200:
logger.error(f"❌ [致命错误] OpenList 写入临时文件失败! 状态码: {put_res.status_code}, 响应: {put_res.text}")
return "写入OpenList失败", 500
get_res = requests.post(f"{ol_host}/api/fs/get", json={"path": target_path, "password": ""}, headers=headers, timeout=10).json()
data_obj = get_res.get('data')
raw_url = data_obj.get('raw_url') if isinstance(data_obj, dict) else None
if not raw_url:
logger.error(f"❌ OpenList 获取直链失败, 响应: {get_res}")
return "无 raw_url", 500
logger.info(f"📥 [3] 成功获取底层 raw_url, 启动防风控嗅探...")
unwrap_headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'accept-encoding', 'authorization']}
unwrap_headers['Accept-Encoding'] = 'identity'
if ol_token: unwrap_headers["Authorization"] = ol_token
if not any(k.lower() == 'range' for k in unwrap_headers): unwrap_headers["Range"] = "bytes=0-"
try:
unwrap_res = requests.get(raw_url, headers=unwrap_headers, allow_redirects=False, timeout=15, stream=True)
status_code = unwrap_res.status_code
headers_dict = dict(unwrap_res.headers)
unwrap_res.close()
if status_code == 500:
logger.warning(f"⚠️ [警告] 嗅探遇 500,去 Range 破冰...")
range_key = next((k for k in unwrap_headers if k.lower() == 'range'), None)
if range_key: del unwrap_headers[range_key]
unwrap_res = requests.get(raw_url, headers=unwrap_headers, allow_redirects=False, timeout=15, stream=True)
status_code = unwrap_res.status_code
headers_dict = dict(unwrap_res.headers)
unwrap_res.close()
logger.info(f"🔍 [4] 嗅探完成!状态码: {status_code}")
if not hasattr(delayed_delete_openlist_file, "last_trigger"): delayed_delete_openlist_file.last_trigger = {}
now_time = time.time()
if now_time - delayed_delete_openlist_file.last_trigger.get(temp_file_name, 0) > 120:
delayed_delete_openlist_file.last_trigger[temp_file_name] = now_time
threading.Thread(target=delayed_delete_openlist_file, args=(ol_host, ol_token, target_dir, temp_file_name, 120), daemon=True).start()
final_return_url = None
if status_code in [301, 302, 303, 307, 308]: final_return_url = headers_dict.get('Location')
elif status_code in [200, 206]: final_return_url = raw_url
if final_return_url:
with cache_lock: native_link_cache[f_md5] = (final_return_url, time.time() + 7200)
logger.info(f"✅ [5] 完美触发!拿到 189 官方直链!")
logger.info(f"[播放放行] 节点: {urllib.parse.urlparse(final_return_url).netloc} | 地址: {truncate_url(final_return_url)}")
logger.info(f"========== 🕵️♂️ 189原生直连(模式B) 链路日志 END ==========\n")
return redirect(final_return_url)
else:
logger.error("❌ 原生直连获取直链异常,未找到有效的重定向地址")
return "获取直链异常", 500
except Exception as unwrap_e:
logger.error(f"❌ 原生直连嗅探异常: {unwrap_e}")
return "获取直链异常", 500
except Exception as e:
logger.error(f"❌ 模式B 处理全局异常: {e}")
return "处理异常", 500
# ==========================================
# 🟠 新增:139 原生直连解析 (参数化,彻底脱离 cas 文件)
# ==========================================
if drive_type == '139_native':
sha256_hash = request.args.get('sha256')
file_size = request.args.get('size')
file_name = request.args.get('name')
if not sha256_hash or not file_size:
return "❌ 缺失特征码参数", 400
file_size = int(file_size)
file_name = urllib.parse.unquote(file_name) if file_name else f"unknown_{sha256_hash[:8]}.mp4"
with cache_lock:
if sha256_hash in native_link_cache:
cached_url, expire_time = native_link_cache[sha256_hash]
if time.time() < expire_time: return redirect(cached_url)
else: del native_link_cache[sha256_hash]
logger.info(f"========== 🕵️♂️ 139 原生直连链路日志 START ==========")
logger.info(f"▶️ [1] 请求无状态 139 原生直通: {file_name}")
try:
dl_url = cloud139_native.get_direct_link(sha256_hash, file_size, file_name)
if dl_url:
with cache_lock: native_link_cache[sha256_hash] = (dl_url, time.time() + 7200)
logger.info(f"✅ [2] 完美触发!拿到 139 官方脱壳直链!")
logger.info(f"[播放放行] 节点: {urllib.parse.urlparse(dl_url).netloc} | 地址: {truncate_url(dl_url)}")
logger.info(f"========== 🕵️♂️ 139 原生直连链路日志 END ==========\n")
return redirect(dl_url)
else:
logger.error("❌ 139 直连获取直链异常,链接为空")
return "获取直链异常", 500
except Exception as e:
logger.error(f"❌ 139 原生直连异常: {e}")
return str(e), 500
# ==========================================
# 🎬 常规媒体直通 / 🟠 移动云 139 通用跳转 (老模式)
# ==========================================
if drive_type in ['139', 'direct']:
if not file_path_param: return "❌ 请求缺少 path 参数", 400
with cache_lock:
if file_path_param in native_link_cache:
cached_url, expire_time = native_link_cache[file_path_param]
if time.time() < expire_time: return redirect(cached_url)
else: del native_link_cache[file_path_param]
is_139 = (drive_type == '139')
ol_host = cfg.get('openlist_host_139' if is_139 else 'openlist_host', 'http://127.0.0.1:5244').rstrip('/')
ol_token = cfg.get('openlist_token_139' if is_139 else 'openlist_token', '')
headers = {"Authorization": ol_token} if ol_token else {}
log_tag = "139云盘" if is_139 else "常规真视频"
logger.info(f"========== 🕵️♂️ {log_tag} 链路日志 START ==========")
logger.info(f"▶️ [1] 触发请求: {file_path_param}")
try:
get_res = requests.post(f"{ol_host}/api/fs/get", json={"path": file_path_param, "password": ""}, headers=headers, timeout=15)
if get_res.status_code == 200:
data_obj = get_res.json().get('data')
raw_url = data_obj.get('raw_url') if isinstance(data_obj, dict) else None
if raw_url:
logger.info(f"📥 [2] 成功获取底层 raw_url...")
unwrap_headers = {
"User-Agent": request.headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'),
"Accept-Encoding": "identity"
}
if ol_token and ol_host in raw_url:
unwrap_headers["Authorization"] = ol_token
client_range = request.headers.get('Range')
unwrap_headers["Range"] = client_range if client_range else "bytes=0-"
try:
unwrap_res = requests.get(raw_url, headers=unwrap_headers, allow_redirects=False, timeout=15, stream=True)
status_code = unwrap_res.status_code
headers_dict = dict(unwrap_res.headers)
unwrap_res.close()
if status_code == 500:
range_key = next((k for k in unwrap_headers if k.lower() == 'range'), None)
if range_key: del unwrap_headers[range_key]
unwrap_res = requests.get(raw_url, headers=unwrap_headers, allow_redirects=False, timeout=15, stream=True)
status_code = unwrap_res.status_code
headers_dict = dict(unwrap_res.headers)
unwrap_res.close()
logger.info(f"🔍 [3] 嗅探完成!状态码: {status_code}")
if status_code in [301, 302, 303, 307, 308]:
final_cdn_url = headers_dict.get('Location')
if final_cdn_url:
expire_sec = int(cfg.get('yun139_link_expire', 7200)) if is_139 else int(cfg.get('link_expire', 120))
with cache_lock: native_link_cache[file_path_param] = (final_cdn_url, time.time() + expire_sec)
logger.info(f"[播放放行] 节点: {urllib.parse.urlparse(final_cdn_url).netloc} | 地址: {truncate_url(final_cdn_url)}")
logger.info(f"========== 🕵️♂️ {log_tag} 链路日志 END ==========\n")
return redirect(final_cdn_url)
else:
logger.error(f"❌ {log_tag} 缺失直链跳转地址")
return "缺失直链", 500
elif status_code in [200, 206]:
expire_sec = int(cfg.get('yun139_link_expire', 7200)) if is_139 else int(cfg.get('link_expire', 120))
with cache_lock: native_link_cache[file_path_param] = (raw_url, time.time() + expire_sec)
logger.info(f"[播放放行] 节点: {urllib.parse.urlparse(raw_url).netloc} | 地址: {truncate_url(raw_url)}")
logger.info(f"========== 🕵️♂️ {log_tag} 链路日志 END ==========\n")
return redirect(raw_url)
else:
logger.error(f"❌ {log_tag} 状态码异常: {status_code}")
return "状态码异常", 500
except Exception as unwrap_e:
logger.error(f"❌ {log_tag} 获取直链嗅探异常: {unwrap_e}")
return "获取直链异常", 500
else:
logger.error(f"❌ {log_tag} 接口返回数据中没有 raw_url")
return "无 raw_url", 500
else:
logger.error(f"❌ {log_tag} 接口破冰失败,状态码: {get_res.status_code}")
return f"破冰失败", 500
except Exception as e:
logger.error(f"❌ {log_tag} 接口通信全局异常: {e}")
return "接口通信异常", 500
# ==========================================
# 🔵 模式 A:四核统一矩阵攻击队列 (容灾滑点)
# ==========================================
if cas:
safe_name = "未知文件"
try:
j = parse_cas_content(cas)
if not j: raise Exception("CAS 数据解析异常或为空")
f_md5 = str(j.get('md5') or j.get('fileMd5') or j.get('fileMD5')).upper()
if not is_allowed_by_anti_scan(client_ip, f_md5):
logger.warning(f"[防刷拦截] 拒绝播放器密集并发嗅探!(MD5: {f_md5[:8]})")
return "Sniff Blocked", 429
s_md5 = str(j.get('slice_md5') or j.get('sliceMd5') or j.get('sliceMD5')).upper()
raw_size = j.get('size') or j.get('fileSize')
human_size = format_size(raw_size)
name = j.get('name') or j.get('fileName')
base_safe_name = "".join(x for x in name if x not in r'\/:*?"<>|')
if show_name_from_url:
clean_show = re.sub(r'\s*\(\d{4}\)', '', show_name_from_url)
show_identifier = re.sub(r'(?i)\s*(HFR|HQ|IQ|HDR|SDR|DV|4K|1080p|720p)\b', '', clean_show).strip()
else:
clean_show = re.split(r'(?i)\.S\d+|\.E\d+|-第\d+集', base_safe_name)[0]
clean_show = re.sub(r'\s*\(\d{4}\)', '', clean_show)
show_identifier = re.sub(r'(?i)\s*(HFR|HQ|IQ|HDR|SDR|DV|4K|1080p|720p)\b', '', clean_show).strip()
bind_key = show_identifier
ext = os.path.splitext(base_safe_name)[1]
if not ext or len(ext) > 6: ext = ".mp4"
ep_num = None
for p in [r'(?i)E(?:P)?\s*0*(\d+)', r'第\s*0*(\d+)\s*[集话期]', r'(?:\[|\()0*(\d+)(?:\]|\))', r'(?i)episode\s*0*(\d+)']:
m = re.search(p, base_safe_name)
if m:
ep_num = int(m.group(1)); break
s_match = re.search(r'(?i)S0*(\d+)', base_safe_name)
s_num = int(s_match.group(1)) if s_match else 1
year_match = re.search(r'(?<!\d)(19\d{2}|20\d{2})(?!\d)', base_safe_name)
year_str = f".{year_match.group(1)}" if year_match else ""
if show_identifier and ep_num is not None: safe_name = f"{show_identifier}.S{s_num:02d}E{ep_num:02d}{year_str}{ext}"
else: safe_name = f"{show_identifier}{year_str}{ext}" if show_identifier else base_safe_name
tags = []
for t in re.findall(r'(?i)\b(1080p|2160p|4K|DV|HQ|HDR|SDR|IQ|HFR|H\.?26[45]|x\.?26[45])\b', base_safe_name + " " + show_name_from_url):
t_u = t.upper().replace('.', '')
if t_u == '1080P': t_u = '1080p'
elif t_u == '2160P': t_u = '2160p'
elif t_u == 'X264': t_u = 'H264'
elif t_u == 'X265': t_u = 'H265'
if t_u not in tags: tags.append(t_u)
tag_str = "." + ".".join(tags) if tags else ""
if tag_str:
if safe_name.endswith(ext): safe_name = safe_name[:-len(ext)] + tag_str + ext
else: safe_name = safe_name + tag_str + ext
with cache_lock:
name_collided = any(v.get('fid') != 'processing' and f_md5 != k and safe_name == (v.get('show_name') + ext if 'show_name' in v else "") for k, v in upload_cache.items())
if name_collided:
size_tag = f".{human_size.replace(' ', '')}"
safe_name = safe_name[:-len(ext)] + size_tag + ext if safe_name.endswith(ext) else safe_name + size_tag + ext
current_time = time.time()
download_url = None
is_processing_by_others = False
with cache_lock:
if f_md5 in upload_cache:
cached_data = upload_cache[f_md5]
if cached_data.get('download_url'):
link_expire_sec = int(cfg.get('link_expire', 120))
if current_time - cached_data.get('url_time', 0) < link_expire_sec:
download_url = cached_data['download_url']
else:
logger.info(f"♻️ [票据过期] 距离上次获取已超 {link_expire_sec} 秒,强行作废,提取新鲜直链...")
upload_cache[f_md5]['download_url'] = None
else:
is_processing_by_others = True
if is_processing_by_others and not download_url:
for _ in range(50):
time.sleep(0.2)
with cache_lock:
if f_md5 in upload_cache and upload_cache[f_md5].get('download_url'):
download_url = upload_cache[f_md5]['download_url']
break
if not download_url and not is_processing_by_others:
with cache_lock: upload_cache[f_md5] = {'fid': 'processing', 'expire': current_time + cfg.get('delete_delay', 600), 'download_url': None}
valid_accs = []
for i, acc in enumerate(cfg.get('accounts', [])):
fam_valid = bool(acc.get('family_id') and acc.get('family_folder_id'))
per_valid = bool(acc.get('personal_folder_id'))
can_login = bool((acc.get('username') and acc.get('password')) or i == 3)
if (fam_valid or per_valid) and (acc.get('session_key') or can_login):
valid_accs.append((i, acc))
if not valid_accs: return "未配置任何有效网盘卡槽", 500
strategy = cfg.get('cloud_strategy', 'hash')
strategy_to_idx = {'slot1': 0, 'slot2': 1, 'slot3': 2, 'slot4': 3}
if strategy in strategy_to_idx:
target_idx = strategy_to_idx[strategy]
vanguard = next((a for a in valid_accs if a[0] == target_idx), None)
if vanguard:
candidates = [vanguard] + [a for a in valid_accs if a[0] != target_idx]
else:
logger.warning(f"⚠️ [防死锁] 优先卡槽 {target_idx+1} 未就绪,退守全局哈希滑点调度!")
hash_idx = int(hashlib.md5(bind_key.encode('utf-8')).hexdigest(), 16) % len(valid_accs)
candidates = valid_accs[hash_idx:] + valid_accs[:hash_idx]
elif strategy == 'hash':
hash_idx = int(hashlib.md5(bind_key.encode('utf-8')).hexdigest(), 16) % len(valid_accs)
candidates = valid_accs[hash_idx:] + valid_accs[:hash_idx]
else:
candidates = valid_accs.copy()
random.shuffle(candidates)
mode_a_cfg = cfg.get('mode_a_channel', 'mix_f2p')
search_sequence = []
if mode_a_cfg == 'mix_f2p':
search_sequence = [('family', i, a) for i, a in candidates] + [('personal', i, a) for i, a in candidates]
elif mode_a_cfg == 'mix_p2f':
search_sequence = [('personal', i, a) for i, a in candidates] + [('family', i, a) for i, a in candidates]
elif mode_a_cfg == 'personal':
search_sequence = [('personal', i, a) for i, a in candidates]
else:
search_sequence = [('family', i, a) for i, a in candidates]
final_channel_type = None
for c_type, s_idx, acc in search_sequence:
fam_id, fam_fd = acc.get('family_id'), acc.get('family_folder_id')
per_fd = acc.get('personal_folder_id')
if c_type == 'family' and not (fam_id and fam_fd): continue
if c_type == 'personal' and not per_fd: continue
target_sk = acc.get('session_key')
target_cookie = acc.get('cookie')
if not target_sk or not target_cookie:
target_sk, target_cookie = refresh_account_logic(s_idx, cfg)
if not target_sk: continue
log_tag = f"卡槽{s_idx+1}({'个人' if c_type=='personal' else '家庭'})"
logger.info(f"[{log_tag}调度] 开始处理: {safe_name}")
try:
if c_type == 'personal':
real_fid = personal_client.rapid_upload_personal(per_fd, f_md5, raw_size, s_md5, safe_name, target_sk, target_cookie)
download_url = personal_client.get_direct_url(real_fid, target_sk, target_cookie, client_ua)
logger.info(f"[{log_tag}秒传就绪] {safe_name} ({human_size})")
else:
items = family_client.get_family_items(fam_id, fam_fd, target_sk)
real_fid = next((i['fileId'] for i in items if f_md5 in i['fileName'] or i['fileName'] == safe_name), None)
if not real_fid:
real_fid = family_client.rapid_upload(fam_id, fam_fd, f_md5, raw_size, s_md5, safe_name, target_sk)
logger.info(f"[{log_tag}秒传成功] {safe_name} ({human_size})")
else:
logger.info(f"[{log_tag}秒传命中] {safe_name} ({human_size})")
download_url = family_client.get_download_url(fam_id, real_fid, target_sk, client_ua)
if download_url:
final_channel_type = c_type
with cache_lock:
upload_cache[f_md5]['fid'] = real_fid
upload_cache[f_md5]['download_url'] = download_url
upload_cache[f_md5]['url_time'] = current_time
upload_cache[f_md5]['is_personal'] = (c_type == 'personal')
if c_type == 'personal': threading.Thread(target=personal_cleanup_worker, args=(real_fid, target_sk, target_cookie, f_md5), daemon=True).start()
else: threading.Thread(target=cleanup_worker, args=(safe_name, f_md5, fam_id, fam_fd, target_sk), daemon=True).start()
break
except Exception as e:
err_str = str(e).lower()
if "black list" in err_str or "security check" in err_str:
logger.error(f"[{log_tag}版权拦截] 黑名单限制,强制阻断!")
break
elif any(k in err_str for k in ["auth_fail", "session", "111", "notlogin"]):
logger.warning(f"[{log_tag}凭证失效] 尝试执行自愈...")
target_sk, target_cookie = refresh_account_logic(s_idx, cfg)
if target_sk:
try:
if c_type == 'personal':
real_fid = personal_client.rapid_upload_personal(per_fd, f_md5, raw_size, s_md5, safe_name, target_sk, target_cookie)
download_url = personal_client.get_direct_url(real_fid, target_sk, target_cookie, client_ua)
logger.info(f"[{log_tag}秒传就绪] {safe_name} ({human_size})")
else:
items = family_client.get_family_items(fam_id, fam_fd, target_sk)
real_fid = next((i['fileId'] for i in items if f_md5 in i['fileName'] or i['fileName'] == safe_name), None)
if not real_fid:
real_fid = family_client.rapid_upload(fam_id, fam_fd, f_md5, raw_size, s_md5, safe_name, target_sk)
logger.info(f"[{log_tag}秒传成功] {safe_name} ({human_size})")
else:
logger.info(f"[{log_tag}秒传命中] {safe_name} ({human_size})")
download_url = family_client.get_download_url(fam_id, real_fid, target_sk, client_ua)
if download_url:
final_channel_type = c_type
with cache_lock:
upload_cache[f_md5]['fid'] = real_fid
upload_cache[f_md5]['download_url'] = download_url
upload_cache[f_md5]['url_time'] = current_time
upload_cache[f_md5]['is_personal'] = (c_type == 'personal')
if c_type == 'personal': threading.Thread(target=personal_cleanup_worker, args=(real_fid, target_sk, target_cookie, f_md5), daemon=True).start()
else: threading.Thread(target=cleanup_worker, args=(safe_name, f_md5, fam_id, fam_fd, target_sk), daemon=True).start()
break
except Exception as e2:
logger.error(f"[{log_tag}自愈后重试依然失败]: {e2}。切换至下一通道...")
continue
else:
logger.warning(f"[{log_tag}遭遇拦截] 报错: {e}。🚨 触发队列滑点:无缝切换至下一个卡槽通道...")
continue
if not download_url:
with cache_lock:
if f_md5 in upload_cache: del upload_cache[f_md5]
raise Exception("攻击队列执行完毕,所有卡槽节点均无法承载该文件!")
if download_url:
parsed = urllib.parse.urlparse(download_url)
is_p = False
with cache_lock:
if f_md5 in upload_cache: is_p = upload_cache[f_md5].get('is_personal', False)
now_t = time.time()
if now_t - print_throttle_cache.get(f"play_{f_md5}", 0) > 15:
logger.info(f"[播放放行] 节点({'个人云' if is_p else '家庭云'}): {parsed.netloc} | 地址: {truncate_url(download_url)}")
print_throttle_cache[f"play_{f_md5}"] = now_t
return redirect(download_url, code=302)
except Exception as e:
logger.error(f"❌ 模式A 处理异常: {e}")
with cache_lock:
if 'f_md5' in locals() and f_md5 in upload_cache and upload_cache[f_md5].get('fid') == 'processing':
del upload_cache[f_md5]
return f"错误: {e}", 500
def warm_up_parent(target_path, headers, api_host):
if not target_path: return
cfg = read_config()
base_path = cfg.get('network_cas_path', '').rstrip('/')
if target_path.startswith(base_path):
rel_path = target_path[len(base_path):].strip('/')
parts = rel_path.split('/')
current_path = base_path
for part in parts[:-1]:
current_path = f"{current_path}/{part}"
try: requests.post(f"{api_host}/api/fs/list", json={"path": current_path, "page": 1, "per_page": 1000, "refresh": True}, headers=headers, timeout=5)
except: pass
def scan_openlist_recursive(current_path, headers, result_list, api_host, file_type='cas'):
try:
res = requests.post(f"{api_host}/api/fs/list", json={"path": current_path, "page": 1, "per_page": 1000, "refresh": True}, headers=headers, timeout=15).json()
if res.get("code") != 200: return
for f in res.get("data", {}).get("content", []):
if f.get("is_dir"): scan_openlist_recursive(f"{current_path}/{f['name']}", headers, result_list, api_host, file_type)
else:
ext = f['name'].lower().split('.')[-1] if '.' in f['name'] else ''
if file_type in ['cas', 'cas_native', 'cas_native_139'] and ext == 'cas': result_list.append(f"{current_path}/{f['name']}")
elif file_type == 'media' and ext in ['mp4', 'mkv', 'ts', 'avi', 'mov', 'webm', 'flv', 'iso']: result_list.append(f"{current_path}/{f['name']}")
except: pass
def generate_strm_from_openlist_to_local(target_path=None, drive_type='189', file_type='cas'):
cfg = read_config()
if drive_type == '139':
scan_root = target_path if target_path else cfg.get('network_cas_path_139', '')
base_cas_path = cfg.get('network_cas_path_139', '')
local_strm_dir = cfg.get('local_strm_dir_139', '')
local_strm_dir_native = cfg.get('local_strm_dir_139_native', '')
api_host = cfg.get('openlist_host_139', 'http://127.0.0.1:5255').rstrip('/')
api_token = cfg.get('openlist_token_139', '')
else:
api_host = cfg.get('openlist_host', 'http://127.0.0.1:5244').rstrip('/')
api_token = cfg.get('openlist_token', '')
if file_type == 'cas':
scan_root = target_path if target_path else cfg.get('network_cas_path', '')
base_cas_path = cfg.get('network_cas_path', '')
local_strm_dir = cfg.get('local_strm_dir', '')
elif file_type == 'cas_native':
scan_root = target_path if target_path else cfg.get('network_cas_path', '')
base_cas_path = cfg.get('network_cas_path', '')
local_strm_dir = cfg.get('local_strm_dir_native', '')
elif file_type == 'media':
scan_root = target_path if target_path else cfg.get('network_media_path', '')
base_cas_path = cfg.get('network_media_path', '')
local_strm_dir = cfg.get('local_strm_dir_media', '')
os.makedirs(local_strm_dir if local_strm_dir else '/tmp', exist_ok=True)
headers = {"Authorization": api_token} if api_token else {}
if target_path and drive_type != '139': warm_up_parent(target_path, headers, api_host)
logger.info(f"[扫描启动] OpenList [{drive_type} / {file_type.upper()}] -> 区域: {scan_root}")
cas_files = []
search_type = 'cas' if file_type == 'both' else file_type
scan_openlist_recursive(scan_root, headers, cas_files, api_host, search_type)
if not cas_files: return logger.info(f"⚠️ 未找到目标文件")
count = 0
req_session = requests.Session()
for full_path in cas_files:
try:
if full_path.startswith(base_cas_path): rel_path = full_path[len(base_cas_path):].lstrip('/')
else: rel_path = full_path.split('/')[-1]
rel_dir = os.path.dirname(rel_path)
dir_parts = [p for p in rel_dir.split('/') if p]
show_name = ""
for part in reversed(dir_parts):
if not re.match(r'(?i)^(season\s*\d+|specials|电视剧|电影|动漫|纪录片|综艺)$', part):
show_name = part; break
if not show_name and dir_parts: show_name = dir_parts[-1]
if not show_name: show_name = "未知剧集"
show_name = re.sub(r'\s*\(\d{4}\)', '', show_name)
show_name = re.sub(r'(?i)\s*(HQ|IQ|HDR|SDR|DV|4K|1080p|720p)\b', '', show_name)
show_name = re.sub(r'[《》]', '', show_name).strip()
base_name = os.path.basename(rel_path).rsplit('.', 1)[0]
# 🎬 常规媒体 (Media)
if file_type == 'media':
target_local_dir = os.path.join(local_strm_dir, rel_dir)
os.makedirs(target_local_dir, exist_ok=True)
strm_path = os.path.join(target_local_dir, f"{base_name}.strm")
if not os.path.exists(strm_path):
with open(strm_path, "w", encoding="utf-8") as f:
f.write(f"{cfg['server_host']}/play?drive=direct&path={urllib.parse.quote(full_path)}&show={urllib.parse.quote(show_name)}")
count += 1
continue
# ⬇️ 云端内容抓取
get_res = req_session.post(f"{api_host}/api/fs/get", json={"path": full_path}, headers=headers, timeout=10).json()
raw_url = get_res.get("data", {}).get("raw_url")
if not raw_url: continue
cas_content = req_session.get(raw_url, timeout=10).text.strip()
# 🚨 终极防污染:如果下载下来的内容是 HTML 网页(说明网盘限流或死机),绝不生成 STRM!
if cas_content.startswith('<'):
logger.warning(f"⚠️ [防污染拦截] OpenList 返回了网页报错,已跳过生成: {base_name}")
continue
# ================== 139 体系 ==================
if drive_type == '139':
# 🎯 【139 模式 B】: 老模式 (OpenList) -> -139B.strm
if file_type in ['cas', 'both'] and local_strm_dir:
target_dir_b = os.path.join(local_strm_dir, rel_dir)
os.makedirs(target_dir_b, exist_ok=True)
strm_path_b = os.path.join(target_dir_b, f"{base_name}-139B.strm")
if not os.path.exists(strm_path_b):
strm_data_b = f"{cfg['server_host']}/play?drive=139&path={urllib.parse.quote(full_path)}&show={urllib.parse.quote(show_name)}"
with open(strm_path_b, "w", encoding="utf-8") as f: f.write(strm_data_b)
count += 1
# 🎯 【139 模式 A】: 官方原生直连 -> -139A.strm
if file_type in ['cas_native', 'both'] and local_strm_dir_native:
try:
c_dict = parse_cas_content(cas_content)
c_sha = c_dict.get('sha256') or c_dict.get('hash') or c_dict.get('fileMd5') or c_dict.get('md5') or ''
c_size = c_dict.get('size') or c_dict.get('fileSize') or ''
c_name = c_dict.get('name') or c_dict.get('fileName') or base_name
if c_sha and str(c_size):
target_dir_a = os.path.join(local_strm_dir_native, rel_dir)
os.makedirs(target_dir_a, exist_ok=True)
strm_path_a = os.path.join(target_dir_a, f"{base_name}-139A.strm")
if not os.path.exists(strm_path_a):
strm_data_a = f"{cfg['server_host']}/play?drive=139_native&sha256={c_sha}&size={c_size}&name={urllib.parse.quote(c_name)}&show={urllib.parse.quote(show_name)}"
with open(strm_path_a, "w", encoding="utf-8") as f: f.write(strm_data_a)
count += 1
except Exception as e:
logger.error(f"139解析异常: {e}")
# ================== 189 体系 ==================
else:
# 🎯 【189 模式 A】: 官方秒传 -> .strm (保持兼容无后缀)
if file_type in ['cas', 'both'] and local_strm_dir:
target_dir_a = os.path.join(local_strm_dir, rel_dir)
os.makedirs(target_dir_a, exist_ok=True)
strm_path_a = os.path.join(target_dir_a, f"{base_name}.strm")
if not os.path.exists(strm_path_a):
strm_data_a = f"{cfg['server_host']}/play?cas={urllib.parse.quote(cas_content)}&show={urllib.parse.quote(show_name)}"
with open(strm_path_a, "w", encoding="utf-8") as f: f.write(strm_data_a)
count += 1
# 🎯 【189 模式 B】: OpenList中转虚空直通 -> -189B.strm
if file_type in ['cas_native', 'both'] and local_strm_dir_native:
target_dir_b = os.path.join(local_strm_dir_native, rel_dir)
os.makedirs(target_dir_b, exist_ok=True)
strm_path_b = os.path.join(target_dir_b, f"{base_name}-189B.strm")
if not os.path.exists(strm_path_b):
strm_data_b = f"{cfg['server_host']}/play?drive=189_native&cas={urllib.parse.quote(cas_content)}&show={urllib.parse.quote(show_name)}"
with open(strm_path_b, "w", encoding="utf-8") as f: f.write(strm_data_b)
count += 1
except Exception as e: time.sleep(2)
if count > 0: logger.info(f"[同步完毕] 成功归档 {count} 个 STRM 文件")
@app_main.route('/api/sync')
def trigger_sync():
target_path = request.args.get('path')
drive_type = request.args.get('drive', '189')
file_type = request.args.get('type', 'cas')
threading.Thread(target=generate_strm_from_openlist_to_local, args=(target_path, drive_type, file_type), daemon=True).start()
return "✅ 同步指令下发成功", 200
def local_cas_sync_worker():
cfg = read_config()
# === 1. 扫描 189 的本地源目录 ===
source_dir_189 = cfg.get('local_cas_source_dir', '')
base_dir_a = cfg.get('local_strm_dir', '')
base_dir_b = cfg.get('local_strm_dir_native', '')
count_189 = 0
if source_dir_189 and os.path.exists(source_dir_189):
logger.info(f"[本地扫描] 启动 189 CAS 扫描 -> 目录: {source_dir_189}")
for root, dirs, files in os.walk(source_dir_189):
for file in files:
if file.endswith('.cas'):
full_path = os.path.join(root, file)
rel_path = full_path[len(source_dir_189):].lstrip('/\\')
rel_dir = os.path.dirname(rel_path)
dir_parts = [p for p in rel_dir.split('/') if p]
show_name = "未知剧集"
for part in reversed(dir_parts):
if not re.match(r'(?i)^(season\s*\d+|specials|电视剧|电影|动漫|纪录片|综艺)$', part):
show_name = part; break
if show_name == "未知剧集" and dir_parts: show_name = dir_parts[-1]
show_name = re.sub(r'\s*\(\d{4}\)', '', show_name)
show_name = re.sub(r'(?i)\s*(HQ|IQ|HDR|SDR|DV|4K|1080p|720p)\b', '', show_name)
show_name = re.sub(r'[《》]', '', show_name).strip()
base_name = os.path.splitext(file)[0]
try:
with open(full_path, 'r', encoding='utf-8') as f:
cas_content = f.read().strip()
# 🎯 189 模式 A (官方秒传) -> .strm
if base_dir_a:
target_a = os.path.join(base_dir_a, rel_dir)
os.makedirs(target_a, exist_ok=True)
strm_a = os.path.join(target_a, f"{base_name}.strm")
if not os.path.exists(strm_a):
with open(strm_a, "w", encoding="utf-8") as fa:
fa.write(f"{cfg['server_host']}/play?cas={urllib.parse.quote(cas_content)}&show={urllib.parse.quote(show_name)}")
count_189 += 1
# 🎯 189 模式 B (OpenList直通) -> -189B.strm
if base_dir_b:
target_b = os.path.join(base_dir_b, rel_dir)
os.makedirs(target_b, exist_ok=True)
strm_b = os.path.join(target_b, f"{base_name}-189B.strm")
if not os.path.exists(strm_b):
with open(strm_b, "w", encoding="utf-8") as fb:
fb.write(f"{cfg['server_host']}/play?drive=189_native&cas={urllib.parse.quote(cas_content)}&show={urllib.parse.quote(show_name)}")
count_189 += 1
except: pass
# === 2. 扫描 139 的本地源目录 ===
source_dir_139 = cfg.get('local_cas_source_dir_139', '')
base_dir_139_old = cfg.get('local_strm_dir_139', '')
base_dir_139_native = cfg.get('local_strm_dir_139_native', '')
net_cas_path_139 = cfg.get('network_cas_path_139', '').rstrip('/')
count_139 = 0
if source_dir_139 and os.path.exists(source_dir_139):
logger.info(f"[本地扫描] 启动 139 CAS 双轨扫描 -> 目录: {source_dir_139}")
for root, dirs, files in os.walk(source_dir_139):
for file in files:
if file.endswith('.cas'):
full_path = os.path.join(root, file)
rel_path = full_path[len(source_dir_139):].lstrip('/\\')
rel_dir = os.path.dirname(rel_path)
dir_parts = [p for p in rel_dir.split('/') if p]
show_name = "未知剧集"
for part in reversed(dir_parts):
if not re.match(r'(?i)^(season\s*\d+|specials|电视剧|电影|动漫|纪录片|综艺)$', part):
show_name = part; break
if show_name == "未知剧集" and dir_parts: show_name = dir_parts[-1]
show_name = re.sub(r'\s*\(\d{4}\)', '', show_name)
show_name = re.sub(r'(?i)\s*(HQ|IQ|HDR|SDR|DV|4K|1080p|720p)\b', '', show_name)
show_name = re.sub(r'[《》]', '', show_name).strip()
base_name = os.path.splitext(file)[0]
try:
with open(full_path, 'r', encoding='utf-8') as f:
cas_content = f.read().strip()
# 🎯 139 模式 B (老模式中转) -> -139B.strm
if base_dir_139_old:
target_139_old = os.path.join(base_dir_139_old, rel_dir)
os.makedirs(target_139_old, exist_ok=True)
strm_139_old = os.path.join(target_139_old, f"{base_name}-139B.strm")
if not os.path.exists(strm_139_old):
fake_net_path = f"{net_cas_path_139}/{rel_path}".replace('\\', '/')
with open(strm_139_old, "w", encoding="utf-8") as f_old:
f_old.write(f"{cfg['server_host']}/play?drive=139&path={urllib.parse.quote(fake_net_path)}&show={urllib.parse.quote(show_name)}")
count_139 += 1
# 🎯 139 模式 A (原生直连) -> -139A.strm
if base_dir_139_native:
target_139_native = os.path.join(base_dir_139_native, rel_dir)
os.makedirs(target_139_native, exist_ok=True)
strm_139_native = os.path.join(target_139_native, f"{base_name}-139A.strm")
if not os.path.exists(strm_139_native):
d = parse_cas_content(cas_content)
c_sha = d.get('sha256') or d.get('hash') or d.get('fileMd5') or d.get('md5') or ''
c_size = d.get('size') or d.get('fileSize') or ''
c_name = d.get('name') or d.get('fileName') or base_name
if c_sha and str(c_size):
with open(strm_139_native, "w", encoding="utf-8") as f_native:
f_native.write(f"{cfg['server_host']}/play?drive=139_native&sha256={c_sha}&size={c_size}&name={urllib.parse.quote(c_name)}&show={urllib.parse.quote(show_name)}")
count_139 += 1
except: pass
if count_189 > 0 or count_139 > 0:
logger.info(f"[扫描完毕] 生成 189 STRM: {count_189} 个, 139 STRM: {count_139} 个")
@app_main.route('/api/sync_local')
def api_sync_local():
threading.Thread(target=local_cas_sync_worker, daemon=True).start()
return "✅ 本地扫描指令下发成功", 200
@app_main.route('/api/make_strm', methods=['POST'])
def api_make_strm():
try:
data = request.json
source_cas_path = data.get('source_cas_path')
target_local_dir = data.get('target_local_dir')
strm_name = data.get('strm_name')
show_name = data.get('show_name')
mode = data.get('mode', '').lower()
if not all([source_cas_path, strm_name, show_name, target_local_dir]):
return jsonify({"code": 400, "msg": "指令参数不全"}), 400
if not os.path.exists(source_cas_path):
return jsonify({"code": 404, "msg": f"找不到源文件: {source_cas_path}"}), 404
cfg = read_config()
with open(source_cas_path, 'r', encoding='utf-8') as f: cas_content = f.read().strip()
if cas_content.startswith('<'):
return jsonify({"code": 500, "msg": "源 cas 文件已被污染成 HTML 网页,拒绝生成"}), 500
base_name = os.path.splitext(strm_name)[0]
os.makedirs(target_local_dir, exist_ok=True)
cas_data = parse_cas_content(cas_content)
# =======================================================
# 👑 绝对统一:生成文件后缀标准
# 外部明确传参(单模式) -> 绝对服从 strm_name (一字不改!)
# 外部批量生成(both双轨) -> 按统一标准附加后缀以防冲突:
# - 189 模式 A (官方直传) -> {base_name}.strm
# - 189 模式 B (代理中转) -> {base_name}-189B.strm
# - 139 模式 A (官方直通) -> {base_name}-139A.strm
# - 139 模式 B (代理中转) -> {base_name}-139B.strm
# =======================================================
# 🎯 【139 模式 A】: 官方原生直连 (139_native)
if mode in ['mode_a_139', 'cas_139_native', '139_native', 'both_139']:
c_sha = cas_data.get('sha256') or cas_data.get('hash') or cas_data.get('fileMd5') or cas_data.get('md5') or ''
c_size = cas_data.get('size') or cas_data.get('fileSize') or ''
c_name = cas_data.get('name') or cas_data.get('fileName') or base_name
if c_sha and str(c_size):
# 只有传 both 才会加后缀,否则绝对服从外部传来的 strm_name
out_name = f"{base_name}-139A.strm" if 'both' in mode else strm_name
strm_path = os.path.join(target_local_dir, out_name)
strm_data = f"{cfg['server_host']}/play?drive=139_native&sha256={c_sha}&size={c_size}&name={urllib.parse.quote(c_name)}&show={urllib.parse.quote(show_name)}"
with open(strm_path, "w", encoding="utf-8") as f: f.write(strm_data)
else:
if 'both' not in mode: return jsonify({"code": 500, "msg": "139模式A生成失败: 提取不到 sha256 或 size"}), 500
# 🎯 【139 模式 B】: OpenList老模式 (139)
if mode in ['mode_b_139', 'cas_139', '139_old', 'both_139']:
cloud_path = data.get('cloud_cas_path', '')
if cloud_path:
out_name = f"{base_name}-139B.strm" if 'both' in mode else strm_name
strm_path = os.path.join(target_local_dir, out_name)
strm_data = f"{cfg['server_host']}/play?drive=139&path={urllib.parse.quote(cloud_path)}&show={urllib.parse.quote(show_name)}"
with open(strm_path, "w", encoding="utf-8") as f: f.write(strm_data)
else:
if 'both' not in mode: return jsonify({"code": 400, "msg": "139模式B缺少 cloud_cas_path 参数"}), 400
# 🎯 【189 模式 A】: 官方秒传直通 (189)
if mode in ['mode_a_189', 'mode_a', 'cas', 'both', 'both_189']:
out_name = f"{base_name}.strm" if 'both' in mode else strm_name
strm_path = os.path.join(target_local_dir, out_name)
strm_data = f"{cfg['server_host']}/play?cas={urllib.parse.quote(cas_content)}&show={urllib.parse.quote(show_name)}"
with open(strm_path, "w", encoding="utf-8") as f: f.write(strm_data)
# 🎯 【189 模式 B】: OpenList虚空直通 (189_native)
if mode in ['mode_b_189', 'mode_b', 'cas_native', 'both', 'both_189']:
out_name = f"{base_name}-189B.strm" if 'both' in mode else strm_name
strm_path = os.path.join(target_local_dir, out_name)
strm_data = f"{cfg['server_host']}/play?drive=189_native&cas={urllib.parse.quote(cas_content)}&show={urllib.parse.quote(show_name)}"
with open(strm_path, "w", encoding="utf-8") as f: f.write(strm_data)
# 👇 只需要在这里增加这一行打印代码 👇
logger.info(f"[接口调用] 成功生成 STRM: {strm_name} (策略: {mode})")
return jsonify({"code": 200, "msg": "success"}), 200
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
emby_session = requests.Session()
@functools.lru_cache(maxsize=256)
def get_emby_item_path(item_id, media_source_id=None):
clean_media_id = str(media_source_id).replace('mediasource_', '').strip() if media_source_id else None
query_ids = f"{clean_media_id},{item_id}" if clean_media_id else item_id
def _extract_path(api_key, desc):
url = f"{EMBY_HOST}/emby/Items?Ids={query_ids}&Fields=Path,MediaSources&api_key={api_key}"
try:
res = emby_session.get(url, timeout=3)
if res.status_code == 200:
items = res.json().get('Items', [])
if clean_media_id:
for item in items:
if str(item.get('Id')) == clean_media_id: return item.get('Path', ''), f"{desc}-精准匹配"
for source in item.get('MediaSources', []):
if str(source.get('Id')) == clean_media_id: return source.get('Path', item.get('Path', '')), f"{desc}-精准匹配"
if items:
sources = items[0].get('MediaSources', [])
if sources: return sources[0].get('Path', ''), f"{desc}-默认版本"
return items[0].get('Path', ''), f"{desc}-兜底主路径"
except: pass
return None, None
res_path, res_desc = _extract_path(API_KEY_LINUX, "Linux(主力)")
if res_path: return res_path, res_desc
return _extract_path(API_KEY_APP, "APP(备用)")
@app_302.route('/', defaults={'path': ''}, methods=['GET', 'HEAD', 'POST', 'OPTIONS'])
@app_302.route('/<path:full_path>', methods=['GET', 'HEAD', 'POST', 'OPTIONS'])
def catch_all_for_emby(full_path):
match = re.search(r'/(?:videos|Items)/(\d+)/(?:stream|original|Download)', request.path, re.IGNORECASE)
if not match: return redirect(f"{EMBY_HOST}{request.full_path}", code=302)
item_id = match.group(1)
media_source_id = request.args.get('MediaSourceId') or request.args.get('mediaSourceId') or request.args.get('mediasourceid')
try:
file_path, version = get_emby_item_path(item_id, media_source_id)
if not file_path: return redirect(f"{EMBY_HOST}{request.full_path}", code=302)
strm_url = None
file_name = file_path.split('/')[-1] if file_path else "未知文件"
if file_path.startswith('http://') or file_path.startswith('https://') or file_path.startswith('play?'):
strm_url = file_path
elif file_path.lower().endswith('.strm') and os.path.exists(file_path):
with open(file_path, 'r', encoding='utf-8') as f: strm_url = f.read().strip()
if strm_url:
cfg = read_config()
lan_ip = get_lan_server_ip(request)
parsed_url = urllib.parse.urlparse(strm_url)
query_params = urllib.parse.parse_qs(parsed_url.query)
is_direct = False
if 'direct' in file_path.lower() or 'modeb' in file_path.lower(): is_direct = True
if str(cfg.get('force_mode_b', 'false')).lower() == 'true': is_direct = True
if is_direct and 'cas' in query_params and 'drive' not in query_params:
query_params['drive'] = ['189_native']
new_query = urllib.parse.urlencode(query_params, doseq=True)
path_str = parsed_url.path if parsed_url.path.startswith('/') else '/' + parsed_url.path
if not parsed_url.netloc:
host_base = f"http://{lan_ip}:5000" if lan_ip else cfg['server_host']
strm_url = f"{host_base}{path_str}?{new_query}"
else:
if lan_ip: strm_url = f"http://{lan_ip}:5000{path_str}?{new_query}"
else: strm_url = f"{parsed_url.scheme}://{parsed_url.netloc}{path_str}?{new_query}"
if lan_ip: logger.info(f"[网络嗅探] 识别为内网播放,下发路由重定向")
now_t = time.time()
if now_t - print_throttle_cache.get(f"hijack_{item_id}", 0) > 15:
logger.info(f"[劫持放行] {version} -> {file_name}")
print_throttle_cache[f"hijack_{item_id}"] = now_t
return redirect(strm_url, code=302)
else:
return redirect(f"{EMBY_HOST}{request.full_path}", code=302)
except:
return redirect(f"{EMBY_HOST}{request.full_path}", code=302)
def run_main(): app_main.run(host='0.0.0.0', port=5000, use_reloader=False)
def run_302(): app_302.run(host='0.0.0.0', port=5001, use_reloader=False)
def keep_alive_worker():
time.sleep(60)
while True:
try:
cfg = read_config()
has_checked = False
for i, acc in enumerate(cfg.get('accounts', [])):
fam_id, fold_id = acc.get('family_id'), acc.get('family_folder_id')
per_id = acc.get('personal_folder_id')
sk, cookie = acc.get('session_key'), acc.get('cookie')
if not (acc.get('username') and acc.get('password')) and i != 3: continue
if not has_checked:
logger.info("📡 [巡逻雷达] 正在对矩阵卡槽进行后台健康体检...")
has_checked = True
is_alive = True
if (fam_id and fold_id and not sk) or (per_id and not cookie) or (i == 3 and not sk):
is_alive = False
if is_alive and fam_id and fold_id and sk:
try: family_client.get_family_items(fam_id, fold_id, sk)
except Exception as e:
if any(k in str(e).lower() for k in ["auth_fail", "111", "session"]): is_alive = False
if is_alive and per_id and sk and cookie:
try: personal_client.get_personal_items(per_id, cookie)
except Exception as e:
if any(k in str(e).lower() for k in ["auth_fail", "111", "session", "notlogin"]): is_alive = False
if not is_alive:
logger.warning(f"⚠️ [巡逻预警] 发现卡槽 {i+1} 凭证异常或为空,立即激活自愈程序!")
refresh_account_logic(i, cfg)
time.sleep(random.randint(5, 10))
if has_checked:
logger.info("✅ [巡逻完毕] 本轮健康体检结束,所有卡槽运转正常。")
except Exception as e:
logger.error(f"❌ [巡逻异常]: {e}")
time.sleep(3600)
if __name__ == '__main__':
logger.info("[管家启动] 双头蛇引擎 V10 四核矩阵滑点版 启动完毕!")
threading.Thread(target=keep_alive_worker, daemon=True).start()
t1 = threading.Thread(target=run_main)
t2 = threading.Thread(target=run_302)
t1.start()
t2.start()
t1.join()
t2.join()