本地计算视频文件分段md5生成cas文件
cas_server.py:
import os
import json
import base64
import hashlib
import time
import argparse
import sys
from flask import Flask, request, jsonify, render_template_string
app = Flask(__name__)
# ==========================================
# 核心配置区
# ==========================================
# Termux 服务器本地 CAS 文件暂存根目录 (分别独立配置)
CAS_BASE_DIRS = {
"189": "/storage/emulated/0/Download/189cas",
"139": "/storage/emulated/0/Download/139cas"
}
for d in CAS_BASE_DIRS.values():
os.makedirs(d, exist_ok=True)
# 28GB 阈值(字节),超过此大小自动切换为 20MB 分片
LARGE_FILE_THRESHOLD = 26 * 1024 * 1024 * 1024
# 支持批量遍历收割的视频扩展名
VIDEO_EXTENSIONS = ('.mp4', '.mkv', '.avi', '.mov', '.flv', '.wmv', '.rmvb', '.ts')
def get_dynamic_slice_size(file_size):
"""根据文件大小动态计算分片大小(防止大文件片数超 3000 爆限制)"""
if file_size > LARGE_FILE_THRESHOLD:
return 20 * 1024 * 1024 # >26G 自动启用 20MB 分片
return 10 * 1024 * 1024 # 普通文件使用 10MB 分片
def calculate_single_file_cas(file_path, cloud_type="189"):
"""核心底层算法:全速解构单个物理文件并计算标准级联哈希或SHA256"""
if not os.path.exists(file_path) or os.path.isdir(file_path):
return None
file_size = os.path.getsize(file_path)
file_name = os.path.basename(file_path)
# ======== 139 移动云盘逻辑 ========
if cloud_type == "139":
sha256_algo = hashlib.sha256()
with open(file_path, 'rb') as f:
while True:
chunk = f.read(4 * 1024 * 1024)
if not chunk:
break
sha256_algo.update(chunk)
return {
"name": file_name,
"size": file_size,
"md5": "",
"sliceMd5": "",
"sha256": sha256_algo.hexdigest().lower(),
"create_time": str(int(time.time())),
"part_size_used": "139无需分片"
}
# ======== 189 天翼云盘逻辑 ========
current_slice_size = get_dynamic_slice_size(file_size)
chunk_size = 4 * 1024 * 1024 # 4MB 流式读取缓冲区
full_md5_compressor = hashlib.md5()
slice_md5_hexs = []
with open(file_path, 'rb') as f:
off = 0
while off < file_size:
slice_end = min(off + current_slice_size, file_size)
slice_md5_compressor = hashlib.md5()
inner = off
while inner < slice_end:
read_len = min(chunk_size, slice_end - inner)
chunk = f.read(read_len)
if not chunk:
break
full_md5_compressor.update(chunk)
slice_md5_compressor.update(chunk)
inner += read_len
slice_md5_hexs.append(slice_md5_compressor.hexdigest().upper())
off = slice_end
full_md5 = full_md5_compressor.hexdigest().upper()
if file_size > current_slice_size:
# 在 Python 端,换行符级联
joined_text = "\n".join(slice_md5_hexs)
slice_md5 = hashlib.md5(joined_text.encode('utf-8')).hexdigest().upper()
else:
slice_md5 = full_md5
return {
"name": file_name,
"size": file_size,
"md5": full_md5,
"sliceMd5": slice_md5,
"create_time": str(int(time.time())),
"cloud": "189",
"part_size_used": f"{current_slice_size / (1024*1024):.0f}MB"
}
# ==========================================
# 终极一体化网页端 UI (原生防转义架构)
# ==========================================
# 采用 raw 字符串 (r"") 避免转义字符导致前端 JS 崩溃
HTML_TEMPLATE = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>天翼/移动云盘 CAS 双擎控制中心</title>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.9.0/sha256.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root { --bg: #0f172a; --card: #1e293b; --border: #334155; --text: #e2e8f0; --dim: #94a3b8; --accent: #3b82f6; --accent-h: #2563eb; --ok: #22c55e; --err: #ef4444; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); padding: 24px; min-height: 100vh; }
.wrap { max-width: 850px; margin: 0 auto; }
h1 { font-size: 22px; margin-bottom: 4px; color: #facc15; }
.sub { color: var(--dim); font-size: 13px; margin-bottom: 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.card-t { font-size: 14px; font-weight: 600; margin-bottom: 14px; color: #38bdf8; display:flex; align-items:center; gap:6px; }
.path-builder { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 12px; }
.pb-group { display: flex; flex-direction: column; gap: 6px; }
.pb-group label { font-size: 12px; color: var(--dim); font-weight: bold; }
.pb-input { padding: 9px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); outline: none; font-size: 13px; transition: border 0.2s; }
.pb-input:focus { border-color: var(--accent); }
.final-path-box { background: rgba(59,130,246,0.1); border: 1px dashed var(--accent); padding: 12px; border-radius: 8px; margin-top: 10px; }
.final-path-text { color: #fff; font-family: monospace; font-size: 13px; word-break: break-all; }
/* 运行模式切换 */
.mode-tabs { display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
.tab { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; background: var(--border); color: var(--dim); border: none; font-weight: bold; transition: all 0.2s; }
.tab:hover { background: #475569; }
.tab.active { background: var(--accent); color: #fff; }
.drop { border: 2px dashed var(--border); border-radius: 12px; padding: 35px; text-align: center; cursor: pointer; transition: all 0.2s; }
.drop:hover, .drop.over { border-color: var(--accent); background: rgba(59,130,246,.05); }
.drop-text { color: var(--dim); font-size: 13px; margin-top: 5px; }
/* 服务器批量解析区布局 */
.server-zone-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
.server-path-row { display: flex; flex-direction: column; gap: 6px; }
.server-path-row label { font-size: 12px; font-weight: bold; color: var(--dim); }
.server-path-input { padding: 12px; background: #020617; border: 1px solid var(--border); border-radius: 8px; color: #a7f3d0; font-family: monospace; outline: none; font-size: 13px; width: 100%; transition: border 0.2s; }
.server-path-input:focus { border-color: var(--accent); }
.flist { margin-top: 12px; max-height: 350px; overflow-y: auto; }
.frow { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--bg); border-radius: 8px; margin-bottom: 6px; font-size: 13px; border-left: 4px solid var(--border); }
.frow.wait { border-left-color: var(--dim); }
.frow.work { border-left-color: var(--accent); }
.frow.done { border-left-color: var(--ok); }
.frow.fail { border-left-color: var(--err); }
.frow .name { flex: 1; word-break: break-all; }
.frow .size { color: var(--dim); white-space: nowrap; font-family: monospace; }
.frow .st { font-size: 12px; white-space: nowrap; min-width: 40px; text-align: right; }
.st.done { color: var(--ok); }
.st.work { color: var(--accent); }
.st.fail { color: var(--err); }
.st.wait { color: var(--dim); }
.btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-p { background: var(--accent); color: #fff; }
.btn-p:hover:not(:disabled) { background: var(--accent-h); }
.btn-s { background: var(--border); color: var(--text); }
.btn-s:hover { background: #475569; }
.btn-g { background: var(--ok); color: #fff; }
.btn-g:hover:not(:disabled) { background: #16a34a; }
.out { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 14px; font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; color: #a7f3d0; max-height: 400px; overflow-y: auto; }
</style>
</head>
<body>
<div class="wrap">
<h1>⚡ 双擎云盘 CAS 远程集成控制中心</h1>
<p class="sub">全面支持 189天翼云(分片拼装) & 139移动云盘(SHA256) · 物理目录严格隔离存放</p>
<!-- 1. Emby 路径配置 -->
<div class="card">
<div class="card-t">📁 1. 自动归档分类配置 (决定落盘文件夹)</div>
<div class="path-builder">
<div class="pb-group">
<label>主分类</label>
<select id="catMain" class="pb-input" onchange="buildPath()">
<option value="华语剧">华语剧</option><option value="欧美剧">欧美剧</option><option value="日韩剧">日韩剧</option>
<option value="短剧">短剧</option><option value="国漫">国漫</option><option value="日漫">日漫</option>
<option value="华语电影">华语电影</option><option value="欧美电影">欧美电影</option><option value="日韩电影">日韩电影</option>
<option value="综艺">综艺</option><option value="纪录片">纪录片</option><option value="演唱会">演唱会</option>
</select>
</div>
<div class="pb-group"><label>影视名称</label><input type="text" id="catName" class="pb-input" placeholder="可手动填 / 拖入文件自动识别" oninput="buildPath()"></div>
<div class="pb-group"><label>年份</label><input type="text" id="catYear" class="pb-input" placeholder="2026" oninput="buildPath()"></div>
<div class="pb-group" id="seasonGroup"><label>季数</label><input type="text" id="catSeason" class="pb-input" placeholder="1" oninput="buildPath()"></div>
</div>
<div class="final-path-box">
<label>即将落盘的物理相对目录 (挂载在对应云盘基础目录下):</label>
<div class="final-path-text" id="finalPathDisplay"></div>
<input type="hidden" id="categoryInput">
</div>
</div>
<!-- 2. 操作模式切换与执行域 -->
<div class="card">
<div class="card-t">⚙️ 2. 选择工作引擎与目标云盘</div>
<div class="mode-tabs">
<select id="cloudType" class="pb-input" style="background:var(--accent); color:white; font-weight:bold; border:none; margin-right:15px; cursor:pointer;" onchange="buildPath()">
<option value="189">☁️ 天翼云盘 (189)</option>
<option value="139">☁️ 移动云盘 (139)</option>
</select>
<button class="tab active" id="tabLocal" onclick="switchMode('local')">🌐 跨网络计算 </button>
<button class="tab" id="tabServer" onclick="switchMode('server')">🖥️ 服务器本地 </button>
</div>
<!-- 模式 A:浏览器跨网络选择计算 -->
<div id="panelLocal">
<div class="drop" id="drop" onclick="document.getElementById('finput').click()">
<div style="font-size: 24px;">☁️</div>
<div class="drop-text">点击或拖拽电脑/手机本地视频到此处<br><span style="color:#64748b;font-size:11px;">(文件添加后处于等待状态,需手动点击开始按钮执行计算)</span></div>
</div>
<input type="file" id="finput" multiple style="display: none;" onchange="handleLocalFiles(event)">
</div>
<!-- 模式 B:控制服务器本地视频批量收割 -->
<div id="panelServer" style="display: none;">
<div class="server-zone-grid">
<div class="server-path-row">
<label>1. 服务器基础挂载路径 (如 /sdcard/Download)</label>
<input type="text" id="serverBasePath" class="server-path-input" value="/data/data/com.termux/files/home/Downloads">
</div>
<div class="server-path-row">
<label>2. ➕ 手动追加【媒体文件夹】(自动扫描该目录下所有视频)</label>
<input type="text" id="serverSubPath" class="server-path-input" placeholder="例如: 华语剧/剑兰女探社 (2026)/Season 1">
</div>
<div class="server-path-row">
<label>3. ➕ [可选] 锁定具体的单一视频文件 (留空则执行全目录收割)</label>
<input type="text" id="serverSpecificFile" class="server-path-input" placeholder="例如: S01E01.mp4">
</div>
</div>
</div>
<!-- 任务队列面板 -->
<div class="flist" id="flist"></div>
<!-- 全局操作面板 -->
<div class="btns">
<button class="btn btn-g" id="btnGo" onclick="executeTasks()">🚀 计算&归档</button>
<button class="btn btn-s" id="btnCopy" disabled onclick="copyJson()">📋 复制json</button>
<button class="btn btn-p" id="btnZip" disabled onclick="downloadCasFile()">📥 下载.cas文件</button>
<button class="btn btn-s" id="btnClear" onclick="resetForm()">🗑️ 清空队列</button>
</div>
</div>
<!-- 3. 输出预览 -->
<div class="card" id="outCard" style="display:none;">
<div class="card-t">📋 明文json预览</div>
<div class="out" id="out"></div>
</div>
</div>
<script>
const $ = function(id) { return document.getElementById(id); };
// ==== 工作流状态全局变量 ====
let activeMode = 'local';
let localQueue = [];
let isRunning = false;
let currentPayloads = [];
const LARGE_FILE_THRESHOLD = 26 * 1024 * 1024 * 1024;
const CHUNK_SIZE = 4 * 1024 * 1024;
// 动态分片计算(对齐天翼云28G防爆机制)
function getPartSize(fileSize) {
if (fileSize > LARGE_FILE_THRESHOLD) {
return 20 * 1024 * 1024;
}
return 10 * 1024 * 1024;
}
// ==== 界面与工具库函数 ====
function switchMode(mode) {
if (isRunning) {
alert("请等待当前任务结束再切换模式");
return;
}
activeMode = mode;
if (mode === 'server') {
$('tabServer').classList.add('active');
$('tabLocal').classList.remove('active');
$('panelServer').style.display = 'block';
$('panelLocal').style.display = 'none';
} else {
$('tabServer').classList.remove('active');
$('tabLocal').classList.add('active');
$('panelServer').style.display = 'none';
$('panelLocal').style.display = 'block';
}
resetForm();
}
function buildPath() {
try {
const main = $('catMain').value;
const name = $('catName').value.trim();
const year = $('catYear').value.trim();
let season = $('catSeason').value.trim();
const isMovie = main.indexOf('电影') !== -1 || main.indexOf('演唱会') !== -1;
$('seasonGroup').style.display = isMovie ? 'none' : 'flex';
if (isMovie) {
season = '';
}
let path = main;
if (name) {
path += '/' + name;
if (year) {
path += ' (' + year + ')';
}
}
if (season && !isMovie) {
if (/^\d+$/.test(season)) {
path += '/Season ' + season;
} else {
path += '/' + season;
}
}
// 界面预览路径加上云盘名称以作区分
const cloudType = $('cloudType').value;
$('finalPathDisplay').innerText = cloudType + 'cas/' + path + '/';
$('categoryInput').value = path;
} catch (e) {
console.error("路径构建失败:", e);
}
}
function smartExtract(filename) {
if ($('catName').value.trim() !== '') return;
let clean = filename.replace(/[_\s\[\]【】]/g, '.');
let ym = clean.match(/\.(19\d{2}|20\d{2})\./);
if (ym) $('catYear').value = ym[1];
let sm = clean.match(/\.[Ss](\d{1,2})/);
if (sm) $('catSeason').value = parseInt(sm[1], 10);
let si = clean.search(/\.(19\d{2}|20\d{2})\.|\.[Ss]\d{1,2}/);
if (si > 0) {
$('catName').value = clean.substring(0, si).replace(/\./g, ' ').trim();
} else {
$('catName').value = clean.split(/[.-]/)[0].trim();
}
buildPath();
}
function fmt(b) {
if (b < 1048576) return (b/1024).toFixed(1)+'KB';
if (b < 1073741824) return (b/1048576).toFixed(2)+'MB';
return (b/1073741824).toFixed(2)+'GB';
}
function esc(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
// 拖拽组件事件绑定
const dropEl = $('drop');
dropEl.addEventListener('dragover', function(e) {
e.preventDefault();
dropEl.classList.add('over');
});
dropEl.addEventListener('dragleave', function() {
dropEl.classList.remove('over');
});
dropEl.addEventListener('drop', function(e) {
e.preventDefault();
dropEl.classList.remove('over');
if (e.dataTransfer.files.length) {
handleLocalFiles({ target: { files: e.dataTransfer.files } });
}
});
function handleLocalFiles(e) {
const files = e.target.files;
if (!files.length) return;
smartExtract(files[0].name);
for (let i = 0; i < files.length; i++) {
localQueue.push({ file: files[i], state: 'wait', progress: 0, md5: '', sliceMd5: '', backendObj: null, casObj: null });
}
renderLocalList();
$('finput').value = '';
}
function renderLocalList() {
$('flist').innerHTML = '';
for (let i = 0; i < localQueue.length; i++) {
const item = localQueue[i];
const row = document.createElement('div');
row.className = 'frow ' + item.state;
let stText = '';
if (item.state === 'wait') stText = '⏳等待触发';
else if (item.state === 'work') stText = '⚙️计算中';
else if (item.state === 'done') stText = '✓完成';
else stText = '✗失败';
const prog = (item.state === 'work' && item.progress > 0) ? ' ' + Math.round(item.progress * 100) + '%' : '';
let badge = '';
if (item.backendObj) {
badge = '<span style="color:var(--ok); font-size:12px; margin-left:8px;">[本地安全归档]</span>';
}
row.innerHTML = '<div class="name">' + esc(item.file.name) + badge + '</div>' +
'<div class="size">' + fmt(item.file.size) + '</div>' +
'<div class="st ' + item.state + '">' + stText + prog + '</div>';
$('flist').appendChild(row);
}
}
// ==== 执行统一中控 ====
async function executeTasks() {
if (isRunning) return;
const ct = $('cloudType').value;
if (activeMode === 'local') {
if (localQueue.length === 0) {
alert("请先添加视频文件到列表!");
return;
}
const pendingTasks = localQueue.filter(q => q.state === 'wait' || q.state === 'fail');
if (pendingTasks.length === 0) {
alert("所有文件已计算完毕!");
return;
}
isRunning = true;
$('btnGo').disabled = true;
$('btnClear').disabled = true;
for (let i = 0; i < localQueue.length; i++) {
const item = localQueue[i];
if (item.state === 'done') continue;
item.state = 'work';
item.progress = 0;
renderLocalList();
try {
let casPayload = {};
// --- 139 移动云盘逻辑 ---
if (ct === '139') {
const shaAlgo = sha256.create();
let off = 0;
while (off < item.file.size) {
const end = Math.min(off + CHUNK_SIZE, item.file.size);
const buf = await item.file.slice(off, end).arrayBuffer();
shaAlgo.update(buf);
off = end;
item.progress = off / item.file.size;
renderLocalList();
await new Promise(r => setTimeout(r, 0));
}
casPayload = {
name: item.file.name,
size: item.file.size,
md5: "",
sliceMd5: "",
sha256: shaAlgo.hex().toLowerCase(),
create_time: String(Math.floor(Date.now() / 1000))
};
}
// --- 189 天翼云盘逻辑 ---
else {
const hashes = await readLocalFileMd5(item.file, function(p) {
item.progress = p;
renderLocalList();
});
casPayload = {
name: item.file.name,
size: item.file.size,
md5: hashes.md5,
sliceMd5: hashes.sliceMd5,
create_time: String(Math.floor(Date.now() / 1000)),
cloud: '189'
};
}
const b64Str = btoa(unescape(encodeURIComponent(JSON.stringify(casPayload))));
// 向后端发起写入指令,传递 cloud_type 进行物理隔离
const res = await fetch('/api/save_cas_only', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cas_data: casPayload,
b64: b64Str,
category: $('categoryInput').value,
cloud_type: ct
})
});
const data = await res.json();
item.backendObj = data;
item.casObj = casPayload; // 保存起来供展示
item.state = 'done';
} catch (e) {
item.state = 'fail';
console.error(e);
}
renderLocalList();
}
currentPayloads = localQueue.filter(q => q.state === 'done').map(q => q.casObj);
isRunning = false;
$('btnGo').disabled = false;
$('btnClear').disabled = false;
showOutput();
} else if (activeMode === 'server') {
const bp = $('serverBasePath').value.trim();
const sp = $('serverSubPath').value.trim();
if (!bp) {
alert("服务器基础挂载路径不能为空!");
return;
}
$('flist').innerHTML = '<div class="frow work">🖥️ 服务器核心正在扫描并流式解析目录内所有视频文件,大文件可能需要数十秒,请耐心等待...</div>';
$('btnGo').disabled = true;
isRunning = true;
try {
const res = await fetch('/api/process_server_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
base_path: bp,
sub_path: sp,
specific_file: $('serverSpecificFile').value.trim(),
category: $('categoryInput').value,
cloud_type: ct
})
});
const data = await res.json();
$('flist').innerHTML = '';
if (data.error) {
$('flist').innerHTML = '<div class="frow fail"><span class="name">❌ 服务器报错: ' + esc(data.error) + '</span></div>';
} else if (data.results && data.results.length > 0) {
for (let i = 0; i < data.results.length; i++) {
const r = data.results[i];
if (r.error) {
$('flist').innerHTML += '<div class="frow fail"><span class="name">❌ 解析失败: ' + esc(r.file_name) + ' - ' + esc(r.error) + '</span></div>';
} else {
$('flist').innerHTML += '<div class="frow done"><span class="name">✓ [服务器高速解析完成] ' + esc(r.cas_data.name) + ' (' + r.cas_data.part_size_used + ')</span><span style="color:var(--ok)">[已物理归档]</span></div>';
}
}
currentPayloads = data.results.filter(r => !r.error).map(r => r.cas_data);
showOutput();
} else {
$('flist').innerHTML = '<div class="frow wait"><span class="name">⚠️ 目标目录下没有扫描到任何常见的视频文件。</span></div>';
}
} catch (e) {
$('flist').innerHTML = '<div class="frow fail"><span class="name">❌ 与服务器通信异常</span></div>';
}
$('btnGo').disabled = false;
isRunning = false;
}
}
// ==== 浏览器纯前端哈希计算底层算法 (189 专用) ====
async function readLocalFileMd5(file, onProgress) {
const fileSize = file.size;
const currentSliceSize = getPartSize(fileSize);
const fileSpark = new SparkMD5.ArrayBuffer();
const sliceMd5Hexs = [];
let off = 0;
while (off < fileSize) {
const sliceEnd = Math.min(off + currentSliceSize, fileSize);
const sliceSpark = new SparkMD5.ArrayBuffer();
let inner = off;
while (inner < sliceEnd) {
const end = Math.min(inner + CHUNK_SIZE, sliceEnd);
const buf = await file.slice(inner, end).arrayBuffer();
fileSpark.append(buf);
sliceSpark.append(buf);
inner = end;
if (onProgress) onProgress(inner / fileSize);
await new Promise(resolve => setTimeout(resolve, 0));
}
sliceMd5Hexs.push(sliceSpark.end().toUpperCase());
off = sliceEnd;
}
const fullMd5 = fileSpark.end().toUpperCase();
let sliceMd5 = fullMd5;
if (fileSize > currentSliceSize) {
// 原生换行符无损拼接
sliceMd5 = SparkMD5.hash(sliceMd5Hexs.join('\n')).toUpperCase();
}
return { md5: fullMd5, sliceMd5: sliceMd5 };
}
// ==== 结果与输出面板控制 ====
function showOutput() {
if (currentPayloads.length === 0) return;
$('out').textContent = JSON.stringify(currentPayloads, null, 2);
$('outCard').style.display = 'block';
$('btnCopy').disabled = false;
$('btnZip').disabled = false;
if (currentPayloads.length > 1) {
$('btnZip').textContent = '📥 下载 .cas 压缩包 (Base64)';
} else {
$('btnZip').textContent = '📥 下载 .cas 文件 (Base64)';
}
}
function copyJson() {
navigator.clipboard.writeText($('out').textContent);
alert("明文 JSON 已成功复制到剪贴板!");
}
function downloadCasFile() {
if (currentPayloads.length === 0) return;
const files = [];
for (let i = 0; i < currentPayloads.length; i++) {
const q = currentPayloads[i];
const b64Str = btoa(unescape(encodeURIComponent(JSON.stringify(q))));
files.push({ name: q.name + '.cas', data: b64Str });
}
if (files.length === 1) {
const blob = new Blob([files[0].data], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = files[0].name;
a.click();
URL.revokeObjectURL(a.href);
return;
}
const zip = new JSZip();
for (let i = 0; i < files.length; i++) {
zip.file(files[i].name, files[i].data);
}
zip.generateAsync({ type: 'blob', compression: 'STORE' }).then(function(blob) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'cas_' + $('cloudType').value + '_' + new Date().getTime() + '.zip';
a.click();
URL.revokeObjectURL(a.href);
});
}
function resetForm() {
if (isRunning) return;
$('flist').innerHTML = '';
$('outCard').style.display = 'none';
$('btnCopy').disabled = true;
$('btnZip').disabled = true;
localQueue = [];
currentPayloads = [];
// 不重置基础类别,只清除名称和季数
$('catName').value = '';
$('catYear').value = '';
$('catSeason').value = '';
if (activeMode === 'server') {
$('serverSubPath').value = '';
$('serverSpecificFile').value = '';
} else {
$('finput').value = '';
}
buildPath();
}
// 首次启动时确保状态全部强绑定
buildPath();
switchMode('local');
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
# API 1:处理服务器本地媒体文件(增强版:支持文件夹批量遍历收割)
@app.route('/api/process_server_folder', methods=['POST'])
def api_process_server_folder():
req = request.get_json()
base_path = req.get('base_path', '').strip()
sub_path = req.get('sub_path', '').strip()
specific_file = req.get('specific_file', '').strip()
category_path = req.get('category', 'default')
cloud_type = req.get('cloud_type', '189')
if not base_path:
return jsonify({"error": "基础路径不可为空"}), 400
full_target_dir = os.path.join(base_path, sub_path)
# 建立即将处理的任务列表
target_files = []
if specific_file:
# 如果指定了具体文件
exact_file = os.path.join(full_target_dir, specific_file)
if os.path.isfile(exact_file):
target_files.append(exact_file)
else:
return jsonify({"error": f"找不到指定的具体文件: {exact_file}"}), 400
else:
# 如果只指派了文件夹,遍历文件夹下的所有视频文件
if not os.path.isdir(full_target_dir):
return jsonify({"error": f"目标文件夹不存在: {full_target_dir}"}), 400
for f in os.listdir(full_target_dir):
f_path = os.path.join(full_target_dir, f)
if os.path.isfile(f_path) and f.lower().endswith(VIDEO_EXTENSIONS):
target_files.append(f_path)
if not target_files:
return jsonify({"results": []})
# 根据云盘类型获取对应的基础落盘路径
base_out_dir = CAS_BASE_DIRS.get(cloud_type, CAS_BASE_DIRS["189"])
category_dir = os.path.join(base_out_dir, category_path)
os.makedirs(category_dir, exist_ok=True)
results = []
for f_path in target_files:
try:
cas_data = calculate_single_file_cas(f_path, cloud_type)
if cas_data:
# 转换 Base64 落盘
json_str = json.dumps(cas_data, ensure_ascii=False)
b64_str = base64.b64encode(json_str.encode('utf-8')).decode('utf-8')
saved_cas_path = os.path.join(category_dir, f"{cas_data['name']}.cas")
with open(saved_cas_path, 'w', encoding='utf-8') as fs:
fs.write(b64_str)
results.append({
"file_name": cas_data['name'],
"cas_data": cas_data,
"saved_path": saved_cas_path
})
except Exception as e:
results.append({"file_name": os.path.basename(f_path), "error": str(e)})
return jsonify({"results": results})
# API 2:保存前端传来的 CAS 特征(本地浏览器跨网络模式)
@app.route('/api/save_cas_only', methods=['POST'])
def api_save_cas_only():
req = request.get_json()
cas_data = req.get('cas_data', {})
b64_str = req.get('b64', '')
category_path = req.get('category', 'default')
cloud_type = req.get('cloud_type', '189')
try:
file_name = cas_data.get('name', 'unknown')
# 根据云盘类型获取对应的基础落盘路径
base_out_dir = CAS_BASE_DIRS.get(cloud_type, CAS_BASE_DIRS["189"])
category_dir = os.path.join(base_out_dir, category_path)
os.makedirs(category_dir, exist_ok=True)
saved_cas_path = os.path.join(category_dir, f"{file_name}.cas")
with open(saved_cas_path, 'w', encoding='utf-8') as f:
f.write(b64_str)
return jsonify({"status": "success"})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ==========================================
# 命令行 (CLI) 入口配置
# ==========================================
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="双擎云盘 CAS 控制中心")
parser.add_argument('--cli', action='store_true', help='启用纯命令行模式(不在后台启动Web服务器)')
parser.add_argument('--file', type=str, help='媒体文件的绝对路径(必须与 --cli 配合使用)')
parser.add_argument('--cloud', type=str, default='139', choices=['189', '139'], help='目标云盘类型 (189 默认 或 139)')
parser.add_argument('--category', type=str, default='default', help='落盘分类路径 (如 华语剧/剧名/Season 1)')
args = parser.parse_args()
# 当 TG 脚本等外部程序传入 --cli 参数时,不启动 Web 服务,直接纯命令流式运算
if args.cli:
if not args.file or not os.path.exists(args.file):
print(f"❌ 错误: 找不到指定的物理文件 -> {args.file}")
sys.exit(1)
print(f"\n🚀 开始纯命令行流式计算 CAS (目标云盘: {args.cloud})")
print(f"📄 目标文件: {args.file}")
cas_data = calculate_single_file_cas(args.file, args.cloud)
if cas_data:
json_str = json.dumps(cas_data, ensure_ascii=False)
b64_str = base64.b64encode(json_str.encode('utf-8')).decode('utf-8')
# 获取基础输出目录并建立完整路径
base_out_dir = CAS_BASE_DIRS.get(args.cloud, CAS_BASE_DIRS["189"])
category_dir = os.path.join(base_out_dir, args.category)
os.makedirs(category_dir, exist_ok=True)
saved_cas_path = os.path.join(category_dir, f"{cas_data['name']}.cas")
with open(saved_cas_path, 'w', encoding='utf-8') as f:
f.write(b64_str)
print("\n✨ CAS 锻造闭环完成 ✨")
print(f"📦 物理文件已安全归档至: {saved_cas_path}")
else:
print("\n❌ 计算异常,未能生成特征负载。")
sys.exit(1)
else:
# 默认模式:正常启动网页服务端
print("启动 Web 远程管理平台 (端口 5050)...")
app.run(host='0.0.0.0', port=5050)