15 Commits

Author SHA1 Message Date
yuming 9aba339d40 feat: 正在下载卡片显示文件总大小
部署到群晖 / deploy (push) Successful in 1m29s
后端 /get_download_list 已返回 total_size,前端补一行渲染。
布局:文件名 | 大小 | 百分比 | 速度 | 控制按钮。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:25:06 +08:00
yuming ee96cc0f46 chore: trigger CI after repo set public
部署到群晖 / deploy (push) Successful in 1m22s
2026-05-12 09:09:10 +08:00
yuming 482908e655 fix(ci): 拉取代码用宿主 IP+端口替代内网域名
部署到群晖 / deploy (push) Failing after 31s
git.ymxixi.space 是局域网 DNS 里的内网域名,act job 容器
默认 DNS 解析不到。直接用 192.168.1.66:3000(gitea 容器
端口映射)走 docker bridge → 宿主 LAN,最稳。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:03:23 +08:00
yuming 93ef7d54f5 fix(ci): 拉取代码改走局域网 gitea,不再依赖外网 action
部署到群晖 / deploy (push) Failing after 40s
actions/checkout 无论走 github.com 还是 gitea.com 都会被国内
网络偶发 reset。runner 跟 gitea 在同一群晖,直接 git clone 本地
仓库最稳。

同时给 apt 安装列表补上 git 和 ca-certificates。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:01:25 +08:00
yuming 7cb60ae8a8 fix(ci): actions/checkout 走 gitea.com 镜像
部署到群晖 / deploy (push) Failing after 6s
避免裸连 github.com 偶发 unexpected EOF 导致部署失败。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:50:37 +08:00
yuming 79966d2130 fix(ci): 彻底清空 Docker daemon 代理,避免拉镜像走 socks5 被 reset
部署到群晖 / deploy (push) Successful in 1m20s
之前只写了空 daemon.json,但 dockerd 仍会继承环境里的
HTTPS_PROXY=socks5://192.168.1.119:1080,导致拉 daocloud
镜像源时被 socks 代理拒绝。

三处同时置空:
1) systemd drop-in:覆盖 dockerd 启动环境变量
2) /etc/docker/daemon.json:显式声明 proxies 全空
3) /root/.docker/config.json:清空 client 端代理

装完 docker 后强制重启 daemon 让新配置生效。
应用容器内部 socks5 配置完全不动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:43:52 +08:00
yuming 53d3ab7769 fix: 网络断开后任务永久卡在"下载中"
部署到群晖 / deploy (push) Failing after 34s
三处协同修复:
1) worker 异常分支补 _release_stuck_task,
   标记 FailedDownload、推进 finish_task、清残留。
2) download_media 中 fetch_message 加 try-except,
   连接异常返回 FailedDownload,不再让异常冒泡。
3) download_chat_task 用 try/finally 兜底回写
   chat_download_config.total_task,避免 _wait_node_finish
   误判频道已完成而切到下一个。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:46:05 +08:00
yuming 1cb70dabe0 fix: 容器时区设为 Asia/Shanghai,日志显示北京时间
部署到群晖 / deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:55:10 +08:00
yuming 383c8ce17c feat: 过滤条件新增文件类型多选,支持 mp3/mp4/jpg 等常见类型
部署到群晖 / deploy (push) Successful in 1m42s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:21:36 +08:00
yuming 6fd4239ab4 feat: 任务队列改为横向卡片布局,超出自动换行
部署到群晖 / deploy (push) Successful in 3m11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:12:11 +08:00
yuming 49fb1bd55a fix: 无下载任务时速度显示不归零问题
当所有任务处于跳过状态时,update_download_status 不被调用,
导致 _total_download_speed 保留上一次的旧值一直显示。
通过检查 _last_download_time,超过 2 秒无新数据则返回 0。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:11:08 +08:00
yuming 3c1f8e4c5a fix: 为 _wait_node_finish 添加超时保护,避免异常时队列永久卡死
部署到群晖 / deploy (push) Successful in 1m39s
默认超时 3600s,超时后记录警告日志并强制跳过当前频道。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:04:27 +08:00
yuming e40b15da33 fix: 修复任务队列第二个任务不自动启动的问题
部署到群晖 / deploy (push) Successful in 58s
_wait_node_finish 传入的是 TaskNode 但字段在 ChatDownloadConfig 上,
导致 AttributeError 使协程崩溃,第二个任务永远无法启动。
改为传入 ChatDownloadConfig 对象。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:39:14 +08:00
yuming ac7c491e6c fix: 在安装Docker前写入干净配置并清除代理环境变量
部署到群晖 / deploy (push) Successful in 1m4s
2026-04-29 22:34:30 +08:00
yuming 7b338ad086 fix: CI构建前清除Docker代理配置避免镜像拉取失败
部署到群晖 / deploy (push) Failing after 1m2s
2026-04-29 22:29:41 +08:00
6 changed files with 346 additions and 67 deletions
+57 -2
View File
@@ -7,17 +7,72 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
# 清除可能存在的代理环境变量,防止 Docker 通过代理拉取镜像
env:
HTTP_PROXY: ""
HTTPS_PROXY: ""
http_proxy: ""
https_proxy: ""
NO_PROXY: "*"
no_proxy: "*"
steps:
- name: 安装 Docker CLI
run: |
# === 彻底清零 Docker 代理,避免 daemon 拉 daocloud 时走 socks5 被 reset ===
# 局域网 socks5 代理(192.168.1.119:1080)只供应用容器内部使用,
# 这里把 daemon 自己的代理一律置空,让它直连国内 docker mirror。
# 1) systemd drop-indockerd 启动时强制覆盖环境变量为空
mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/no-proxy.conf <<'EOF'
[Service]
Environment="HTTP_PROXY="
Environment="HTTPS_PROXY="
Environment="NO_PROXY=*"
EOF
# 2) daemon.json 显式声明空 proxies(双保险,覆盖任何外部继承)
mkdir -p /etc/docker
cat > /etc/docker/daemon.json <<'EOF'
{
"proxies": {
"http-proxy": "",
"https-proxy": "",
"no-proxy": "*"
}
}
EOF
# 3) docker 客户端配置:影响 docker build/buildx 自身走的代理
mkdir -p /root/.docker
cat > /root/.docker/config.json <<'EOF'
{
"proxies": {
"default": {
"httpProxy": "",
"httpsProxy": "",
"noProxy": "*"
}
}
}
EOF
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
apt-get update -qq
apt-get install -y -qq docker.io
# 顺手装 git:拉取代码那步要用,ubuntu-latest 默认不带
apt-get install -y -qq docker.io git ca-certificates
# 装完之后让 daemon 重新读上面三处配置;不同启动方式都试一遍
systemctl daemon-reload 2>/dev/null || true
systemctl restart docker 2>/dev/null || service docker restart 2>/dev/null || true
# 给 daemon 一点时间就绪,否则紧接着 docker build 会连不上 socket
sleep 3
- name: 拉取代码
uses: actions/checkout@v4
# 直接走宿主 IP+端口,避免 act job 容器解析不了 git.ymxixi.space 内网域名
run: |
git init
git remote add origin http://192.168.1.66:3000/adminym/telegram-downloader.git
# 优先按本次触发的 commit SHA 拉,确保部署的是 push 上来的那次
git fetch --depth 1 origin "$GITHUB_SHA"
git checkout FETCH_HEAD
- name: 构建镜像
run: docker build -t telegram-downloader:latest .
+6
View File
@@ -14,6 +14,12 @@ FROM python:3.11.9-alpine AS runtime-image
WORKDIR /app
# 设置时区为东八区(北京时间),让日志和数据库 download_time 字段使用本地时间
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.ustc.edu.cn|g' /etc/apk/repositories \
&& apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
# 从上面自己编译的阶段复制依赖(支持任意架构)
COPY --from=compile-image /usr/local/lib/python3.11/site-packages \
/usr/local/lib/python3.11/site-packages
+4
View File
@@ -13,6 +13,10 @@ services:
# ports:
# - "5000:5000"
# 时区设置(让日志、数据库 download_time 显示北京时间)
environment:
- TZ=Asia/Shanghai
volumes:
# 配置、session、进度数据统一持久化到 ./appdata 目录
- "./appdata:/app/appdata"
+111 -47
View File
@@ -423,7 +423,15 @@ async def download_media(
task_start_time: float = time.time()
media_size = 0
_media = None
message = await fetch_message(client, message)
# 关键修复:fetch_message 是发起任何下载前的网络调用,连接异常时不能让异常一路冒泡,
# 否则 worker 只会打日志,状态永远停在 DownloadingUI 卡死。
try:
message = await fetch_message(client, message)
except Exception as fetch_err:
logger.warning(
f"Message[{getattr(message, 'id', '?')}] 拉取消息失败(可能连接断开): {fetch_err}"
)
return DownloadStatus.FailedDownload, None
try:
for _type in media_types:
_media = getattr(message, _type, None)
@@ -581,18 +589,23 @@ def _check_config() -> bool:
async def worker(client: pyrogram.client.Client):
"""Work for download task"""
"""下载任务消费者协程"""
while app.is_running:
message = None
node = None
try:
item = await queue.get()
message = item[0]
node: TaskNode = item[1]
node = item[1]
if node.is_stop_transmission:
# 主动中止:把队列里残留的下载中状态清掉,避免 UI 一直显示 Downloading
_release_stuck_task(node, message, DownloadStatus.SkipDownload)
continue
if is_message_skipped(str(node.chat_id), message.id):
skip_message(str(node.chat_id), message.id)
_release_stuck_task(node, message, DownloadStatus.SkipDownload)
continue
if node.client:
@@ -600,7 +613,39 @@ async def worker(client: pyrogram.client.Client):
else:
await download_task(client, message, node)
except Exception as e:
logger.exception(f"{e}")
logger.exception(f"worker 捕获到未处理异常: {e}")
# 关键修复:worker 吞异常时必须把状态推进,否则 finish_task 永远追不上 total_task
if node is not None and message is not None:
_release_stuck_task(node, message, DownloadStatus.FailedDownload)
def _release_stuck_task(
node: "TaskNode",
message: "pyrogram.types.Message",
status: "DownloadStatus",
):
"""将异常/中断的任务从"下载中"状态释放,避免任务队列永久卡死。
做三件事:
1) 标记 download_status,让 UI 不再显示"下载中"
2) 推进 finish_task,让 _wait_node_finish 能正常退出。
3) 清理 _download_result 残留条目,避免速度/列表脏数据。
"""
try:
msg_id = getattr(message, "id", None)
if msg_id is None:
return
node.download_status[msg_id] = status
# finish_task 通过 app.set_download_id 推进;bot 模式下原逻辑也不走 set_download_id
# 这里保持一致:非 bot 时才推进,避免重复计数。
if not node.bot:
try:
app.set_download_id(node, msg_id, status)
except Exception as inner:
logger.warning(f"释放卡死任务时 set_download_id 失败 msg_id={msg_id}: {inner}")
remove_download_entry(node.chat_id, msg_id)
except Exception as e:
logger.warning(f"释放卡死任务清理失败: {e}")
async def download_chat_task(
@@ -657,58 +702,77 @@ async def download_chat_task(
for message in skipped_messages:
await add_download_task(message, node)
async for message in messages_iter: # type: ignore
# Update checking progress for each message
increment_task_stat("checked_messages")
# 关键修复:消息迭代和缓存写入必须包进 try/finally
# 否则中途抛 Connection lost 时 total_task 不会被回写,_wait_node_finish 误判频道已完成,
# 后续 worker 还在跑就被切到下一个频道,UI 永远显示"下载中"。
iter_completed = False
try:
async for message in messages_iter: # type: ignore
# Update checking progress for each message
increment_task_stat("checked_messages")
meta_data = MetaData()
meta_data = MetaData()
caption = message.caption
if caption:
caption = validate_title(caption)
app.set_caption_name(node.chat_id, message.media_group_id, caption)
app.set_caption_entities(
node.chat_id, message.media_group_id, message.caption_entities
)
else:
caption = app.get_caption_name(node.chat_id, message.media_group_id)
set_meta_data(meta_data, message, caption)
if app.need_skip_message(chat_download_config, message.id):
continue
if app.exec_filter(chat_download_config, meta_data):
# 改动点 B:通过 filter 的消息计数,作为 X/N 的分母实时递增
increment_task_stat("qualified_files")
await add_download_task(message, node)
else:
node.download_status[message.id] = DownloadStatus.SkipDownload
increment_task_stat("skipped_files")
if message.media_group_id:
await upload_telegram_chat(
client,
node.upload_user,
app,
node,
message,
DownloadStatus.SkipDownload,
caption = message.caption
if caption:
caption = validate_title(caption)
app.set_caption_name(node.chat_id, message.media_group_id, caption)
app.set_caption_entities(
node.chat_id, message.media_group_id, message.caption_entities
)
else:
caption = app.get_caption_name(node.chat_id, message.media_group_id)
set_meta_data(meta_data, message, caption)
chat_download_config.need_check = True
chat_download_config.total_task = node.total_task
node.is_running = True
if app.need_skip_message(chat_download_config, message.id):
continue
if app.exec_filter(chat_download_config, meta_data):
# 改动点 B:通过 filter 的消息计数,作为 X/N 的分母实时递增
increment_task_stat("qualified_files")
await add_download_task(message, node)
else:
node.download_status[message.id] = DownloadStatus.SkipDownload
increment_task_stat("skipped_files")
if message.media_group_id:
await upload_telegram_chat(
client,
node.upload_user,
app,
node,
message,
DownloadStatus.SkipDownload,
)
iter_completed = True
finally:
# 不论遍历是否正常结束,都必须把 node.total_task 同步给 chat_download_config
# 否则 _wait_node_finish 会用 total_task=0 的旧值立即返回,跳过对 worker 的等待。
chat_download_config.need_check = True
chat_download_config.total_task = node.total_task
node.is_running = True
# 改动点 C:遍历正常完成,把实际 qualified_files 写入缓存并覆盖 estimated_total
# 仅在正常完成时写入(异常/中断时不执行,避免脏缓存)
actual_total = get_task_progress().get("qualified_files", 0)
db.save_scan_cache(str(node.chat_id), filter_key, actual_total)
update_task_progress(estimated_total=actual_total, is_checking=False)
if iter_completed:
actual_total = get_task_progress().get("qualified_files", 0)
db.save_scan_cache(str(node.chat_id), filter_key, actual_total)
update_task_progress(estimated_total=actual_total, is_checking=False)
else:
# 异常中断时也要把 is_checking 关掉,避免 UI 一直显示"扫描中"
update_task_progress(is_checking=False)
async def _wait_node_finish(node: TaskNode):
"""等待单个频道的所有下载任务完成(含重试中的任务)"""
async def _wait_node_finish(chat_config, timeout: int = 3600):
"""等待单个频道的所有下载任务完成(含重试中的任务),超时后强制跳过避免队列卡死"""
deadline = asyncio.get_event_loop().time() + timeout
while True:
if node.need_check and node.finish_task >= node.total_task:
if chat_config.need_check and chat_config.finish_task >= chat_config.total_task:
break
if asyncio.get_event_loop().time() > deadline:
logger.warning(
f"等待频道任务完成超时({timeout}s),强制跳过,"
f"finish={chat_config.finish_task} total={chat_config.total_task}"
)
break
await asyncio.sleep(0.5)
@@ -725,7 +789,7 @@ async def download_all_chat(client: pyrogram.Client):
finally:
value.need_check = True
# 等当前频道所有下载任务跑完,再拍快照;否则 completed_files 还是 0
await _wait_node_finish(value.node)
await _wait_node_finish(value)
snapshot_current_chat()
reset_task_progress()
# 所有频道都已在循环内快照并重置,此处仅确保最终状态干净
+3
View File
@@ -59,6 +59,9 @@ def get_download_result() -> dict:
def get_total_download_speed() -> int:
"""get total download speed"""
# 超过 2 秒没有新数据,视为速度为 0(防止旧速度值残留显示)
if time.time() - _last_download_time > 2.0:
return 0
return _total_download_speed
+165 -18
View File
@@ -214,13 +214,13 @@
.qbanner-summary { font-size: 11px; }
/* 队列卡片(已完成 / 当前 / 排队中) */
#qbanner-list { display: flex; flex-wrap: wrap; gap: 8px; }
.qcard {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 10px; border-radius: 6px;
margin-bottom: 6px;
border: 1px solid transparent;
flex: 1 1 220px; min-width: 200px; max-width: 380px;
}
.qcard:last-child { margin-bottom: 0; }
.qcard-icon { font-size: 14px; line-height: 1.5; flex-shrink: 0; }
.qcard-main { flex: 1; min-width: 0; }
.qcard-name { font-weight: 600; font-size: 13px; color: var(--text);
@@ -298,6 +298,8 @@
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dl-pct { font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; }
/* 总大小:跟速度同级别的次要信息,靠灰度区分 */
.dl-size { font-size: 11px; color: var(--muted); flex-shrink: 0; }
.dl-spd { font-size: 11px; color: var(--muted); flex-shrink: 0; }
.prog { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.prog-fill {
@@ -377,6 +379,43 @@
background: var(--orange); color: #0d1117; font-size: 10px; font-weight: 700;
}
/* ════ 文件类型多选下拉 ════ */
.fb-field-ext { flex-shrink: 0; border-right: 1px solid var(--border); padding: 0 12px; }
.ext-pop {
display: none;
position: absolute; top: calc(100% - 4px); left: 8px; z-index: 320;
width: 320px; max-height: 360px; overflow: auto;
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.45);
padding: 10px 12px;
}
.ext-pop.on { display: block; }
.ext-grp-title {
font-size: 11px; color: var(--muted); font-weight: 600;
margin: 8px 0 5px; letter-spacing: .5px;
}
.ext-grp-title:first-child { margin-top: 0; }
.ext-grp { display: flex; flex-wrap: wrap; gap: 5px; }
.ext-chip {
display: inline-flex; align-items: center;
padding: 3px 9px; border-radius: 12px;
border: 1px solid var(--border); background: transparent;
color: var(--muted); font-size: 11px; cursor: pointer;
transition: all .12s; user-select: none;
}
.ext-chip:hover { border-color: var(--accent); color: var(--accent); }
.ext-chip.on { border-color: var(--orange); color: var(--orange); background: rgba(210,153,34,.12); }
.ext-pop-foot {
display: flex; justify-content: space-between; align-items: center;
margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border);
font-size: 11px;
}
.ext-pop-foot a {
color: var(--muted); cursor: pointer; text-decoration: none;
}
.ext-pop-foot a:hover { color: var(--accent); }
.ext-pop-foot a.danger:hover { color: var(--red); }
/* ════ 过滤器弹窗 ════ */
.filter-mask {
display: none; position: fixed; inset: 0;
@@ -968,6 +1007,15 @@
</button>
</div>
<!-- 文件类型 -->
<div class="fb-field fb-field-ext">
<div class="fb-label">文件类型</div>
<button class="btn-filter" id="ext-open-btn" onclick="toggleExtPop(event)">
🗂 全部 <span class="filter-badge" id="ext-badge" style="display:none">0</span>
</button>
<div class="ext-pop" id="ext-pop" onclick="event.stopPropagation()"></div>
</div>
<!-- 保存路径 -->
<div class="fb-field fb-field-path">
<div class="fb-label">
@@ -1126,25 +1174,117 @@
return d.toLocaleDateString('zh-CN');
}
// ── 文件类型多选 ──
// 分组定义;扩展名要全小写(底层 file_extension 字段保证小写)
const EXT_GROUPS = [
{ name: '🎬 视频', items: ['mp4','mov','mkv','avi','webm','flv','wmv'] },
{ name: '🎵 音频', items: ['mp3','flac','wav','m4a','aac','ogg','opus'] },
{ name: '🖼 图片', items: ['jpg','png','gif','webp','heic','bmp'] },
{ name: '📄 文档', items: ['pdf','zip','rar','7z','txt','doc','docx','xlsx','pptx','epub'] },
];
let selectedExtensions = []; // 当前主表单选中的扩展名列表
function renderExtPop() {
const pop = document.getElementById('ext-pop');
if (!pop) return;
const groups = EXT_GROUPS.map(g => {
const chips = g.items.map(ext => {
const on = selectedExtensions.includes(ext) ? ' on' : '';
return `<span class="ext-chip${on}" onclick="toggleExt('${ext}')">${ext}</span>`;
}).join('');
return `<div class="ext-grp-title">${g.name}</div><div class="ext-grp">${chips}</div>`;
}).join('');
pop.innerHTML = groups + `
<div class="ext-pop-foot">
<a onclick="clearExtensions()" class="danger">清空</a>
<a onclick="closeExtPop()">完成</a>
</div>`;
}
function toggleExtPop(ev) {
if (ev) ev.stopPropagation();
const pop = document.getElementById('ext-pop');
const open = !pop.classList.contains('on');
if (open) {
renderExtPop();
pop.classList.add('on');
// 点击外部关闭:注册一次性 listener
setTimeout(() => document.addEventListener('click', closeExtPopOnOutside), 0);
} else {
closeExtPop();
}
}
function closeExtPop() {
document.getElementById('ext-pop').classList.remove('on');
document.removeEventListener('click', closeExtPopOnOutside);
}
function closeExtPopOnOutside() { closeExtPop(); }
function toggleExt(ext) {
const i = selectedExtensions.indexOf(ext);
if (i >= 0) selectedExtensions.splice(i, 1);
else selectedExtensions.push(ext);
renderExtPop();
updateExtBtn();
}
function clearExtensions() {
selectedExtensions = [];
renderExtPop();
updateExtBtn();
}
function updateExtBtn() {
const btn = document.getElementById('ext-open-btn');
const badge = document.getElementById('ext-badge');
if (!btn) return;
const n = selectedExtensions.length;
if (n > 0) {
btn.className = 'btn-filter active';
btn.firstChild.textContent = '🗂 已选 ';
badge.style.display = '';
badge.textContent = n;
} else {
btn.className = 'btn-filter';
btn.firstChild.textContent = '🗂 全部 ';
badge.style.display = 'none';
}
}
function buildExtensionFilter(exts) {
if (!exts || !exts.length) return '';
if (exts.length === 1) return `file_extension == '${exts[0]}'`;
return `file_extension == r'(${exts.join('|')})'`;
}
// 从表达式里提取已经写在 file_extension == r'(...)' / 'xxx' 里的扩展名列表
function parseExtensionsFromFilter(filter) {
if (!filter) return [];
const reg = filter.match(/file_extension\s*==\s*r'\(([^)]+)\)'/i);
if (reg) return reg[1].split('|').map(s => s.trim().toLowerCase()).filter(Boolean);
const single = filter.match(/file_extension\s*==\s*'([^']+)'/i);
if (single) return [single[1].trim().toLowerCase()];
return [];
}
// ── 加载配置 ──
function parseFilterDisplay(filter) {
if (!filter) return '';
const startM = filter.match(/message_date\s*>=\s*(\d{4}-\d{2}-\d{2})/);
const endM = filter.match(/message_date\s*<=\s*(\d{4}-\d{2}-\d{2})/);
const exts = parseExtensionsFromFilter(filter);
const parts = [];
if (startM || endM) {
if (startM && endM) parts.push('📅 ' + startM[1] + ' 至 ' + endM[1]);
else if (startM) parts.push('📅 ' + startM[1] + ' 起');
else parts.push('📅 至 ' + endM[1]);
// 检查是否还有日期之外的自定义条件
const rest = filter
.replace(/message_date\s*>=\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/gi, '')
.replace(/message_date\s*<=\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/gi, '')
.replace(/\band\b/gi, '').trim();
if (rest) parts.push('🔍 ' + rest);
} else {
parts.push('🔍 ' + filter);
}
if (exts.length) parts.push('🗂 ' + exts.join(', '));
// 检查是否还有日期/扩展名之外的自定义条件
const rest = filter
.replace(/message_date\s*>=\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/gi, '')
.replace(/message_date\s*<=\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/gi, '')
.replace(/file_extension\s*==\s*r'\([^)]+\)'/gi, '')
.replace(/file_extension\s*==\s*'[^']+'/gi, '')
.replace(/\band\b/gi, '').trim();
if (rest) parts.push('🔍 ' + rest);
if (!parts.length) parts.push('🔍 ' + filter);
return parts.join(' · ');
}
@@ -1310,19 +1450,25 @@
}
function buildFilterFromForm(startDate, endDate) {
// 高级过滤器表达式存在时优先用它,文件类型选择被忽略(与日期同样的策略)
const custom = filterExpression || '';
if (custom) return custom;
if (!startDate) return '';
return 'message_date >= ' + startDate + ' 00:00:00' +
(endDate ? ' and message_date <= ' + endDate + ' 23:59:59' : '');
const segs = [];
if (startDate) {
segs.push('message_date >= ' + startDate + ' 00:00:00');
if (endDate) segs.push('message_date <= ' + endDate + ' 23:59:59');
}
const extSeg = buildExtensionFilter(selectedExtensions);
if (extSeg) segs.push(extSeg);
return segs.join(' and ');
}
// 把队列里的起止日期格式化成 "📅 2025-11-20 至 2026-04-23";无日期则 fallback 到原 filter
// 把队列里的起止日期/扩展名格式化展示;优先用解析 filter(能覆盖日期+文件类型)
function formatQueueDateRange(startDate, endDate, fallbackFilter) {
if (startDate) {
return '📅 ' + startDate + ' 至 ' + (endDate || startDate);
}
return fallbackFilter || '无过滤条件';
const parsed = parseFilterDisplay(fallbackFilter || '');
if (parsed) return parsed;
if (startDate) return '📅 ' + startDate + ' 至 ' + (endDate || startDate);
return '无过滤条件';
}
function renderQueue() {
@@ -1641,6 +1787,7 @@
<div class="dl-item">
<div class="dl-top">
<div class="dl-name" title="${item.filename}">${item.filename}</div>
<span class="dl-size">${item.total_size||''}</span>
<span class="dl-pct">${item.download_progress}%</span>
<span class="dl-spd">${item.download_speed||''}</span>
<div class="dl-ctrl">