# -*- coding: utf-8 -*- # jm.py — JM 下载模块 # 流程: 源站下载 -> 本地打包 ZIP -> 复制到本机 OSS (/mnt/oss/jm/books/JM) -> 删除本地临时文件 -> QQ 回复分享页链接 # 约定: 任何情况下不在 QQ 内直发文件/图片; 失败时回复源站原url或 JMID import asyncio import json import os from pathlib import Path BASE_DIR = os.path.dirname(os.path.abspath(__file__)) import re import shutil import tempfile import threading import time import unicodedata from pathlib import Path # ---------- 配置 ---------- JM_DIR = os.environ.get("JM_DIR", os.path.join(BASE_DIR, "jm")) # 留存: 直接写本机 ossfs2 挂载的 OSS, 不再上传远程文件服务器。 # /mnt/oss/jm/books/JM 与 pan 上的 /mnt/A/jm/books/JM 是同一位置, 分享页 JMGeneralDownload 即指向它。 OSS_MOUNT = os.environ.get("JM_OSS_MOUNT", "/mnt/oss") OSS_JM_DIR = os.environ.get("JM_OSS_DIR", os.path.join(OSS_MOUNT, "jm", "books", "JM")) SHARE_PAGE = "https://pan.rrrosmontis.icu/share/JMGeneralDownload" PROXY_CONFIG_PATH = os.environ.get("BOT_PROXY_CONFIG", os.path.join(BASE_DIR, "proxies.json")) PROXY_COOLDOWN = 600 # 节点失败后冷却秒数 # 下载模式 (获取 ZIP 的方式): # "openapi" = 走 openapi jm 服务 (hk 直连源站打包后回 ZIP), 代理绕行加速 + 全失败直连兜底 # "local" = 本地 jmcomic + 代理下载打包 (旧模式) # 说明: 元数据解析/搜索仍用本地 jmcomic (单次轻量 API 调用); openapi 模式只是把 # 大头 (逐张下载+打包) offload 给 hk。上传 cf / 分享链接流程两种模式一致。 JM_FETCH_MODE = "openapi" # 2026-09-23 启用 openapi 模式 OPENAPI_JM_URL = "https://openapi.rrrosmontis.icu/jm/get_comic" OPENAPI_TIMEOUT = (30, 1800) # (连接, 读取) 秒; 30 章 ZIP 经代理 ~200KB/s 需留足余量 MAX_CHAPTERS = 30 # 单段最多章节 SEARCH_LIMIT = 10 # 搜索最多返回条数 HTTP_TIMEOUT = 30 HELP_TEXT = ( "JM 下载\n" "━━━━━━━━━━━━\n" "• .jm [起始-结束]\n" "• .jm zip [起始-结束]\n" "• .jm ss <关键词> [页码] 搜索\n" "示例: .jm 350234\n" " .jm 350234 1-30\n" " .jm ss 关键词 2\n" "• 总章节 ≤30 可不填范围自动全量\n" "• 每段最多 30 章\n" "━━━━━━━━━━━━\n" "下载完成将发送文件分享地址" ) _active_users = set() # 同一用户防并发 _album_locks = {} # album_id -> asyncio.Lock: 同一 JMID 防并发 (共享 ZIP 文件名/工作目录) _album_locks_guard = threading.Lock() _proxy_lock = threading.Lock() def _get_album_lock(album_id): """取指定 JMID 的 asyncio.Lock (去重/下载/打包/上传/清理 全段持锁, 同 JMID 串行, 不同 JMID 并行)""" key = str(album_id) with _album_locks_guard: lock = _album_locks.get(key) if lock is None: lock = asyncio.Lock() _album_locks[key] = lock return lock # ---------- 代理列表 (从 proxies.json 导入, 轮换 + 回退) ---------- def _load_proxy_list(): """读取代理列表配置, 失败返回空列表""" try: with open(PROXY_CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) return [p for p in data.get("proxies", []) if p.get("localPort")] except Exception as e: print(f"[jm] 代理列表加载失败: {e}") return [] PROXY_LIST = _load_proxy_list() _proxy_idx = 0 # 轮换指针 _proxy_unavailable = {} # localPort -> 失败时间戳 def _next_proxy(): """轮换选取下一个可用代理, 全部冷却中则清空重来""" global _proxy_idx with _proxy_lock: if not PROXY_LIST: return None now = time.time() for port in list(_proxy_unavailable): if now - _proxy_unavailable[port] >= PROXY_COOLDOWN: del _proxy_unavailable[port] for _ in range(len(PROXY_LIST)): p = PROXY_LIST[_proxy_idx % len(PROXY_LIST)] _proxy_idx = (_proxy_idx + 1) % len(PROXY_LIST) if p["localPort"] not in _proxy_unavailable: return p # 全部冷却中: 保留冷却记录, 降级使用最早失败的节点 return min(PROXY_LIST, key=lambda p: _proxy_unavailable.get(p["localPort"], 0)) def _safe_text(s): """确保文本可被 Windows GBK 控制台输出 (剔除 emoji 等无法编码字符)""" try: s.encode("gbk") return s except (UnicodeEncodeError, AttributeError): return s.encode("gbk", "replace").decode("gbk", "replace") def _mark_proxy_down(proxy): """标记代理不可用, 进入冷却""" if not proxy: return with _proxy_lock: _proxy_unavailable[proxy["localPort"]] = time.time() print(f"[jm] 代理不可用, 冷却 {PROXY_COOLDOWN}s: {_safe_text(proxy.get('name'))} " f"(127.0.0.1:{proxy['localPort']})") def _jm_proxies(proxy=None): """jmcomic postman 使用的代理配置 (本地 xray http 入站)""" p = proxy or _next_proxy() if not p: return {} return { "http": f"http://127.0.0.1:{p['localPort']}", "https": f"http://127.0.0.1:{p['localPort']}", } # ---------- 工具 ---------- def _sanitize_filename(name): """清理文件名非法字符, 保留中英文/数字/常见符号""" cleaned = unicodedata.normalize("NFKC", name or "") result = [] for ch in cleaned: if ch.isalnum() or ch in ("_", "-", ".", "(", ")", "[", "]", "(", ")", "【", "】"): result.append(ch) elif ch.isspace(): result.append("_") else: result.append("-") s = "".join(result) while "__" in s: s = s.replace("__", "_") while "--" in s: s = s.replace("--", "-") return s[:80].strip("_-") or "unnamed" def _source_url(album_id): """源站原url (域名运行时取自 jmcomic 库配置, 不硬编码)""" try: from jmcomic import JmModuleConfig domains = JmModuleConfig.get_html_domain_all() or [] if domains: return f"https://{domains[0]}/album/{album_id}" except Exception: pass return f"JMID {album_id}" def _clear_local_cache(): """清空本地临时目录, 返回提示文本""" d = Path(JM_DIR) if not d.exists(): return "本地临时目录不存在, 无需清理" total = 0 count = 0 for p in d.rglob("*"): if p.is_file(): count += 1 try: total += p.stat().st_size except OSError: pass shutil.rmtree(d, ignore_errors=True) d.mkdir(parents=True, exist_ok=True) return f"已清理本地临时文件: {count} 个, 释放 {total / 1024 / 1024:.1f} MB" def _cleanup_album(album_id, zip_path=None): """删除某次的临时图片目录和 ZIP (上传完毕后调用)""" try: shutil.rmtree(Path(JM_DIR) / str(album_id), ignore_errors=True) except Exception: pass if zip_path: try: zp = Path(zip_path) if zp.exists(): os.remove(zp) except Exception: pass # ---------- 下载 + 打包 ---------- class _ParamError(RuntimeError): """参数类错误, 不应触发代理轮换""" def _is_missing_album_error(e): """判定 jmcomic 的「本子/章节不存在」类异常 (确定性内容错误)。 优先按类型匹配 (jmcomic.jm_exception.MissingAlbumPhotoException), 版本差异取不到类时按类名兜底""" try: from jmcomic.jm_exception import MissingAlbumPhotoException if isinstance(e, MissingAlbumPhotoException): return True except ImportError: pass return type(e).__name__ == "MissingAlbumPhotoException" def _resolve_album_plan(album_id, range_start, range_end, proxy=None): """获取元数据并确定章节范围与 ZIP 文件名。 返回 (title, episodes, range_start, range_end, zip_name),失败抛异常""" from jmcomic import JmModuleConfig option = JmModuleConfig.option_class().default() try: option.client.postman.meta_data.proxies = _jm_proxies(proxy) except Exception: pass client = option.build_jm_client() try: album = client.get_album_detail(int(album_id)) except Exception as e: # jmcomic 对不存在/已下架 JMID 抛 MissingAlbumPhotoException, 属确定性内容错误, # 转 _ParamError 避免误伤代理池 (冷却重试无意义); 其余异常(网络/解析)照旧冒泡走代理轮换 if _is_missing_album_error(e): raise _ParamError(f"JMID {album_id} 不存在或已下架") raise title = getattr(album, "title", "") or str(album_id) episodes = getattr(album, "episode_list", []) or [] total = len(episodes) if total == 0: raise _ParamError("未找到任何章节") if range_start is None: if total > MAX_CHAPTERS: raise _ParamError( f"共 {total} 章, 超过单段上限 {MAX_CHAPTERS} 章, 请分段下载 (如 1-{MAX_CHAPTERS})" ) range_start, range_end = 1, total else: if range_start < 1 or range_end < range_start: raise _ParamError("章节范围不合法") if range_end - range_start + 1 > MAX_CHAPTERS: raise _ParamError(f"章节跨度超过 {MAX_CHAPTERS} 章, 请缩小范围") if range_start > total: raise _ParamError(f"起始章节超出总章节数 {total}") range_end = min(range_end, total) zip_name = f"{_sanitize_filename(title)}_{album_id}_ch{range_start}-{range_end}.zip" return title, episodes, range_start, range_end, zip_name def _check_existing(zip_name): """检查 OSS 留存目录是否已有同名 ZIP, 返回 bool (OSS 未挂载或异常视为不存在)""" try: if not os.path.ismount(OSS_MOUNT): return False return os.path.isfile(os.path.join(OSS_JM_DIR, zip_name)) except Exception: return False def _download_and_pack(album_id, range_start, range_end, proxy=None, plan=None): """下载并打包 ZIP。成功返回 (title, zip_path, image_count, ok_ch, fail_ch),失败抛异常""" import zipfile from PIL import Image from jmcomic import JmModuleConfig # A previous proxy attempt may have left partial chapters or a truncated ZIP. # The caller holds the per-album lock, so discard that attempt before reuse. _cleanup_album(album_id, Path(JM_DIR) / plan[4] if plan is not None else None) work_dir = Path(JM_DIR) / str(album_id) work_dir.mkdir(parents=True, exist_ok=True) if plan is None: plan = _resolve_album_plan(album_id, range_start, range_end, proxy) title, episodes, range_start, range_end, zip_name = plan # 逐章下载 ok_count = 0 fail_count = 0 for idx in range(range_start - 1, range_end): photo_id = episodes[idx][0] try: dl_option = JmModuleConfig.option_class().construct({ "dir_rule": {"rule": "Bd_Pid", "base_dir": str(work_dir)}, "download": {"image": {"suffix": ".webp"}}, "client": {"postman": {"meta_data": {"proxies": _jm_proxies(proxy)}}}, }) dler = JmModuleConfig.downloader_class()(dl_option) dler.download_photo(int(photo_id)) ok_count += 1 print(f"[jm] 章节下载完成: {album_id}/{photo_id}") except Exception as e: fail_count += 1 print(f"[jm] 章节下载失败: {album_id}/{photo_id}: {e}") # 收集图片 (按章节区间顺序逐章收集, 不依赖路径字典序: photo_id 位数跨越时 sorted() 会乱序; # 同时避免把上次失败残留的、超出本次范围的章节图片打进来) images = [] for idx in range(range_start - 1, range_end): ch_dir = work_dir / str(episodes[idx][0]) for ext in ("*.webp", "*.jpg", "*.png"): images.extend(sorted(ch_dir.glob(ext))) if not images: raise RuntimeError("未下载到任何图片") # 打包 ZIP (webp 统一转 jpg, 无密码) zip_path = Path(JM_DIR) / zip_name tmp_dir = Path(tempfile.mkdtemp(prefix="jmpack_", dir=JM_DIR)) try: packed = [] for i, p in enumerate(images, 1): out = tmp_dir / f"{i:04d}.jpg" try: img = Image.open(str(p)) if img.mode != "RGB": img = img.convert("RGB") img.save(str(out), "JPEG", quality=95) except Exception: out = tmp_dir / f"{i:04d}{p.suffix.lower()}" shutil.copyfile(str(p), str(out)) packed.append(out) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for j in packed: zf.write(j, j.name) finally: shutil.rmtree(tmp_dir, ignore_errors=True) print(f"[jm] 打包完成: {zip_name} ({len(images)} 张)") return title, zip_path, len(images), ok_count, fail_count def _download_from_openapi(album_id, plan, proxy=None): """从 openapi jm 服务下载已打包的 ZIP (hk 直连源站, 无需本地代理出墙)。 plan: _resolve_album_plan 的结果, 用于章节范围与本地文件名 (与 cf 去重一致)。 成功返回 (zip_path, image_count),失败抛异常; openapi 400 (参数类) 抛 _ParamError 不轮换代理。""" import requests title, episodes, range_start, range_end, zip_name = plan url = f"{OPENAPI_JM_URL}?id={album_id}&range={range_start}-{range_end}" kwargs = {"timeout": OPENAPI_TIMEOUT, "stream": True} if proxy: kwargs["proxies"] = _jm_proxies(proxy) r = requests.get(url, **kwargs) try: if r.status_code != 200: msg = f"HTTP {r.status_code}" try: j = r.json() msg = j.get("detail") or j.get("error") or msg except Exception: pass if r.status_code == 400: raise _ParamError(msg) raise RuntimeError(f"openapi jm {r.status_code}: {msg}") # 本地文件名沿用 plan 的 zip_name, 保证上传 cf 的名字与去重检查一致 zip_path = Path(JM_DIR) / zip_name with open(zip_path, "wb") as f: for chunk in r.iter_content(chunk_size=256 * 1024): if chunk: f.write(chunk) import zipfile try: with zipfile.ZipFile(zip_path) as z: img_count = len(z.namelist()) except zipfile.BadZipFile: try: os.remove(zip_path) except OSError: pass raise RuntimeError("openapi 返回的 ZIP 损坏") print(f"[jm] openapi 下载完成: {zip_name} ({img_count} 张)", flush=True) return zip_path, img_count finally: r.close() # ---------- 搜索 ---------- def _search_jm(keyword, page=1, limit=SEARCH_LIMIT, proxy=None): """站内搜索, 返回 ([(album_id, title), ...], total, page_count),失败抛异常""" from jmcomic import JmModuleConfig option = JmModuleConfig.option_class().default() try: option.client.postman.meta_data.proxies = _jm_proxies(proxy) except Exception: pass client = option.build_jm_client() sp = client.search_site(search_query=keyword, page=page) results = [] for aid, title in sp.iter_id_title(): results.append((aid, title)) if len(results) >= limit: break return results, int(sp.total), sp.page_count # ---------- 留存到本机 OSS ---------- def _retain_to_oss(zip_path): """把 ZIP 复制到本机 OSS 留存目录, 返回 (ok, msg) OSS 由 ossfs2 挂在 OSS_MOUNT 上; 未挂载时拒绝写入, 避免把大文件写到根分区。 """ src = Path(zip_path) if not os.path.ismount(OSS_MOUNT): return False, "OSS 未挂载: %s" % OSS_MOUNT try: os.makedirs(OSS_JM_DIR, exist_ok=True) dst = os.path.join(OSS_JM_DIR, src.name) shutil.copyfile(str(src), dst) # ossfs2 复制后 stat 可能短暂滞后, 最多重试 5 秒 expect = src.stat().st_size for _ in range(5): try: if os.path.getsize(dst) == expect: return True, "ok" except OSError: pass time.sleep(1) return False, "复制后大小校验不一致" except Exception as e: return False, str(e) # ---------- 指令入口 ---------- async def handle_jm(websocket, message_type, target_id, user_id, raw_text): from channel import send_message uid = str(user_id) body = re.sub(r"^\s*\.jm\b", "", raw_text, flags=re.IGNORECASE).strip() parts = body.split() # 无参数 / help if not parts or parts[0].lower() == "help": await send_message(websocket, message_type, target_id, HELP_TEXT) return # del-cache (仅管理员) if parts[0].lower() in ("del-cache", "delcache", "clearcache", "clear"): if not websocket.is_admin(user_id): await send_message(websocket, message_type, target_id, "该指令仅管理员可用") return await send_message(websocket, message_type, target_id, await asyncio.to_thread(_clear_local_cache)) return # 站内搜索 if parts[0].lower() in ("ss", "search"): args = parts[1:] page_num = 1 if args and args[-1].isdigit(): page_num = max(1, int(args[-1])) args = args[:-1] keyword = " ".join(args).strip() if not keyword: await send_message(websocket, message_type, target_id, "用法: .jm ss <关键词> [页码]") return if uid in _active_users: await send_message(websocket, message_type, target_id, "你有一个任务在处理中, 请稍候...") return _active_users.add(uid) try: await send_message(websocket, message_type, target_id, f"正在搜索「{keyword}」第{page_num}页...") last_err = None node = None for _ in range(len(PROXY_LIST)): node = _next_proxy() if node is None: break try: results, total, page_count = await asyncio.to_thread( _search_jm, keyword, page_num, SEARCH_LIMIT, node) break except Exception as e: print(f"[jm] 搜索失败 (代理 {_safe_text(node.get('name'))}): {e}") _mark_proxy_down(node) last_err = e node = None if node is None: await send_message(websocket, message_type, target_id, f"搜索失败: 所有代理均不可用 ({str(last_err)[:80]})") return if not results: if total > 0 and page_num > 1: await send_message(websocket, message_type, target_id, f"「{keyword}」第{page_num}页没有结果 (共{total}条/{page_count}页)") else: await send_message(websocket, message_type, target_id, f"未找到「{keyword}」相关结果") return lines = [f"「{keyword}」第{page_num}页/共{page_count}页 (总{total}条):"] for i, (aid, title) in enumerate(results, 1): lines.append(f"{i}. {title}\n JMID: {aid}") lines.append("━━━━━━━━━━━━") if page_num < page_count: lines.append(f"翻页: .jm ss {keyword} {page_num + 1}") lines.append("使用 .jm 下载") await send_message(websocket, message_type, target_id, "\n".join(lines)) finally: _active_users.discard(uid) return # 输出模式 (目前仅 zip) if parts[0].lower() == "zip": parts = parts[1:] elif parts[0].lower() in ("pic", "pdf", "all"): await send_message(websocket, message_type, target_id, "目前仅支持 zip 输出\n用法: .jm zip [起始-结束]") return if not parts or not parts[0].isdigit(): await send_message(websocket, message_type, target_id, "格式: .jm [起始-结束]\n示例: .jm 350234 或 .jm 350234 1-30") return album_id = parts[0] range_start = range_end = None if len(parts) >= 2: m = re.fullmatch(r"(\d{1,4})-(\d{1,4})", parts[1]) if not m: await send_message(websocket, message_type, target_id, "章节范围格式: 起始-结束 (如 1-30)") return range_start, range_end = int(m.group(1)), int(m.group(2)) if uid in _active_users: await send_message(websocket, message_type, target_id, "你有一个任务在处理中, 请稍候...") return _active_users.add(uid) try: await send_message(websocket, message_type, target_id, f"开始处理 JMID {album_id}, 完成后发送下载链接...") # 1) 解析元数据, 确定章节范围与 ZIP 文件名 (轮换代理) last_err = None node = None plan = None for _ in range(len(PROXY_LIST)): node = _next_proxy() if node is None: break try: plan = await asyncio.to_thread( _resolve_album_plan, album_id, range_start, range_end, node) break except _ParamError as e: await send_message(websocket, message_type, target_id, f"JMID {album_id} 参数错误: {e}") return except Exception as e: print(f"[jm] 获取信息失败 (代理 {_safe_text(node.get('name'))}): {e}") _mark_proxy_down(node) last_err = e node = None if node is None or plan is None: src_url = await asyncio.to_thread(_source_url, album_id) await send_message(websocket, message_type, target_id, f"JMID {album_id} 获取信息失败: 所有代理均不可用 ({str(last_err)[:80]})\n" f"可尝试: {OPENAPI_JM_URL}?id={album_id}\n" f"或自行访问原url: {src_url}") return # 同一 JMID 串行: 并发请求同一本时, 后到任务持锁后先复查 OSS 去重 (前者已保存则直接复用), # 避免同名 ZIP 并发互写 / local 模式共享工作目录混图 / 互相清理临时文件 async with _get_album_lock(album_id): # 2) 去重: 目标目录已有同名 ZIP 则跳过 zip_name = plan[4] if await asyncio.to_thread(_check_existing, zip_name): await send_message(websocket, message_type, target_id, f"文件已存在, 跳过下载: {zip_name}\n" f"下载地址: {SHARE_PAGE}") return # 3) 获取 ZIP (openapi 模式: openapi jm 服务已打包直接回 ZIP, 代理绕行加速; # local 模式: 本地 jmcomic + 代理下载打包。两者失败均轮换代理重试) ok = False zip_path = img_count = ok_c = fail_c = None title = plan[0] for _ in range(len(PROXY_LIST)): try: if JM_FETCH_MODE == "openapi": zip_path, img_count = await asyncio.to_thread( _download_from_openapi, album_id, plan, node) ok_c = plan[3] - plan[2] + 1 # 章节数 (openapi 单章失败不会部分返回) fail_c = 0 else: title, zip_path, img_count, ok_c, fail_c = await asyncio.to_thread( _download_and_pack, album_id, range_start, range_end, node, plan) ok = True break except _ParamError as e: # 参数类错误是确定性结果, 不触发代理轮换 await send_message(websocket, message_type, target_id, f"JMID {album_id} 参数错误: {e}") return except Exception as e: print(f"[jm] 下载失败 (模式 {JM_FETCH_MODE}, 节点 " f"{_safe_text(node.get('name')) if node else '直连'}): {e}") _mark_proxy_down(node) last_err = e node = _next_proxy() if node is None: break if not ok and JM_FETCH_MODE == "openapi": # 全部代理不可用: openapi 模式最后尝试直连兜底 (bj->hk 直连 ~8KB/s, 慢但可用) try: print(f"[jm] 代理全部失败, openapi 模式尝试直连兜底: {last_err}") zip_path, img_count = await asyncio.to_thread( _download_from_openapi, album_id, plan, None) ok_c = plan[3] - plan[2] + 1 fail_c = 0 ok = True except _ParamError as e: await send_message(websocket, message_type, target_id, f"JMID {album_id} 参数错误: {e}") return except Exception as e: last_err = e if not ok: src_url = await asyncio.to_thread(_source_url, album_id) # 对称清理: 失败也删除残留临时文件 (local 模式残留章节目录, openapi 模式半截 ZIP) await asyncio.to_thread(_cleanup_album, album_id, zip_path or Path(JM_DIR) / plan[4]) await send_message(websocket, message_type, target_id, f"JMID {album_id} 下载失败: 所有代理均不可用 ({str(last_err)[:80]})\n" f"可尝试: {OPENAPI_JM_URL}?id={album_id}\n" f"或自行访问原url: {src_url}") return try: up_ok, up_msg = await asyncio.to_thread(_retain_to_oss, zip_path) except Exception as e: up_ok, up_msg = False, str(e) is_tg = getattr(websocket, "name", "") == "tg" if not up_ok: # TG 分支不回复错误, 但必须留日志 (旧逻辑只在 QQ 分支打印, 上传失败会静默) print(f"[jm] 保存失败 {album_id}: {up_msg}") try: summary = (f"下载完成: {title}\n" f"文件: {zip_path.name} ({img_count} 张, 成功 {ok_c} 章 / 失败 {fail_c} 章)") if is_tg: # TG 侧直接发送压缩包文件(不发 share 链接); # 文件必须在 _cleanup_album 删除前发送,tg 通道失败时会提示分享页兜底 await send_message(websocket, message_type, target_id, [ {"type": "text", "data": {"text": summary}}, {"type": "file", "data": {"file": str(zip_path), "manual": f"{OPENAPI_JM_URL}?id={album_id}"}}, ]) elif not up_ok: src_url = await asyncio.to_thread(_source_url, album_id) await send_message(websocket, message_type, target_id, f"JMID {album_id} 保存到 OSS 失败\n" f"可尝试: {OPENAPI_JM_URL}?id={album_id}\n" f"或自行访问原url: {src_url}") else: await send_message(websocket, message_type, target_id, summary + f"\n下载地址: {SHARE_PAGE}") finally: # 无论上传/发送成败都删除本地临时文件 await asyncio.to_thread(_cleanup_album, album_id, zip_path) finally: _active_users.discard(uid)