From 8446166120092d55d6dea254adf8f01736316423 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:10:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor!:=20uv=20=E7=AE=A1=E7=90=86=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=20+=20=E5=BD=BB=E5=BA=95=E5=88=A0=E9=99=A4=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E5=80=BA=EF=BC=88Flask=20=E4=B8=8E=20requests=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=A7=BB=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## uv 环境管理 - pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定 - 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt - .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建) - 新增 jinja2(首页渲染) ## 删除 Flask 技术债 - 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动), 框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、 任务监控、缓存清扫),零 Flask 依赖 - 删除 main.py(4527 行)与 auth_routes.py(1050 行) - auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/ admin_required/_client_ip 等),保留数据与业务层 - 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流) - FastAPI 侧改直接依赖 services.runtime,不再 import main - web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选) ## 删除 requests 技术债 - 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池, 流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容 - core/utils、auth_system、podcast_api、music_downloader、services 全部切换 - 实测真实下载 5.2MB 音频完整成功 ## 质量门禁归零 - ruff:0 错误(B904 全部修复,不再忽略) - pyright:0 错误(类型收窄修复) - pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖) - 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确 新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照, FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。 --- .gitignore | 3 - .python-version | 1 + Dockerfile | 11 +- _mkpkg.py | 2 +- auth_routes.py | 1047 -------- auth_system.py | 252 +- core/async_bridge.py | 5 + core/net.py | 45 + core/sync_http.py | 202 ++ core/utils.py | 4 +- docker-compose.yml | 2 +- entrypoint.sh | 6 +- main.py | 4526 -------------------------------- music_api.py | 58 +- music_downloader.py | 13 +- podcast_api.py | 17 +- pyproject.toml | 44 +- requirements-dev.txt | 6 - requirements-fastapi.txt | 14 - requirements.txt | 13 - services/__init__.py | 6 + services/runtime.py | 1957 ++++++++++++++ tests/golden/responses.json | 540 ++++ tests/test_auth_routes.py | 20 +- tests/test_download_billing.py | 16 +- tests/test_golden.py | 109 + tests/test_register.py | 22 +- tests/test_root_mode.py | 2 +- tests/test_session.py | 58 +- uv.lock | 1574 +++++++++++ web/app.py | 30 +- web/auth_state.py | 172 ++ web/main.py | 12 +- web/ratelimit.py | 16 +- web/routers/auth.py | 8 +- web/routers/download.py | 28 +- web/routers/register.py | 6 +- web/routers/tasks.py | 8 +- web/tasks_registry.py | 6 +- 功能介绍.md | 2 +- 部署文档.md | 33 +- 41 files changed, 4836 insertions(+), 6060 deletions(-) create mode 100644 .python-version delete mode 100644 auth_routes.py create mode 100644 core/net.py create mode 100644 core/sync_http.py delete mode 100644 main.py delete mode 100644 requirements-dev.txt delete mode 100644 requirements-fastapi.txt delete mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/runtime.py create mode 100644 tests/golden/responses.json create mode 100644 tests/test_golden.py create mode 100644 uv.lock create mode 100644 web/auth_state.py diff --git a/.gitignore b/.gitignore index eaf5237..b4dce09 100644 --- a/.gitignore +++ b/.gitignore @@ -19,10 +19,7 @@ user_cookie.*.txt downloads/ *.log music_api.log* -_pkg_ok.txt -_css_ok.txt _audit_work/ .zcode/ .env -_smoke_test_downloader.py 云村小助手-网站开关.bat diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile index b9ddd0a..6942f66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,11 @@ WORKDIR /app # (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响) RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \ apk add --no-cache tzdata ffmpeg -# 目标栈:FastAPI + uvicorn(Flask 作为迁移期兜底仍随包) -COPY requirements-fastapi.txt requirements-fastapi.txt -COPY requirements.txt requirements.txt -RUN pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ - pip3 install --no-cache-dir -r requirements-fastapi.txt && \ - pip3 install --no-cache-dir -r requirements.txt +# 依赖由 uv 从 uv.lock 精确安装(可复现构建);uv 二进制仅存在于构建阶段 +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ +ENV UV_PROJECT_ENVIRONMENT=/usr/local UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev COPY . . RUN chmod +x /app/entrypoint.sh # 非 root 运行(容器安全基线)。注:挂载卷 ./downloads ./data 的宿主机目录 diff --git a/_mkpkg.py b/_mkpkg.py index fda8de5..aa31ba4 100644 --- a/_mkpkg.py +++ b/_mkpkg.py @@ -50,7 +50,7 @@ if public: '待修问题清单_第四轮深挖.md', # 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除 '待修问题清单_第五轮深挖.md', - '_pkg_ok.txt', '_css_ok.txt'): + '_pkg_ok.txt',): p = os.path.join(DST, rel) try: if os.path.isdir(p): diff --git a/auth_routes.py b/auth_routes.py deleted file mode 100644 index 31153a2..0000000 --- a/auth_routes.py +++ /dev/null @@ -1,1047 +0,0 @@ -# -*- coding: utf-8 -*- -"""站点认证与管理路由(/auth/* 与 /admin/site/*) - -由 main.py 调用 register_auth_routes(app) 挂载。 -""" - -import hmac -import secrets -import sqlite3 -import time - -from flask import request, send_file - -import auth_system as auth - - -def _data() -> dict: - try: - if request.method == 'GET': - return dict(request.args) - json_data = request.get_json(silent=True) or {} - return {**dict(request.form), **json_data} - except Exception: - return {} - - -# 登录失败锁定(P3-7):按用户名维度记录失败,10 分钟窗口内 >=5 次临时锁定该用户名。 -# 与既有按 IP 10/分限流互补——IP 池轮换爆破同一弱口令账号时仍被挡住 -_LOGIN_FAILS: dict = {} -_LOGIN_FAIL_LIMIT = 5 -_LOGIN_FAIL_WINDOW = 600 -# R5-6:清理节流——超限后的全表扫描每 60 秒最多一次,不再每个请求都 O(n); -# 硬上限 20000 条(按插入序砍掉最早的一半),防止节流窗口内被随机用户名撑爆 -_LOGIN_FAIL_SWEEP_TS = 0.0 -# R5-4:本锁定周期已落过审计日志的用户名(解锁或成功登录即重置,下个周期允许再记一条) -_LOGIN_FAIL_LOGGED: set = set() - -# 第 86 条:管理员两步验证(2FA)——票据表(内存态,单实例部署与全站任务表同口径)。 -# ticket -> {user_id, username, email, ts, attempts};密码验证通过才签发, -# Step2 邮箱比对与 Step3 验证码共用 attempts(上限内错满即作废),Step3 成功即销毁。 -# 第 87 条:每管理员仅 1 张活票(新票签发即销毁同账号旧票);_ADMIN2FA_LAST_SEND -# 为每账号 admin2fa 发码软冷却(60s,防持密码者借多票持续轰炸绑定邮箱) -_ADMIN_2FA: dict = {} -_ADMIN2FA_LAST_SEND: dict = {} -_ADMIN_2FA_TTL = 300 # 票据 5 分钟有效 -_ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限 -_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧) -_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环 - - -def _admin2fa_purge() -> None: - now = time.time() - for t in [t for t, v in _ADMIN_2FA.items() if now - v['ts'] > _ADMIN_2FA_TTL]: - _ADMIN_2FA.pop(t, None) - - -def _admin2fa_mask(email: str) -> str: - """邮箱掩码(审计日志与前端提示用):3*****@qq.com 样式""" - try: - local, dom = str(email).split('@', 1) - return (local[0] if local else '*') + '*****@' + dom - except Exception: - return '*****' - - -def _admin2fa_issue(user) -> str: - _admin2fa_purge() - # 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票 - for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]: - _ADMIN_2FA.pop(t, None) - if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS: - items = list(_ADMIN_2FA.items()) # 快照后再 min,防并发 pop 使迭代失效 - if items: - oldest = min(items, key=lambda kv: kv[1]['ts'])[0] - _ADMIN_2FA.pop(oldest, None) - ticket = secrets.token_urlsafe(16) - _ADMIN_2FA[ticket] = {'user_id': user['id'], 'username': user['username'], - 'email': user['email'], 'ts': int(time.time()), 'attempts': 0} - return ticket - - -def _admin2fa_take(ticket: str): - """取活票(不销毁):无票/过期返回 (None, None);命中返回 (ticket, 数据)。""" - _admin2fa_purge() - v = _ADMIN_2FA.get(ticket or '') - if not v: - return None, None - return ticket, v - - -def _login_fail_check(username: str) -> bool: - """返回 True 表示允许尝试登录;锁定窗口内返回 False""" - global _LOGIN_FAIL_SWEEP_TS - now = time.time() - fails = [t for t in _LOGIN_FAILS.get(username, []) if now - t < _LOGIN_FAIL_WINDOW] - _LOGIN_FAILS[username] = fails - if len(fails) < _LOGIN_FAIL_LIMIT: - _LOGIN_FAIL_LOGGED.discard(username) - if len(_LOGIN_FAILS) > 10000 and now - _LOGIN_FAIL_SWEEP_TS > 60: # 容量上限(用户名为键,防无界增长) - _LOGIN_FAIL_SWEEP_TS = now - stale_users = [u for u, ts in _LOGIN_FAILS.items() - if not ts or now - max(ts) > _LOGIN_FAIL_WINDOW] - for u in stale_users[:5000]: - _LOGIN_FAILS.pop(u, None) - if len(_LOGIN_FAILS) > 20000: - for u in list(_LOGIN_FAILS.keys())[:len(_LOGIN_FAILS) // 2]: - _LOGIN_FAILS.pop(u, None) - return len(fails) < _LOGIN_FAIL_LIMIT - - -def _login_fail_record(username: str) -> None: - _LOGIN_FAILS.setdefault(username, []).append(time.time()) - - -def _login_fail_clear(username: str) -> None: - _LOGIN_FAILS.pop(username, None) - _LOGIN_FAIL_LOGGED.discard(username) - - -def _login_fail_locked_log_once(username: str) -> bool: - """R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表; - 解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。""" - if username in _LOGIN_FAIL_LOGGED: - return False - _LOGIN_FAIL_LOGGED.add(username) - return True - - -def _actor() -> str: - """审计日志/卡密 created_by 的操作者名——跟随站点管理员的真实用户名 - (2026-09-22 管理员账号由 admin 更名 qfbsh 后,日志不得再硬编码旧名; - 无会话兜底用 system——admin 账号已不存在,不能再出现在新日志里)""" - cu = auth.current_user() - return cu['username'] if cu else 'system' - - -def _safe_register_credits() -> int: - """注册赠送次数(settings.register_credits,默认 3)。 - R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。""" - try: - credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0) - except (TypeError, ValueError): - return auth.DEFAULT_REGISTER_CREDITS - return max(0, credits) - - -# 验证码按 IP 限流(每 IP 每小时 10 次),防止公网环境换邮箱刷爆 SMTP -_SEND_CODE_IP = {} -_SEND_CODE_IP_LIMIT = 10 -_SEND_CODE_IP_WINDOW = 3600 - - -def _send_code_ip_check(ip: str) -> bool: - """返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)""" - now = time.time() - if len(_SEND_CODE_IP) > 10000: - stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW] - for k in stale: - _SEND_CODE_IP.pop(k, None) - cnt, win = _SEND_CODE_IP.get(ip, (0, now)) - if now - win > _SEND_CODE_IP_WINDOW: - cnt, win = 0, now - if cnt >= _SEND_CODE_IP_LIMIT: - return False - _SEND_CODE_IP[ip] = (cnt + 1, win) - return True - - -def _ok(data=None, message: str = 'success'): - resp = {'status': 200, 'success': True, 'message': message} - if data is not None: - resp['data'] = data - return resp, 200 - - -def _err(message: str, code: int = 400, error_code: str = ''): - resp = {'status': code, 'success': False, 'message': message} - if error_code: - resp['error_code'] = error_code - return resp, code - - -def register_auth_routes(app) -> None: - # ==================== 用户侧 ==================== - - @app.route('/auth/me', methods=['GET', 'POST']) - def auth_me(): - user = auth.current_user() - ann = auth.get_announcement() - data = { - 'user': auth._user_public(user) if user else None, - 'duration_text': auth.format_credits(user['duration_seconds']) if user else '', - 'announcement': {'title': ann['title'], 'content': ann['content'], - 'popup_mode': ann['popup_mode'], 'updated_at': ann['updated_at']}, - # 未配置 SMTP 时前端隐藏注册入口(owner 手动建号模式,不开放邮箱注册) - 'smtp_configured': auth.smtp_configured(), - # F-6.1(第 79 条):注册赠送次数动态下发——设置改值后前端文案同步,不再硬编码"3 次" - 'register_credits': _safe_register_credits(), - } - return _ok(data) - - @app.route('/auth/send_code', methods=['POST']) - def auth_send_code(): - try: - if not _send_code_ip_check(auth._client_ip()): - return _err('验证码请求过于频繁,请稍后再试') - data = _data() - email = str(data.get('email') or '').strip() - # 第 85 条:验证码按用途区分(注册/找回),邮件文案与库行用途隔离 - purpose = str(data.get('purpose') or 'register').strip() - if purpose not in ('register', 'reset'): - return _err('验证码用途不正确') - auth.send_verify_code(email, purpose) - return _ok(message='验证码已发送,请查收邮箱') - except ValueError as e: - return _err(str(e)) - except RuntimeError as e: - return _err(str(e)) - except Exception: - return _err('验证码发送失败,请稍后重试', 500) - - @app.route('/auth/register', methods=['POST']) - def auth_register(): - try: - data = _data() - username = str(data.get('username') or '').strip() - password = str(data.get('password') or '') - email = auth._normalize_email(str(data.get('email') or '')) - code = str(data.get('code') or '').strip() - device_id = str(data.get('device_id') or '').strip()[:64] - # 第 81 条:注册前必须已同意《用户协议》与《隐私政策》——前端入口闸的 - # 服务端兜底(同意状态本身存于用户浏览器,此处为语义性强制) - if str(data.get('agree') or '').strip() != '1': - return _err('请先阅读并同意《用户协议》与《隐私政策》') - - if not (3 <= len(username) <= 20 and re_username_ok(username)): - return _err('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线') - if len(password) < 8 or len(password) > 128: - return _err('密码长度需在 8-128 位之间') - if len(email) > 254: - return _err('邮箱格式不正确') - # R5-P2:查重统一后移到验证码校验通过之后——验证码是“证明你能收这个 - # 邮箱的信”的凭证,凭证在手才有资格探测注册状态(堵未认证枚举); - # 并发双提交仍由 UNIQUE 索引兜底 - code_ttl = auth.check_verify_code(email, code, 'register') - if not code_ttl: - return _err('邮箱验证码错误或已过期') - if auth.get_user_by_name(username): - auth.restore_verify_code(email, code, code_ttl, 'register') - return _err('用户名已被注册') - # P3-19:邮箱查重(配合库侧唯一索引——索引防并发,这里给出友好提示) - if email and auth.get_user_by_email(email): - auth.restore_verify_code(email, code, code_ttl, 'register') - return _err('该邮箱已被注册,请更换邮箱或联系管理员') - - # P1-1(第 79 条):防刷号双闸——① 同设备只送一次注册赠送;② 同 IP 24h - # 最多 3 次注册。device_id 是客户端随机串可伪造,故两层都要,叠加 - # send_code 10 封/时/IP 与邮箱归一化查重后,刷号产能压到可忽略。 - device_used_flag = auth.device_used(device_id) if device_id else False - reg_ip = auth._client_ip() - if reg_ip and auth.register_ip_exceeded(reg_ip): - auth.restore_verify_code(email, code, code_ttl, 'register') - auth.add_log('fail', username, reg_ip, '注册', '同 IP 24h 注册次数超限') - return _err('当前网络注册过于频繁,请 24 小时后再试或联系管理员') - - # P3-21 + R5 防呆:赠送次数读取与钳制统一走 _safe_register_credits - credits = _safe_register_credits() - if credits < 0: - credits = 0 # R5 防呆:赠送次数不允许配成 -1(会造出不限次新号) - if device_used_flag: - # 同设备重复注册:账号仍可创建(家庭共用电脑场景),但不再重复赠送 - credits = 0 - try: - user = auth.create_user(username, password, email=email, credits=credits, - device_id=device_id, - ip=reg_ip, - ua=str(request.headers.get('User-Agent') or ''), - register_ip_limit=3, - register_device_once=True) - except sqlite3.IntegrityError: - # 并发双击提交时的兜底(前面已有重名预检);还原验证码供重试 - auth.restore_verify_code(email, code, code_ttl, 'register') - return _err('用户名或邮箱已被注册,请换一个再试') - except ValueError as e: - # 锁内 IP 闸复查(register_ip_limit)等业务性拒绝:友好 400 而非 500 - auth.restore_verify_code(email, code, code_ttl, 'register') - auth.add_log('fail', username, reg_ip, '注册', str(e)) - return _err(str(e)) - except Exception: - # 建号失败不吞验证码(F14 第二轮补证) - auth.restore_verify_code(email, code, code_ttl, 'register') - raise - auth.login_session(user) - fresh = auth.get_user_by_name(username) - # 第 100 条(对抗复审):设备闸在事务内置零时路由层 credits 不回读—— - # 按入库实际次数对齐审计日志与 toast 文案(正常路径 min 无操作) - credits = min(credits, max(0, int(fresh['duration_seconds'] or 0))) - # 第 81 条:注册成功落审计日志(后台日志页可见刷号行为) - auth.add_log('success', username, reg_ip, '注册账号', - f'赠送 {credits} 次' if credits else '未赠送次数') - return _ok({'user': auth._user_public(fresh), - 'duration_text': auth.format_credits(fresh['duration_seconds']), - 'redirect': 'admin' if fresh['role'] == 'admin' else 'user'}, - '注册成功' + (f',已赠送 {credits} 次下载次数' if credits else '')) - except Exception: - return _err('注册失败,请稍后重试', 500) - - @app.route('/auth/trial', methods=['POST']) - def auth_trial(): - """试用已并入注册:注册即送 N 次(随后台设置联动,第 98 条不再硬编码),无需领取""" - return _err(f'注册即送 {_safe_register_credits()} 次免费下载次数,请直接注册账号') - - @app.route('/auth/login', methods=['POST']) - def auth_login(): - try: - data = _data() - username = str(data.get('username') or '').strip() - password = str(data.get('password') or '') - if len(password) > 128: - # 超长密码不做 PBKDF2(CPU 放大器),直接按密码错误拒绝 - return _err('用户名或密码错误') - # 第 85 条:邮箱登录——含 @ 按归一化邮箱解析出真实账号再验密; - # 锁定/日志/状态检查统一用解析出的真实用户名(邮箱大小写与 +tag - # 变体归一后,锁定不被变体绕过,日志也不再出现邮箱串)。 - # 邮箱不存在与密码错误统一文案,不暴露账号存在性。 - if '@' in username: - iuser = auth.get_user_by_email(username) - lock_name = iuser['username'] if iuser else (auth._normalize_email(username) or username) - else: - iuser = None - lock_name = username - # P3-7:用户名维度失败锁定(10 分钟内 5 次失败);R5-4:日志整个锁定周期只记一条 - if not _login_fail_check(lock_name): - if _login_fail_locked_log_once(lock_name): - auth.add_log('fail', lock_name, auth._client_ip(), '登录', '失败次数过多,临时锁定') - return _err('尝试过于频繁,该账号已临时锁定,请 10 分钟后再试') - if iuser is not None: - user = auth.verify_login(iuser['username'], password) - else: - user = auth.verify_login(username, password) - if not user: - _login_fail_record(lock_name) - return _err('用户名或密码错误') - status = user['status'] if 'status' in user.keys() else 1 - if status == 2: - reason = user['ban_reason'] if 'ban_reason' in user.keys() else '' - auth.add_log('fail', lock_name, auth._client_ip(), '登录', f'封禁账号尝试登录: {reason}') - return _err('账号已被封禁' + (f':{reason}' if reason else '') + ',请联系管理员') - if status == 0: - return _err('账号已被禁用,请联系管理员') - _login_fail_clear(lock_name) # 登录成功清零失败计数 - if user['role'] == 'admin': - # 第 86 条:管理员不在此建立会话——一律转两步验证通道 - # (密码正确才签发票据;未绑定邮箱 fail-closed) - admin_email = user['email'] if 'email' in user.keys() else '' - if not admin_email: - auth.add_log('fail', lock_name, auth._client_ip(), '管理员登录', '未绑定邮箱,两步验证不可用') - return _err('管理员账号未绑定邮箱,请联系管理员先完成绑定') - ticket = _admin2fa_issue(user) - auth.add_log('success', lock_name, auth._client_ip(), '管理员登录', '密码验证通过,待邮箱验证') - return _ok({'ticket': ticket, 'email_masked': _admin2fa_mask(admin_email), - 'username': user['username'], 'admin_2fa': True}, - '密码验证通过,请完成邮箱验证') - auth.login_session(user) - # IP 归属地异常检测:24h 内 >=3 个不同省份自动封禁(防倒卖) - banned, _info = auth.record_login_location(user, auth._client_ip()) - if banned: - auth.logout_session() - return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员') - fresh = auth.get_user_by_id(user['id']) - return _ok({ - 'user': auth._user_public(fresh), - 'duration_text': auth.format_credits(fresh['duration_seconds']), - 'redirect': 'admin' if fresh['role'] == 'admin' else 'user', - }, '登录成功') - except Exception: - return _err('登录失败,请稍后重试', 500) - - @app.route('/auth/admin_login/email', methods=['POST']) - def auth_admin_login_email(): - """管理员两步验证 Step2:比对绑定邮箱 → 发送 admin2fa 验证码(第 86 条)""" - try: - data = _data() - ticket, v = _admin2fa_take(str(data.get('ticket') or '')) - if not v: - return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired') - email_input = auth._normalize_email(str(data.get('email') or '')) - # 第 97 条(P3):第二因子的比对走常时比较,与本文件密钥/令牌口径一致 - if (not email_input or not v.get('email') - or not hmac.compare_digest(email_input, v['email'])): - v['attempts'] += 1 # 第 87 条:失败路径自增(原实现计数器从未生效) - auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '绑定邮箱不匹配') - if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS: - _ADMIN_2FA.pop(ticket, None) - return _err('错误次数过多,请重新开始登录') - return _err(f'绑定邮箱不正确,还可尝试 {_ADMIN_2FA_MAX_ATTEMPTS - v["attempts"]} 次') - # 第 87 条:同账号 60s 发码软冷却——票据一次性+单活票后仍防持续轰炸绑定邮箱 - last_send = _ADMIN2FA_LAST_SEND.get(v['username'], 0) - if time.time() - last_send < _ADMIN2FA_SEND_GAP: - return _err('验证码发送过于频繁,请 1 分钟后再试') - auth.send_verify_code(v['email'], 'admin2fa') - _ADMIN2FA_LAST_SEND[v['username']] = time.time() - auth.add_log('success', v['username'], auth._client_ip(), '管理员登录', - f'绑定邮箱验证通过,验证码已发送({_admin2fa_mask(v["email"])})') - return _ok(message='验证码已发送至管理员邮箱,请查收') - except ValueError as e: - return _err(str(e)) - except RuntimeError as e: - return _err(str(e)) - except Exception: - return _err('验证码发送失败,请稍后重试', 500) - - @app.route('/auth/admin_login/verify', methods=['POST']) - def auth_admin_login_verify(): - """管理员两步验证 Step3:验证码原子消费 → 建立管理员会话(第 86 条)""" - try: - data = _data() - ticket, v = _admin2fa_take(str(data.get('ticket') or '')) - if not v: - return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired') - code = str(data.get('code') or '').strip() - if not auth.check_verify_code(v['email'], code, 'admin2fa'): - v['attempts'] += 1 # 第 87 条:失败路径自增(原实现计数器从未生效) - if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS: - _ADMIN_2FA.pop(ticket, None) - return _err('错误次数过多,请重新开始登录') - auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '验证码错误') - return _err('验证码错误或已过期') - # Step3 成功即销毁票据(一次性);建会话前以库内最新状态复核 - _ADMIN_2FA.pop(ticket, None) - user = auth.get_user_by_id(v['user_id']) - if not user or user['role'] != 'admin': - return _err('登录会话已过期,请重新开始登录', 401, 'admin_2fa_expired') - status = user['status'] if 'status' in user.keys() else 1 - if status == 2: - auth.add_log('fail', v['username'], auth._client_ip(), '管理员登录', '封禁账号尝试登录') - return _err('账号已被封禁,请联系管理员') - if status == 0: - return _err('账号已被禁用,请联系管理员') - auth.login_session(user) - # IP 归属地异常检测:管理员天然豁免省份封禁(record_login_location 内部判定) - banned, _info = auth.record_login_location(user, auth._client_ip()) - if banned: - auth.logout_session() - return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员') - fresh = auth.get_user_by_id(user['id']) - auth.add_log('success', v['username'], auth._client_ip(), '管理员登录', '两步验证通过') - return _ok({ - 'user': auth._user_public(fresh), - 'duration_text': auth.format_credits(fresh['duration_seconds']), - 'redirect': 'admin', - }, '登录成功') - except Exception: - return _err('登录失败,请稍后重试', 500) - - @app.route('/auth/logout', methods=['POST']) - def auth_logout(): - auth.logout_session() - return _ok(message='已退出登录') - - @app.route('/auth/heartbeat', methods=['POST']) - def auth_heartbeat(): - user = auth.current_user() - if not user: - return _err('登录已失效', 401, 'unauthorized') - fresh = auth.get_user_by_id(user['id']) - credits = int(fresh['duration_seconds'] or 0) - if fresh['role'] != 'admin' and credits != -1 and credits <= 0: - return _err('剩余次数已用完', 401, 'expired') - return _ok({'duration_seconds': fresh['duration_seconds'], - 'duration_text': auth.format_credits(fresh['duration_seconds'])}) - - @app.route('/auth/redeem', methods=['POST']) - def auth_redeem(): - user = auth.current_user() - if not user: - return _err('请先登录', 401, 'unauthorized') - try: - code = str(_data().get('code') or '') - credits, remaining = auth.redeem_card(code, user['username']) - auth.add_log('success', user['username'], auth._client_ip(), '卡密充值', - f'{code[:4]}**** · +{credits} 次') - return _ok({'credits': credits, 'duration_seconds': remaining, - 'duration_text': auth.format_credits(remaining)}, - f'充值成功,+{credits} 次') - except ValueError as e: - auth.add_log('fail', user['username'], auth._client_ip(), '卡密充值', str(e)) - return _err(str(e)) - except Exception: - return _err('充值失败,请稍后重试', 500) - - @app.route('/auth/ann_seen', methods=['POST']) - def auth_ann_seen(): - user = auth.current_user() - if user: - ann = auth.get_announcement() - auth._x('UPDATE users SET ann_seen = ? WHERE id = ?', (ann['updated_at'], user['id'])) - return _ok() - - # -------------------- 自助找回密码(第 79 条:复用验证码通道) -------------------- - - @app.route('/auth/forgot_password', methods=['POST']) - def auth_forgot_password(): - """自助找回密码:注册邮箱收验证码 → 验码原子消费 → 重置密码并踢下全部会话。 - 安全口径:响应统一为「验证码错误或已过期」(不区分账号是否存在,防枚举)。""" - try: - data = _data() - # 第 84 条修复:此处原为裸调用 `_normalize_email(...)`——该名称从未定义/ - # 导入,NameError 被下方 except 吞成 500,导致本接口自第 79 条诞生起 - # 任何输入都返回"重置失败"。改用 auth_system 的实现(邮箱归一化)。 - email = auth._normalize_email(str(data.get('email') or '')) - code = str(data.get('code') or '').strip() - newpwd = str(data.get('password') or '') - if len(newpwd) < 8 or len(newpwd) > 128: - return _err('新密码长度需在 8-128 位之间') - if not email: - return _err('缺少邮箱') - if not auth.check_verify_code(email, code, 'reset'): - return _err('邮箱验证码错误或已过期') - user = auth.get_user_by_email(email) - if not user: - # 验证码已对空邮箱消费掉——统一文案,不暴露账号存在性 - return _err('邮箱验证码错误或已过期') - # 第 86 条:管理员不走自助找回——放行则邮箱持有者可自助改密, - # 改完的密码+邮箱验证码=两因子全握,2FA 形同虚设。统一防枚举文案 - if user['role'] == 'admin': - return _err('邮箱验证码错误或已过期') - auth.reset_password(user['id'], newpwd) - auth.add_log('success', user['username'], auth._client_ip(), '自助找回密码', '邮箱验证码重置') - return _ok(message='密码已重置,请使用新密码登录(所有设备已下线)') - except Exception: - return _err('重置失败,请稍后重试', 500) - - # ==================== 管理后台 ==================== - - @app.route('/admin/site/users', methods=['POST']) - @auth.admin_required - def admin_site_users(): - return _ok({'users': auth.list_users()}) - - @app.route('/admin/site/user/delete', methods=['POST']) - @auth.admin_required - def admin_site_user_delete(): - try: - uid = int(_data().get('id') or 0) - target = auth.get_user_by_id(uid) - if not auth.delete_user(uid): - return _err('用户不存在或为管理员') - auth.add_log('success', _actor(), auth._client_ip(), '删除用户', target['username'] if target else str(uid)) - return _ok(message='用户已删除') - except Exception as e: - return _err(f'删除失败: {e!s}', 500) - - @app.route('/admin/site/user/duration', methods=['POST']) - @auth.admin_required - def admin_site_user_duration(): - try: - data = _data() - uid = int(data.get('id') or 0) - try: - count = int(float(data.get('count') or data.get('minutes') or 0)) - except (TypeError, ValueError, OverflowError): - return _err('次数必须为数字') - if count < -1: - return _err('次数不能小于 -1(-1 表示不限次数)') - target = auth.get_user_by_id(uid) - if not target: - return _err('用户不存在') - if target['role'] == 'admin': - return _err('管理员不限次数') - auth.set_user_credits(uid, count) - auth.add_log('success', _actor(), auth._client_ip(), '修改用户次数', - f'{target["username"]} → {count} 次') - return _ok(message=f'已修改为 {count} 次') - except Exception as e: - return _err(f'修改失败: {e!s}', 500) - - def _block_admin_target(target) -> tuple | None: - """禁止对管理员账号执行危险操作(防自锁)""" - if target and target['role'] == 'admin': - return _err('不能对管理员账号执行该操作') - return None - - @app.route('/admin/site/user/create', methods=['POST']) - @auth.admin_required - def admin_site_user_create(): - """管理员手动添加用户""" - try: - data = _data() - username = str(data.get('username') or '').strip() - password = str(data.get('password') or '') - email = auth._normalize_email(str(data.get('email') or '')) - try: - credits = int(float(data.get('count') or 0)) - except (TypeError, ValueError, OverflowError): - # OverflowError:count='inf' → int(inf) 溢出(原实现漏捕 → 500) - return _err('次数必须为数字') - import math as _math - if not _math.isfinite(credits): - return _err('次数必须为有限数字') - if credits < -1: - return _err('次数不能小于 -1(-1 表示不限次数)') - if not re_username_ok(username): - # 第 100 条:管理端为受信方——保留名给明确提示(注册端保持防枚举模糊文案) - if username.lower() in _RESERVED_USERNAMES: - return _err('该用户名为系统保留名,不可使用') - return _err('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线') - if len(password) < 8 or len(password) > 128: - return _err('密码长度需在 8-128 位之间') - if auth.get_user_by_name(username): - return _err('用户名已存在') - # R5-13:与注册路径同口径的邮箱查重——否则重复邮箱触发 UNIQUE 约束 - # 走兜底 except,给管理员看原始 500 报错 - if email and auth.get_user_by_email(email): - return _err('该邮箱已被注册,请更换邮箱') - auth.create_user(username, password, email=email, credits=credits, - ip=auth._client_ip(), ua='admin创建') - auth.add_log('success', _actor(), auth._client_ip(), '添加用户', - f'{username} · {credits} 次') - return _ok(message=f'用户 {username} 创建成功({credits} 次)') - except Exception as e: - return _err(f'创建失败: {e!s}', 500) - - @app.route('/admin/site/user/reset_password', methods=['POST']) - @auth.admin_required - def admin_site_user_reset_password(): - try: - data = _data() - uid = int(data.get('id') or 0) - newpwd = str(data.get('password') or '') - if len(newpwd) < 8 or len(newpwd) > 128: - # 第 87 条:与注册/找回/建号统一 8 位下限(原 6 位为最后一处口径不一) - return _err('新密码长度需在 8-128 位之间') - target = auth.get_user_by_id(uid) - if not target: - return _err('用户不存在') - current = auth.current_user() - if target['role'] == 'admin': - # F02:允许管理员修改自己的密码(需验证旧密码),否则文档要求的改密入口形同虚设 - if not current or current['id'] != target['id']: - return _err('不能对其他管理员账号执行该操作') - oldpwd = str(data.get('old_password') or '') - ok, _ = auth._verify_password(target['password_hash'], oldpwd, target['salt']) - if not ok: - return _err('原密码错误') - auth.reset_password(uid, newpwd) - auth.add_log('success', current['username'], auth._client_ip(), '修改自己的密码', '管理员') - return _ok(message='密码已修改,下次登录请使用新密码(其他设备已全部下线)') - auth.reset_password(uid, newpwd) - auth.add_log('success', _actor(), auth._client_ip(), '重置密码', target['username']) - return _ok(message=f'已重置 {target["username"]} 的密码') - except Exception as e: - return _err(f'重置失败: {e!s}', 500) - - @app.route('/admin/site/user/toggle', methods=['POST']) - @auth.admin_required - def admin_site_user_toggle(): - """启用 / 禁用账号""" - try: - uid = int(_data().get('id') or 0) - target = auth.get_user_by_id(uid) - if not target: - return _err('用户不存在') - blocked = _block_admin_target(target) - if blocked: - return blocked - new_status = 0 if (target['status'] if 'status' in target.keys() else 1) else 1 - auth.set_user_status(uid, new_status) - auth.add_log('success', _actor(), auth._client_ip(), '禁用/启用', - f'{target["username"]} → {"启用" if new_status else "禁用"}') - return _ok({'status': new_status}, - message=f'已{"启用" if new_status else "禁用"} {target["username"]}') - except Exception as e: - return _err(f'操作失败: {e!s}', 500) - - @app.route('/admin/site/user/ban', methods=['POST']) - @auth.admin_required - def admin_user_ban(): - """封禁/解封用户(防倒卖:封禁后立即踢下线)""" - try: - data = _data() - uid = int(data.get('id') or 0) - action = str(data.get('action') or 'ban') - reason = str(data.get('reason') or '') - row = auth.get_user_by_id(uid) - if not row: - return _err('用户不存在') - if row['role'] == 'admin': - return _err('不能对管理员执行该操作') - admin_name = _actor() - if action == 'ban': - if not auth.ban_user(uid, reason or '管理员手动封禁'): - return _err('封禁失败') - auth.add_log('success', admin_name, - auth._client_ip(), '封禁用户', f'{row["username"]}: {reason or "手动封禁"}') - return _ok(message=f'已封禁用户「{row["username"]}」,该账号已强制下线') - else: - if not auth.unban_user(uid): - return _err('解封失败') - auth.add_log('success', admin_name, - auth._client_ip(), '解封用户', row['username']) - return _ok(message=f'已解封用户「{row["username"]}」') - except Exception as e: - return _err(f'操作失败: {e!s}', 500) - - @app.route('/admin/site/user/cookie', methods=['POST']) - @auth.admin_required - def admin_site_user_cookie(): - username = str(_data().get('username') or '') - user = auth.get_user_by_name(username) - if not user: - return _err('用户不存在', 404) - return _ok({'username': username, 'cookie': user['netease_cookie'] or ''}) - - @app.route('/admin/site/smtp/get', methods=['POST']) - @auth.admin_required - def admin_site_smtp_get(): - cfg = auth.get_smtp_config() - return _ok({'host': cfg['host'], 'port': cfg['port'], - 'sender': cfg['sender'], 'configured': auth.smtp_configured()}) - - @app.route('/admin/site/smtp/save', methods=['POST']) - @auth.admin_required - def admin_site_smtp_save(): - try: - data = _data() - host = str(data.get('host') or '').strip() - try: - port = int(data.get('port') or 587) - except (TypeError, ValueError): - return _err('端口必须为数字') - sender = str(data.get('sender') or '').strip() - password = str(data.get('password') or '') - if not host or not sender: - return _err('SMTP 服务器与发件邮箱不能为空') - if not password: - # 密码留空表示保持原有授权码不变 - password = auth.get_smtp_config().get('password', '') - auth.save_smtp_config(host, port, sender, password) - auth.add_log('success', _actor(), auth._client_ip(), '保存SMTP配置', f'{host}:{port}') - return _ok(message='SMTP 配置已保存') - except Exception as e: - return _err(f'保存失败: {e!s}', 500) - - @app.route('/admin/site/smtp/test', methods=['POST']) - @auth.admin_required - def admin_site_smtp_test(): - try: - to = str(_data().get('to') or '').strip() - if not to: - return _err('请填写测试收件邮箱') - # 收件人地址校验(防 CRLF 头注入;与验证码发送同规则) - import re as _re - if not _re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to): - return _err('收件邮箱格式不正确') - auth.send_email(to, 'SMTP 测试邮件', - '这是一封来自云村小助手的测试邮件,收到即说明 SMTP 配置成功。') - auth.add_log('success', _actor(), auth._client_ip(), 'SMTP测试', to) - return _ok(message='测试邮件已发送,请查收') - except Exception as e: - auth.add_log('fail', _actor(), auth._client_ip(), 'SMTP测试', str(e)) - return _err(f'发送失败: {e!s}', 500) - - @app.route('/admin/site/logs', methods=['POST']) - @auth.admin_required - def admin_site_logs(): - """日志分页查询(10 条/页)""" - data = _data() - log_type = 'fail' if str(data.get('type')) == 'fail' else 'success' - try: - page = max(1, int(data.get('page') or 1)) - except (TypeError, ValueError): - page = 1 - rows, total = auth.query_logs(log_type, limit=10, offset=(page - 1) * 10) - return _ok({'logs': rows, 'type': log_type, 'page': page, - 'total': total, 'pages': max(1, (total + 9) // 10)}) - - @app.route('/admin/site/logs/clear', methods=['POST']) - @auth.admin_required - def admin_site_logs_clear(): - """清除指定类型的全部日志""" - log_type = 'fail' if str(_data().get('type')) == 'fail' else 'success' - deleted = auth.clear_logs(log_type) - auth.add_log('success', _actor(), - auth._client_ip(), '清除日志', f'{log_type} · {deleted} 条') - return _ok(message=f'已清除 {"失败" if log_type == "fail" else "成功"}日志 {deleted} 条') - - @app.route('/admin/site/cards/generate', methods=['POST']) - @auth.admin_required - def admin_site_cards_generate(): - try: - data = _data() - try: - count = int(data.get('count') or 1) - valid_days = int(data.get('valid_days') or 0) - except (TypeError, ValueError): - return _err('张数 / 次数 / 有效天数必须为数字') - # 第 81 条:显式传 0(JSON 体)不再被 or 链静默替换成默认 5 - raw_credits = data.get('credits', data.get('minutes')) - try: - credits = int(raw_credits) if raw_credits is not None else 5 - except (TypeError, ValueError): - return _err('张数 / 次数 / 有效天数必须为数字') - if credits <= 0: - return _err('卡密次数必须大于 0') - prefix = str(data.get('prefix') or 'VIP') - note = str(data.get('note') or '') - codes = auth.generate_cards(count, credits, created_by=_actor(), - prefix=prefix, note=note, valid_days=valid_days) - auth.add_log('success', _actor(), auth._client_ip(), '生成卡密', - f'{count} 张 · {credits} 次/张 · 有效{valid_days or "永久"}天') - return _ok({'codes': codes, 'credits': credits, 'count': len(codes)}, - f'已生成 {len(codes)} 张卡密') - except Exception as e: - return _err(f'生成失败: {e!s}', 500) - - @app.route('/admin/site/cards/list', methods=['POST']) - @auth.admin_required - def admin_site_cards_list(): - # F85:支持 limit/offset 分页与总数返回,较早卡密可翻页查看 - data = _data() - try: - limit = int(data.get('limit') or 100) - except (TypeError, ValueError): - limit = 100 - try: - offset = int(data.get('offset') or 0) - except (TypeError, ValueError): - offset = 0 - cards, total = auth.list_cards(limit=limit, offset=offset) - return _ok({'cards': cards, 'total': total}) - - @app.route('/admin/site/cards/disable', methods=['POST']) - @auth.admin_required - def admin_site_cards_disable(): - # 第 81 条:禁用/恢复未使用卡密(可逆操作;已使用卡密无禁用意义) - try: - data = _data() - code = str(data.get('code') or '').strip() - if not code: - return _err('缺少卡密') - disable = str(data.get('disabled') or '').strip().lower() in ('1', 'true') # 第 87 条:兼容布尔/大小写变体 - auth.set_card_disabled(code, disable) - auth.add_log('success', _actor(), auth._client_ip(), - '禁用卡密' if disable else '恢复卡密', code[:4] + '****') - return _ok(message=('已禁用 ' if disable else '已恢复 ') + code[:4] + '****') - except ValueError as e: - return _err(str(e)) - except Exception: - return _err('操作失败,请稍后重试', 500) - - @app.route('/admin/site/cards/delete', methods=['POST']) - @auth.admin_required - def admin_site_cards_delete(): - # 第 81 条:删除未使用卡密(已使用卡密保留兑换凭证,按 owner 规则不可删) - try: - data = _data() - code = str(data.get('code') or '').strip() - if not code: - return _err('缺少卡密') - auth.delete_card(code) - auth.add_log('success', _actor(), auth._client_ip(), '删除卡密', code[:4] + '****') - return _ok(message='已删除 ' + code[:4] + '****') - except ValueError as e: - return _err(str(e)) - except Exception: - return _err('操作失败,请稍后重试', 500) - - @app.route('/admin/site/ann/get', methods=['POST']) - @auth.admin_required - def admin_site_ann_get(): - return _ok(auth.get_announcement()) - - @app.route('/admin/site/ann/save', methods=['POST']) - @auth.admin_required - def admin_site_ann_save(): - try: - data = _data() - auth.save_announcement(str(data.get('title') or ''), str(data.get('content') or ''), - str(data.get('popup_mode') or 'off')) - auth.add_log('success', _actor(), auth._client_ip(), '保存公告', str(data.get('title') or '')) - return _ok(message='公告已保存') - except Exception as e: - return _err(f'保存失败: {e!s}', 500) - - # -------------------- 播放 API 密钥 -------------------- - - @app.route('/admin/site/play_key/get', methods=['POST']) - @auth.admin_required - def admin_site_play_key_get(): - """获取当前播放 API 密钥(不存在则自动生成一次,保证弹窗拼 URL 总有可用密钥)""" - key = auth.get_setting('playlist_play_key', '') - if not key: - key = secrets.token_urlsafe(16) - auth.set_setting('playlist_play_key', key) - return _ok({'key': key}) - @app.route('/admin/site/play_key/rotate', methods=['POST']) - @auth.admin_required - def admin_site_play_key_rotate(): - """刷新播放 API 密钥:旧密钥立即失效(已签发的音频地址按 36 分钟自然过期(随交付保留期联动))""" - key = secrets.token_urlsafe(16) - auth.set_setting('playlist_play_key', key) - auth.add_log('success', _actor(), auth._client_ip(), '刷新播放API密钥', '旧密钥已失效') - return _ok({'key': key}, '密钥已刷新,旧密钥已全部失效') - - # -------------------- 数据库管理 -------------------- - - @app.route('/admin/site/backup/create', methods=['POST']) - @auth.admin_required - def admin_site_backup_create(): - try: - name = auth.create_backup() - auth.set_setting('last_backup_ts', str(time.time())) - auth.add_log('success', _actor(), auth._client_ip(), '数据库备份', name) - return _ok({'name': name}, f'备份完成:{name}') - except Exception as e: - return _err(f'备份失败: {e!s}', 500) - - @app.route('/admin/site/backup/list', methods=['POST']) - @auth.admin_required - def admin_site_backup_list(): - return _ok({'backups': auth.list_backups(), - 'interval_hours': auth.get_setting('backup_interval_hours', '0'), - 'db_size': auth.DB_PATH.stat().st_size if auth.DB_PATH.exists() else 0}) - - @app.route('/admin/site/backup/download', methods=['GET']) - @auth.admin_required - def admin_site_backup_download(): - name = request.args.get('name') or '' - p = auth.backup_path(name) - if not p: - return _err('备份文件不存在', 404) - resp = send_file(str(p), as_attachment=True, download_name=name) - # 第 97 条(P3):备份含完整用户库(邮箱+哈希),禁止共享缓存存储 - resp.headers['Cache-Control'] = 'no-store' - return resp - - @app.route('/admin/site/backup/upload', methods=['POST']) - @auth.admin_required - def admin_site_backup_upload(): - dest = None - try: - f = request.files.get('file') - if not f or not f.filename: - return _err('请选择数据库文件') - if not f.filename.endswith('.db'): - return _err('仅支持 .db 文件') - auth.BACKUP_DIR.mkdir(parents=True, exist_ok=True) - # S6:同秒上传自动追加序号,不再互相覆盖 - base = 'upload_' + time.strftime('%Y%m%d_%H%M%S') - name = base + '.db' - k = 1 - while (auth.BACKUP_DIR / name).exists(): - name = f'{base}-{k}.db' - k += 1 - dest = auth.BACKUP_DIR / name - f.save(str(dest)) - # 只读文件头 16 字节做魔数校验(原实现 read_bytes() 会把整个备份读进内存) - with dest.open('rb') as hf: - header = hf.read(16) - if not header.startswith(b'SQLite format 3'): - dest.unlink() - return _err('不是有效的 SQLite 数据库文件') - auth.add_log('success', _actor(), auth._client_ip(), '上传数据库', dest.name) - # 第 93 条(P2-3):上传备份同样纳入配额裁剪(20+5) - try: - auth.prune_backups(20, 5) - except Exception: - pass - return _ok({'name': dest.name}, '上传成功,可在下方选择该备份进行恢复') - except Exception as e: - # 第 99 条:上传中途失败(ENOSPC/IO)清理半截文件——残缺 .db 会进 - # list_backups 且因 mtime 最新挤掉一份好备份(恢复前校验虽能拒收) - try: - if dest and dest.exists(): - dest.unlink() - except Exception: - pass - return _err(f'上传失败: {e!s}', 500) - - @app.route('/admin/site/backup/restore', methods=['POST']) - @auth.admin_required - def admin_site_backup_restore(): - try: - name = str(_data().get('name') or '') - auth.restore_backup(name) - auth.add_log('success', _actor(), auth._client_ip(), '恢复数据库', name) - return _ok(message='数据库已恢复,请刷新页面') - except ValueError as e: - # 第 95 条(P3-1):客户端可纠正错误(备份不存在/校验失败)返回 400, - # 与 delete 的 404 口径对齐——原实现一律 500 - return _err(f'恢复失败: {e!s}', 400) - except Exception as e: - auth.add_log('fail', _actor(), auth._client_ip(), '恢复数据库', str(e)) - return _err(f'恢复失败: {e!s}', 500) - - @app.route('/admin/site/backup/delete', methods=['POST']) - @auth.admin_required - def admin_site_backup_delete(): - try: - name = str(_data().get('name') or '') - if auth.delete_backup(name): - return _ok(message='备份已删除') - return _err('备份文件不存在', 404) - except Exception as e: - return _err(f'删除失败: {e!s}', 500) - - @app.route('/admin/site/backup/auto', methods=['POST']) - @auth.admin_required - def admin_site_backup_auto(): - try: - import math as _math - try: - hours = float(_data().get('hours') or 0) - except (TypeError, ValueError): - return _err('间隔必须为数字') - # F46:NaN/Infinity 一律拒绝,不能持久化后让调度循环静默失效 - if not _math.isfinite(hours): - return _err('间隔必须为有限数字') - if hours < 0: - return _err('间隔不能为负') - auth.set_setting('backup_interval_hours', str(hours)) - return _ok(message=f'定时备份已开启,每 {hours} 小时自动备份' if hours > 0 - else '定时备份已关闭') - except Exception as e: - return _err(f'设置失败: {e!s}', 500) - - -# ============ 辅助 ============ - - -_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'} - - -def re_username_ok(username: str) -> bool: - import re as _re - # P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险) - # R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符 - #("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口 - return bool(_re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \ - not _re.match(r'^_+$', username) and not username.startswith('_') and not username.endswith('_') \ - and username.lower() not in _RESERVED_USERNAMES - - -def secrets_rand() -> int: - import secrets as _s - return _s.randbelow(900000) + 100000 diff --git a/auth_system.py b/auth_system.py index 50f6097..fbccde0 100644 --- a/auth_system.py +++ b/auth_system.py @@ -27,12 +27,9 @@ import time from email.header import Header from email.mime.text import MIMEText from email.utils import formataddr -from functools import wraps from pathlib import Path from typing import Any -from flask import g, request, session - # ============ 路径与常量 ============ DATA_DIR = Path('data') DB_PATH = DATA_DIR / 'site.db' @@ -465,18 +462,6 @@ def _is_private_ip(ip: str) -> bool: return False -def _is_loopback_ip(ip: str) -> bool: - """可信反代判定(第 91 条 P2-4:从"所有内网源"收窄为仅回环地址)。 - 服务绑 0.0.0.0 时内网任意主机都可直连伪造 XFF/X-Real-IP 轮换绕过限流与 - 登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → Flask)不受影响。""" - try: - import ipaddress - addr = ipaddress.ip_address(ip) - return addr.is_loopback - except ValueError: - return False - - def query_ip_province(ip: str) -> str: """查询 IP 归属省份(内网/失败返回空串),带进程内缓存 F47:改用官方路径 /json/{query} 与 zh-CN 语言参数""" @@ -489,7 +474,7 @@ def query_ip_province(ip: str) -> str: try: import urllib.parse as _ul - import requests as _rq + from core import sync_http as _rq r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip), params={'lang': 'zh-CN', 'fields': 'status,regionName'}, timeout=5) @@ -598,142 +583,6 @@ def _last_province(r: sqlite3.Row) -> str: return '' -# ============ 会话 ============ - - -def login_session(user: sqlite3.Row) -> None: - """登录会话:数据库侧原子递增 login_seq 实现顶号(旧会话自动失效)(F04)""" - session.permanent = True - with _DB_LOCK: - conn = get_conn() - try: - conn.execute('UPDATE users SET last_active = ?, last_login = ?, ' - 'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', - (int(time.time()), int(time.time()), user['id'])) - row = conn.execute('SELECT login_seq FROM users WHERE id = ?', (user['id'],)).fetchone() - conn.commit() - seq = int(row['login_seq']) if row else 1 - finally: - conn.close() - session[SESSION_KEY] = user['id'] - session[SESSION_KEY + '_seq'] = seq - # P3-23:记录本次签发时间,供滚动续期判断(活跃用户不再登录满 7 天被硬登出) - session[SESSION_KEY + '_ts'] = int(time.time()) - - -def _clear_session_only() -> None: - """仅清除本浏览器会话字段(不动数据库 login_seq)—— - 用于顶号/失效检测路径:清掉旧会话即可,绝不能递增 seq, - 否则会把"刚刚在别处合法登录"的新会话一并踢下线""" - session.pop(SESSION_KEY, None) - session.pop(SESSION_KEY + '_seq', None) - - -def logout_session() -> None: - """退出登录(F03:递增 login_seq,使本账号全部已签发会话 Cookie 立即失效)""" - uid = session.get(SESSION_KEY) - _clear_session_only() - if uid: - try: - _x('UPDATE users SET login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', (int(uid),)) - except Exception: - pass - - -def current_user() -> sqlite3.Row | None: - """当前登录用户(每请求只查一次库:结果缓存到 flask.g。 - 一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。 - 缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)""" - cached = getattr(g, '_site_current_user', None) - if cached is not None: - return cached[0] - user = _load_current_user() - g._site_current_user = (user,) - return user - - -def _load_current_user() -> sqlite3.Row | None: - uid = session.get(SESSION_KEY) - if not uid: - return None - user = get_user_by_id(int(uid)) - if not user: - return None - status = user['status'] if 'status' in user.keys() else 1 - if status == 0 or status == 2: - # 被封禁账号的残留会话:清除并记录一次(只清本会话,不动 seq) - if status == 2: - _clear_session_only() - add_log('fail', user['username'], _client_ip(), '封禁账号访问', - user['ban_reason'] if 'ban_reason' in user.keys() else '') - return None - # 顶号校验(F04):数据库已有版本号时,会话版本必须严格一致; - # 缺失/为 0 的会话版本不再放行(防绕过)。检测路径只清本会话,不动 seq。 - db_seq = int(user['login_seq'] if 'login_seq' in user.keys() and user['login_seq'] else 0) - sess_seq = int(session.get(SESSION_KEY + '_seq') or 0) - if db_seq and sess_seq != db_seq: - _clear_session_only() - add_log('fail', user['username'], _client_ip(), '顶号下线', - '账号已在其他设备登录或会话已失效,本机会话已下线') - return None - # P3-23:滚动续期——会话签发超过一半寿命(3.5 天)时刷新签发时间并 touch 会话, - # 活跃用户的 Cookie 有效期自动顺延;只有这半天窗口内的请求会带一次 Set-Cookie - try: - issued = int(session.get(SESSION_KEY + '_ts') or 0) - if issued and time.time() - issued > 3.5 * 86400: - session[SESSION_KEY + '_ts'] = int(time.time()) - session.permanent = True # 触发 cookie 重发(刷新 7 天窗口) - except Exception: - pass - return user - - -def _trusted_proxy_ip(header_value: str) -> str: - """第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。 - 最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端 - 伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份 - 记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。""" - if not header_value: - return '' - import ipaddress as _ipa - for part in reversed(header_value.split(',')): - part = part.strip() - if not part: - continue - try: - return str(_ipa.ip_address(part)) - except ValueError: - continue - return '' - - -def _client_ip_from_headers(remote: str) -> str: - """第 97 条:可信反代场景的客户端 IP 解析(_client_ip 与 main._rate_client_ip 共用)。 - XFF 最右段优先(唯一由可信反代追加的值,客户端伪造的最左段无效);最右段为 - 回环(PassNAT 纯 TCP 隧道下 Caddy 只能看到隧道本机端)时回落采信 X-Real-IP—— - 该场景要求隧道开启真实 IP 透传(覆写 X-Real-IP,部署闸 §5.7 B2 必须实测); - 客户端伪造面已由"Caddy 恒追加 XFF + 仅回环信任"收口。全部无效回落 remote。""" - fwd = _trusted_proxy_ip(request.headers.get('X-Forwarded-For') or '') - if fwd and not _is_loopback_ip(fwd): - return fwd - xrip = _trusted_proxy_ip(request.headers.get('X-Real-IP') or '') - if xrip: - return xrip - return remote - - -def _client_ip() -> str: - """客户端 IP(F08:仅当直连方是可信反代时才信任代理头,防伪造绕过限流。 - 第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意 - 主机直连可伪造 XFF 轮换绕过限流/登录锁定(动态实测实锤)。 - 第 97 条 P2-1:解析规则见 _client_ip_from_headers——XFF 最右段优先, - 最右段为回环时回落 X-Real-IP(PassNAT 隧道场景),不再无条件读 X-Real-IP)""" - remote = request.remote_addr or '-' - if remote != '-' and _is_loopback_ip(remote): - return _client_ip_from_headers(remote) - return remote - - # ============ 使用次数(按次计费:一首歌 = 1 次) ============ @@ -1017,103 +866,6 @@ def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int: return 0 -# ============ 装饰器 ============ - - -def _deny(message: str, code: int = 401, error_code: str = 'unauthorized') -> tuple[dict[str, Any], int]: - return ({'status': code, 'success': False, 'message': message, 'error_code': error_code}, code) - - -def login_required(action: str = '', cost=None, free: bool = False): - """强制登录装饰器: - - 未登录 401;剩余次数不足本次消耗时 401/expired - - free=True:解析/试听类接口,仅要求登录,不检查也不扣减次数 - - cost: 可选 callable,返回本次请求消耗次数(下载类:单曲 1 次,批量按歌曲数) - - A2/B8 统一账本通道:预扣与开账同事务(reserve_and_open,临时键挂在 flask.g); - 路由创建任务后用 ledger_rename 把账本改挂 task_id,worker 结算唯一入口走账本; - 请求失败(HTTP>=400/异常)时装饰器只对"仍 open 的账本行"结算退款—— - 任何路径(含线程启动失败、json 模式)都恰好退一次,杜绝双退/漏退。 - """ - def deco(fn): - @wraps(fn) - def wrapper(*args, **kwargs): - user = current_user() - if not user: - return _deny('请先登录后再使用解析功能') - fresh = get_user_by_id(user['id']) - if fresh is None: - # 极小竞态:会话校验通过后、预扣前用户被删除——按未登录收敛,不再 None 解引用 500 - return _deny('登录已失效,请重新登录') - credits = int(fresh['duration_seconds'] or 0) - unlimited = fresh['role'] == 'admin' or credits == -1 - need = 0 - reserved = False - from flask import g as _flask_g - if not free: - try: - need = max(1, int(cost() if cost is not None else 1)) - except Exception: - need = 1 - if not unlimited: - key = 'req-' + secrets.token_hex(8) - remaining = reserve_and_open(fresh['id'], need, key) - if remaining is None: - add_log('fail', fresh['username'], _client_ip(), action or request.path, - f'剩余次数不足(本次需 {need} 次)') - return _deny(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),' - '请充值卡密后继续下载', error_code='expired') - _flask_g.billing_ledger_key = key - reserved = True - def _refund_open(reason: str) -> None: - """A2:仅对仍 open 的账本行全额结算退款(被任务 rename/结算过则跳过)""" - key = getattr(_flask_g, 'billing_ledger_key', None) - if not key: - return - try: - row = ledger_get(key) - if row and row['status'] == 'open': - ledger_settle(key, 0, reason) - except Exception: - pass - try: - resp = fn(*args, **kwargs) - except Exception: - # 路由内部异常:预扣的次数经账本退回 - if reserved: - _refund_open('rejected') - raise - status = resp[1] if isinstance(resp, tuple) else 200 - if status < 400: - # P2-15:free 端点(搜索/解析等高频动作)成功不再写逐次 success 日志—— - # 它们与卡密充值/退费/封禁等审计关键日志同表竞争 5000 行配额, - # 忙站点几小时就把追账依据冲掉;下载/计费/失败路径照常记录 - if action and not free: - detail = (f'-{need} 次,剩余 {credits - need} 次' if reserved and need > 0 else '') - add_log('success', fresh['username'], _client_ip(), action, detail) - elif action and free: - # 解析类动作改记到 REQ_LOG(管理后台"操作记录"页),不入 logs 表 - pass - elif action: - add_log('fail', fresh['username'], _client_ip(), action, f'HTTP {status}') - if status >= 400 and reserved: - # 路由失败(404/版权限制等):预扣的次数经账本退回 - _refund_open('rejected') - return resp - return wrapper - return deco - - -def admin_required(fn): - """仅站点管理员可用""" - @wraps(fn) - def wrapper(*args, **kwargs): - user = current_user() - if not user or user['role'] != 'admin': - return _deny('需要管理员权限', 403, 'forbidden') - return fn(*args, **kwargs) - return wrapper - - # ============ 日志 ============ @@ -1697,7 +1449,7 @@ def restore_backup(name: str) -> None: # 替换后初始化失败:回滚安全副本,恢复原库 shutil.copy2(str(safety), str(DB_PATH)) init_db() - raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') + raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None # 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写 try: prune_backups(20, 5) diff --git a/core/async_bridge.py b/core/async_bridge.py index f858e63..94e6479 100644 --- a/core/async_bridge.py +++ b/core/async_bridge.py @@ -32,6 +32,11 @@ def _get_loop() -> asyncio.AbstractEventLoop: return _loop +def get_loop() -> asyncio.AbstractEventLoop: + """后台事件循环(供需要直接提交协程的调用方使用)。""" + return _get_loop() + + def run_sync[T](coro: Coroutine[Any, Any, T], timeout: float | None = None) -> T: """在同步代码中运行协程并等待结果。 diff --git a/core/net.py b/core/net.py new file mode 100644 index 0000000..061f931 --- /dev/null +++ b/core/net.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""客户端 IP 解析工具(框架无关)。 + +从 auth_system 抽出:可信反代判定与 XFF/X-Real-IP 解析规则。 +Flask 侧原本经 request.headers 取头;FastAPI 侧由 web.ratelimit 传入 header 值调用, +故此处只保留纯函数(不依赖任何 Web 框架)。 +""" + + +def is_loopback_ip(ip: str) -> bool: + """可信反代判定(第 91 条 P2-4:从"所有内网源"收窄为仅回环地址)。 + 服务绑 0.0.0.0 时内网任意主机都可直连伪造 XFF/X-Real-IP 轮换绕过限流与 + 登录锁定(实测实锤);同机 Caddy 反代(127.0.0.1 → Flask)不受影响。""" + try: + import ipaddress + addr = ipaddress.ip_address(ip) + return addr.is_loopback + except ValueError: + return False + + + + +def trusted_proxy_ip(header_value: str) -> str: + """第 97 条(P2-1):从可信反代转发的 X-Forwarded-For 中取最右一个可解析 IP。 + 最右段是可信反代(Caddy)追加的直连方地址;最左段与 X-Real-IP 均可被客户端 + 伪造(Caddy 默认不改写客户端自带的 X-Real-IP),采信它们等于给限流/锁定/省份 + 记录开了伪造口。无有效值返回空串,调用方回落 remote_addr。""" + if not header_value: + return '' + import ipaddress as _ipa + for part in reversed(header_value.split(',')): + part = part.strip() + if not part: + continue + try: + return str(_ipa.ip_address(part)) + except ValueError: + continue + return '' + + +# 兼容原名(auth_system 等历史调用点沿用下划线命名) +_is_loopback_ip = is_loopback_ip +_trusted_proxy_ip = trusted_proxy_ip diff --git a/core/sync_http.py b/core/sync_http.py new file mode 100644 index 0000000..b387c96 --- /dev/null +++ b/core/sync_http.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +"""同步 HTTP 门面(底层 aiohttp)——彻底移除 requests 依赖。 + +为什么需要它:部分代码天然是同步的(worker 线程里的流式下载、纯函数里的短链展开/ +IP 归属地查询)。本模块提供 requests 风格的同步 API,但底层复用 aiohttp 连接池, +不再引入 requests。 + +流式读取的实现:在后台事件循环起一个读取协程,把块推入有界队列(背压), +同步侧从队列消费——每次搬运只过一次队列,不做「每块一次跨线程调度」。 +""" +import asyncio +import queue +import threading +from collections.abc import Iterator, Mapping +from typing import Any + +from core import async_bridge +from core.http_client import client as _client + +_DEFAULT_TIMEOUT = 30 + + +class RequestException(Exception): + """请求异常基类(对齐 requests 命名,便于迁移)。""" + + +class HTTPError(RequestException): + """raise_for_status() 抛出的 HTTP 状态异常。""" + + def __init__(self, message: str, response: 'SyncResponse | None' = None): + super().__init__(message) + self.response = response + + +class SyncResponse: + """requests.Response 的同步子集(底层 aiohttp)。""" + + def __init__(self, status: int, headers: Mapping[str, str], content: bytes | None, + url: str, stream_state: dict | None = None): + self.status_code = status + self.headers = dict(headers) + self.url = url + self._content = content + self._stream = stream_state + + @property + def content(self) -> bytes: + if self._content is None: + self._content = b''.join(self.iter_content(64 * 1024)) + return self._content + + @property + def text(self) -> str: + return self.content.decode('utf-8', errors='replace') + + def json(self, **kw) -> Any: + import json as _json + return _json.loads(self.text, **kw) + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise HTTPError(f'HTTP {self.status_code}', self) + + def iter_content(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]: + """流式读取响应体(大小写与 requests 一致:chunk_size 为字节数)。""" + if self._content is not None: + for i in range(0, len(self._content), chunk_size): + yield self._content[i:i + chunk_size] + return + st = self._stream + assert st is not None + q: queue.Queue = st['queue'] + while True: + item = q.get() + if item is None: # 正常结束 + break + if isinstance(item, BaseException): # 读取出错 + raise item + yield item + + def close(self) -> None: + st = self._stream + if st and st.get('cancel') is not None: + try: + async_bridge.run_sync(_cancel(st)) + except Exception: + pass + + def __enter__(self) -> 'SyncResponse': + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False + + +async def _cancel(st: dict) -> None: + st['cancelled'] = True + resp = st.get('resp') + if resp is not None: + resp.close() + + +def _stream_reader(st: dict, chunk_size: int) -> None: + """在后台事件循环读块 → 有界队列(背压),同步侧消费。""" + async def _run(): + try: + async for chunk in st['resp'].content.iter_chunked(chunk_size): + if st.get('cancelled'): + break + while True: # 背压:队列满则稍等,避免内存无界 + try: + st['queue'].put(chunk, timeout=0.5) + break + except queue.Full: + if st.get('cancelled'): + return + except Exception as e: + st['queue'].put(e) + finally: + try: + st['resp'].close() + except Exception: + pass + st['queue'].put(None) + + asyncio.run_coroutine_threadsafe(_run(), async_bridge.get_loop()) + + +class Session: + """requests.Session 的同步子集(连接池由底层 aiohttp 统一维护)。""" + + trust_env = False + + def __init__(self, **kw) -> None: + # headers:会话级默认头(与 requests.Session().headers.update 等价) + self.headers: dict[str, str] = dict(kw.pop('headers', {})) + + def _merge(self, kw: dict) -> dict: + h = dict(self.headers) + h.update(kw.pop('headers', None) or {}) + if h: + kw['headers'] = h + return kw + + def get(self, url: str, **kw) -> SyncResponse: + return _request('GET', url, **self._merge(kw)) + + def post(self, url: str, **kw) -> SyncResponse: + return _request('POST', url, **self._merge(kw)) + + def close(self) -> None: + pass + + +def _norm_timeout(timeout): + """requests 允许 (connect, read) 元组;aiohttp 只接受单一总时长——取较大者。""" + if isinstance(timeout, (tuple, list)): + vals = [float(v) for v in timeout if isinstance(v, (int, float))] + return max(vals) if vals else _DEFAULT_TIMEOUT + return timeout + + +def _request(method: str, url: str, *, headers: Mapping[str, str] | None = None, + cookies: Mapping[str, str] | None = None, params: Mapping[str, Any] | None = None, + data: Any = None, json: Any = None, timeout: float | None = _DEFAULT_TIMEOUT, + allow_redirects: bool = True, stream: bool = False) -> SyncResponse: + timeout = _norm_timeout(timeout) + if stream: + return _open_stream(method, url, headers=headers, cookies=cookies, params=params, + data=data, json=json, timeout=timeout, + allow_redirects=allow_redirects) + resp = async_bridge.run_sync(_client.request( + method, url, headers=headers, cookies=cookies, params=params, data=data, + json_body=json, timeout=timeout, allow_redirects=allow_redirects)) + return SyncResponse(resp.status, resp.headers, resp.text.encode('utf-8'), + resp.url) + + +def _open_stream(method: str, url: str, **kw) -> SyncResponse: + timeout = _norm_timeout(kw.pop('timeout', _DEFAULT_TIMEOUT)) + # AsyncHTTPClient.open 只接受 headers/cookies/timeout/allow_redirects; + # 流式场景不支持 params/data/json(调用方直接把查询串拼进 URL) + for unsupported in ('params', 'data', 'json'): + kw.pop(unsupported, None) + resp = async_bridge.run_sync(_client.open(method, url, timeout=timeout, **kw)) + st = {'resp': resp, 'queue': queue.Queue(maxsize=8), 'cancel': object(), 'cancelled': False} + th = threading.Thread(target=_stream_reader, args=(st, 64 * 1024), daemon=True) + th.start() + return SyncResponse(resp.status, resp.headers, None, str(resp.url), stream_state=st) + + +# 模块级便捷函数(等价于共享 Session 的 get/post) +_default_session = Session() + + +def get(url: str, **kw) -> SyncResponse: + return _default_session.get(url, **kw) + + +def post(url: str, **kw) -> SyncResponse: + return _default_session.post(url, **kw) diff --git a/core/utils.py b/core/utils.py index e45923b..40b9c20 100644 --- a/core/utils.py +++ b/core/utils.py @@ -105,9 +105,9 @@ def expand_shortlink(url: str) -> str: return hit expanded = url try: - import requests + from core import sync_http - response = requests.get(url, allow_redirects=False, timeout=10) + response = sync_http.get(url, allow_redirects=False, timeout=10) expanded = response.headers.get('Location', url) except Exception: expanded = url diff --git a/docker-compose.yml b/docker-compose.yml index f8017fb..5344aef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - TZ=Asia/Shanghai # 未提供 .env 时的默认时区(F59:文档"默认即可运行"现在成立) - ENABLE_SECURE_COOKIE=${ENABLE_SECURE_COOKIE:-} # HTTPS 反代上线后设 1(见 .env.example) ports: - - "5001:5000" # 对外 5001 端口(容器内 Flask 监听 5000) + - "5001:5000" # 对外 5001 端口(容器内服务监听 5000) restart: unless-stopped volumes: - ./cookie.txt:/app/cookie.txt # 全局管理员 Cookie(持久化,重建不丢失;需先创建文件) diff --git a/entrypoint.sh b/entrypoint.sh index 6c9b2a2..fdb7e56 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,9 +10,5 @@ for f in user_cookie.txt cookie.txt; do fi [ -f "/app/$f" ] || touch "/app/$f" done -# 生产服务器改为 uvicorn(FastAPI + Flask 兜底,见 web/main.py) -# WEB_STACK=flask 可回退到旧入口 main.py(waitress/Werkzeug)以应急 -if [ "${WEB_STACK:-fastapi}" = "flask" ]; then - exec python3 main.py -fi +# 生产服务器:uvicorn(FastAPI)。 exec python3 web/main.py diff --git a/main.py b/main.py deleted file mode 100644 index 457bc62..0000000 --- a/main.py +++ /dev/null @@ -1,4526 +0,0 @@ -# -*- coding: utf-8 -*- -"""网易云音乐API服务主程序 - -提供网易云音乐相关API服务,包括: -- 歌曲信息获取 -- 音乐搜索 -- 歌单和专辑详情 -- 音乐下载 -- 健康检查 -""" - -import collections -import hashlib -import hmac -import json -import logging -import os -import re -import shutil -import sys -import threading -import time -import traceback -import uuid -import zipfile -from pathlib import Path -from typing import Any -from urllib.parse import parse_qs, quote, urlsplit - -import requests -from flask import Flask, Response, g, make_response, render_template, request, send_file, stream_with_context - -try: - import auth_system - import podcast_api - from auth_routes import register_auth_routes - from music_api import ( - APIException, - NeteaseAPI, - account_info, - album_detail, - album_subscribe, - artist_songs, - detect_vip_by_probe, - load_cookies, - lyric_v1, - name_v1, - playlist_detail, - playlist_subscribe, - post_comment, - search_music, - song_comments, - toplist_list, - url_v1, - user_playlists, - ) - from music_downloader import ( - CountingSemaphore, - DownloadCanceled, - DownloadException, - MusicDownloader, - fetch_cover_bytes, - ) - from qr_login import QRLoginClient -except ImportError as e: - # 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印 - print("ImportError: " + str(e)) - print("Please make sure all dependency modules exist and are importable.") - sys.exit(1) - - -# ============ 框架无关核心层(core/)============ -# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现** -# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。 -# QRLoginManager 等已异步化的上游方法,Flask 同步路由经 run_sync 桥接调用。 -from core.async_bridge import run_sync -from core.config import ( - APP_VERSION, - BATCH_ZIP_CONCURRENCY, - CANCEL_AFTER_SECONDS, - HEARTBEAT_INTERVAL, - MAX_ACTIVE_TASKS_PER_USER, - MAX_TERMINAL_TASKS, - MAX_TOTAL_TASKS, - PAUSE_AFTER_SECONDS, - RESULT_RETENTION_SECONDS, - USER_COOKIE_FILE, - VALID_QUALITY_LEVELS, - Settings, -) -from core.security import init_secret_key as _init_audio_secret -from core.security import protect_audio_urls as _protect_audio_urls -from core.security import verify_audio_token as _verify_audio_token -from core.state import ( - BATCH_DURATIONS, - BATCH_DURATIONS_LOCK, - DEVICE_LAST_SEEN, - DEVICE_LAST_SEEN_LOCK, - DOWNLOAD_TASKS, - DOWNLOAD_TASKS_LOCK, - REQ_LOG, - REQUEST_STATS, -) -from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE -from core.state import RATE_BUCKETS as _RATE_BUCKETS -from core.state import RATE_LOCK as _RATE_LOCK -from core.state import SERVER_NICK_CACHE as _SERVER_NICK_CACHE -from core.state import SERVER_NICK_LOCK as _SERVER_NICK_LOCK -from core.state import STATS_LOCK as _STATS_LOCK -from core.state import VIP_CACHE as _VIP_CACHE -from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK -from core.state import VIP_USER_CACHE as _VIP_USER_CACHE -from core.utils import expand_shortlink as _expand_shortlink -from core.utils import format_lyric as _format_lyric -from core.utils import parse_meta_fields as _parse_meta_fields -from core.utils import safe_console_print as _safe_console_print -from core.utils import safe_int as _safe_int - -# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 -APIConfig = Settings - - -class APIResponse: - """API响应工具类""" - - @staticmethod - def success(data: Any = None, message: str = 'success', status_code: int = 200) -> tuple[dict[str, Any], int]: - """成功响应""" - response = { - 'status': status_code, - 'success': True, - 'message': message - } - if data is not None: - response['data'] = data - return response, status_code - - @staticmethod - def error(message: str, status_code: int = 400, error_code: str | None = None) -> tuple[dict[str, Any], int]: - """错误响应""" - response = { - 'status': status_code, - 'success': False, - 'message': message - } - if error_code: - response['error_code'] = error_code - return response, status_code - - -class MusicAPIService: - """音乐API服务类""" - - def __init__(self, config: APIConfig): - self.config = config - self.logger = self._setup_logger() - self.netease_api = NeteaseAPI() - # F97:下载目录传给下载器——原实现下载器固定 downloads, - # 服务配置指向自定义目录时音乐落盘位置与配置分离 - self.downloader = MusicDownloader(download_dir=config.downloads_dir) - - # 创建下载目录 - self.downloads_path = Path(config.downloads_dir) - self.downloads_path.mkdir(exist_ok=True) - - self.logger.info(f"音乐API服务初始化完成,下载目录: {self.downloads_path.absolute()}") - - def _setup_logger(self) -> logging.Logger: - """设置日志记录器""" - logger = logging.getLogger('music_api') - logger.setLevel(getattr(logging, self.config.log_level.upper())) - - if not logger.handlers: - # 控制台处理器 - console_handler = logging.StreamHandler() - console_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - console_handler.setFormatter(console_formatter) - logger.addHandler(console_handler) - - # 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀) - try: - from logging.handlers import RotatingFileHandler - file_handler = RotatingFileHandler('music_api.log', maxBytes=5 * 1024 * 1024, - backupCount=3, encoding='utf-8') - file_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' - ) - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) - except Exception as e: - logger.warning(f"无法创建日志文件: {e}") - - return logger - - def _get_cookies(self) -> dict[str, str]: - """直接从 cookie.txt 读取并解析为 dict""" - try: - cookies = load_cookies() - if not cookies: - self.logger.warning("cookie.txt 为空或不存在,部分接口(VIP 音质)将不可用") - return cookies - except Exception as e: - self.logger.error(f"读取 cookie.txt 异常: {e}") - return {} - - def _extract_music_id(self, id_or_url: str) -> str: - """提取音乐ID(F7/F74:正规 URL 解析替代子串/删除非数字启发式) - - 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF) - - music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关) - """ - raw = str(id_or_url or '').strip() - try: - # 短链展开:仅接受"整个输入就是 163cn.tv 链接"(F7:子串包含不算) - if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE): - raw = _expand_shortlink(raw) - - # 网易云链接:正规解析 query 取 id(F74:不再 find('id=') 误吃 userid= 尾部) - if 'music.163.com' in raw: - try: - q = parse_qs(urlsplit(raw).query) - for key in ('id', 'songid'): - if q.get(key): - return str(q[key][0]).strip() - except Exception: - pass - # 兜底:老格式 #/song?id=xxx 的片段部分 - m = re.search(r'[?&#]id=(\d+)', raw) - if m: - return m.group(1) - - # 纯数字/普通文本直接返回 - return raw - except Exception as e: - self.logger.error(f"提取音乐ID失败: {e}") - return str(id_or_url or '').strip() - - def _format_file_size(self, size_bytes) -> str: - """格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)""" - try: - size = float(size_bytes or 0) - except (TypeError, ValueError): - return "大小未知" - if size <= 0: - return "0B" - - units = ["B", "KB", "MB", "GB", "TB"] - unit_index = 0 - - while size >= 1024.0 and unit_index < len(units) - 1: - size /= 1024.0 - unit_index += 1 - - return f"{size:.2f}{units[unit_index]}" - - def _get_quality_display_name(self, quality: str) -> str: - """获取音质显示名称""" - quality_names = { - 'standard': "标准音质", - 'exhigh': "极高音质", - 'lossless': "无损音质", - 'hires': "Hi-Res音质", - 'sky': "沉浸环绕声", - 'jyeffect': "高清环绕声", - 'jymaster': "超清母带", - 'dolby': "杜比全景声" - } - return quality_names.get(quality, f"未知音质({quality})") - - def _validate_request_params(self, required_params: dict[str, Any]) -> tuple[dict[str, Any], int] | None: - """验证请求参数""" - for param_name, param_value in required_params.items(): - if not param_value: - return APIResponse.error(f"参数 '{param_name}' 不能为空", 400) - return None - - def _safe_get_request_data(self) -> dict[str, Any]: - """安全获取请求数据""" - try: - if request.method == 'GET': - return dict(request.args) - else: - # 优先使用JSON数据,然后是表单数据 - json_data = request.get_json(silent=True) or {} - form_data = dict(request.form) - # 合并数据,JSON优先 - return {**form_data, **json_data} - except Exception as e: - self.logger.error(f"获取请求数据失败: {e}") - return {} - - -# 创建Flask应用和服务实例 -config = APIConfig() -app = Flask(__name__) -api_service = MusicAPIService(config) - -# ============ 站点认证系统(SQLite + 签名会话) ============ -app.secret_key = auth_system.get_secret_key() -# 音频签名密钥注入 core.security(框架无关;与 app.secret_key 同源, -# 保证 Flask 与迁移目标 FastAPI 生成的音频 token 互相可校验) -_init_audio_secret(app.secret_key) - - -def _quiesce_tasks_for_restore() -> None: - """S5:恢复数据库前取消全部在途任务(worker 不再引用旧库用户结算)""" - with DOWNLOAD_TASKS_LOCK: - for task in DOWNLOAD_TASKS.values(): - if task.get('status') in ('queued', 'running', 'paused'): - task['status'] = 'canceled' - task['msg'] = '数据库恢复中,任务已取消(费用按实际交付结算)' - task['completed_at'] = time.time() - try: - task['cancel'].set() - except Exception: - pass - - -auth_system.register_before_restore_hook(_quiesce_tasks_for_restore) -app.config['PERMANENT_SESSION_LIFETIME'] = 60 * 60 * 24 * 7 # 会话 7 天 -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF 加固:跨站请求不携带会话 Cookie -# F11:部署在 HTTPS 反代(如 Caddy)后设置环境变量 ENABLE_SECURE_COOKIE=1, -# 会话 Cookie 仅经 HTTPS 传输(HTTP 部署下不要开启,否则浏览器拒收 Cookie) -if os.environ.get('ENABLE_SECURE_COOKIE', '').strip() in ('1', 'true', 'yes'): - app.config['SESSION_COOKIE_SECURE'] = True -auth_system.init_db() -register_auth_routes(app) - - -# ============ 公网限流(按 IP 滑动窗口,防刷防爆破) ============ -# _RATE_BUCKETS / _RATE_LOCK 已迁出至 core.state(此处 import 同名共享) - - -def _rate_client_ip() -> str: - # F08:仅当直连方是可信反代时才信任代理头(与 auth_system._client_ip 同策略)。 - # 第 91 条 P2-4:可信范围从"本机/内网"收窄为"仅回环"——绑 0.0.0.0 时内网任意 - # 主机直连可伪造 XFF 轮换绕过全部限流(动态实测实锤) - remote = request.remote_addr or '-' - ip = remote - if remote != '-' and auth_system._is_loopback_ip(remote): - # 第 97 条(P2-1):统一走 auth_system._client_ip_from_headers——XFF 最右段 - # 优先(客户端伪造的最左段与直连伪造均无效),最右段为回环(PassNAT 隧道 - # 场景)回落 X-Real-IP;取值做可解析校验,垃圾值回落直连地址 - ip = auth_system._client_ip_from_headers(remote) - return ip - - -def _rate_ok(key: str, limit: int, window: int) -> bool: - """滑动窗口限流:True=放行,False=超限""" - now = time.time() - with _RATE_LOCK: - dq = _RATE_BUCKETS.get(key) - if dq is None: - # 桶数量防膨胀:超限时清理不活跃桶 - if len(_RATE_BUCKETS) > 8000: - for k in [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > window]: - _RATE_BUCKETS.pop(k, None) - dq = collections.deque() - _RATE_BUCKETS[key] = dq - while dq and now - dq[0] > window: - dq.popleft() - if len(dq) >= limit: - return False - dq.append(now) - return True - - -@app.before_request -def _body_size_limit(): - """P2-16:POST 请求体上限。默认 2MB(正常业务请求都在 KB 级);数据库备份上传 - 单独放宽到 200MB(P3-11)。按 Content-Length 直接短路返回 413 JSON—— - 原实现的 MAX_CONTENT_LENGTH 方式,413 会被 silent=True 的 JSON 解析吞成 400。 - R5-1:无 Content-Length 的 chunked 流式请求同样拒绝——本站前端从不发 chunked, - 放行等于给公网开无限内存放大面(Werkzeug 逐线程全量读取)。""" - limit = 200 * 1024 * 1024 if request.path == '/admin/site/backup/upload' else 2 * 1024 * 1024 - cl = request.content_length or 0 - if cl > limit: - return APIResponse.error("请求体过大,请减少提交内容后重试", 413) - if request.method == 'POST' and cl == 0 and \ - 'chunked' in (request.headers.get('Transfer-Encoding') or '').lower(): - return APIResponse.error("请求体过大,请减少提交内容后重试", 413) - - -@app.before_request -def _rate_limit_guard(): - """公网接口限流(按 IP):免费业务 30 次/分,下载提交 20 次/分,登录 10 次/分。 - 试听流 /audio_stream 与进度轮询不受限(保证播放与任务状态刷新)。""" - if request.method == 'OPTIONS': - return None - path = request.path - if path == '/auth/login': - if not _rate_ok('login:' + _rate_client_ip(), 10, 60): - return APIResponse.error('尝试过于频繁,请稍后再试', 429) - return None - if path in ('/auth/admin_login/email', '/auth/admin_login/verify'): - # 第 86 条:管理员两步验证 Step2/3 共桶 10 次/分/IP(Step1 走 /auth/login 桶; - # 票据侧另有 5 次尝试上限) - if not _rate_ok('adminlogin:' + _rate_client_ip(), 10, 60): - return APIResponse.error('尝试过于频繁,请稍后再试', 429) - return None - if path == '/auth/redeem': - # R5-P3:卡密爆破在线虽不可行,但登录号无限速刷失败会灌掉审计日志——独立 5 次/分桶 - if not _rate_ok('redeem:' + _rate_client_ip(), 5, 60): - return APIResponse.error('操作过于频繁,请稍后再试', 429) - return None - if path == '/auth/forgot_password': - # R5-P3:找回密码涉及验码+改密,独立 5 次/分桶(配合验证码 5 次错作废) - if not _rate_ok('forgot:' + _rate_client_ip(), 5, 60): - return APIResponse.error('操作过于频繁,请稍后再试', 429) - return None - if path == '/auth/register': - # 注册接口本身不在统计名单里,但验证码是 6 位数字且消费是原子删除—— - # 不限流就存在无速率上限的暴力猜测面,这里按 IP 10 次/分钟封住 - if not _rate_ok('reg:' + _rate_client_ip(), 10, 60): - return APIResponse.error('注册尝试过于频繁,请稍后再试', 429) - return None - if path == '/api/task/heartbeat': - # 第 98 条:心跳不在统计名单且原零限速——正常节奏 15s/次(4 次/分), - # 60 次/分/IP 的宽松桶只封滥用,不影响合法客户端 - if not _rate_ok('hb:' + _rate_client_ip(), 60, 60): - return APIResponse.error('请求过于频繁,请稍后再试', 429) - return None - if path.lower() not in _COUNTED_LOWER: - return None - # R5-P3:/download/result 等领取端点不计入 20 次/分付费提交桶——连续领取多个 - # 文件(批量逐首模式)会被自家限流误伤;领取免费且归属校验在端点内 - if path.startswith('/download') and '/result' not in path: - ok = _rate_ok('dl:' + _rate_client_ip(), 20, 60) - elif path.startswith('/podcast/download'): - ok = _rate_ok('dl:' + _rate_client_ip(), 20, 60) - else: - # 30/分:实测网易云搜索接口的快速突发容忍度就在 30 次附近, - # 超过的请求会被网易云拒绝并变成 500——限流阈值对齐后用户只会看到干净的 429 - ok = _rate_ok('free:' + _rate_client_ip(), 30, 60) - if not ok: - return APIResponse.error('请求过于频繁,请稍后再试', 429) - - -@app.before_request -def _count_request(): - """统计各接口请求数(仅统计真实业务接口)。C8:键统一小写,别名不再分开计数""" - path = request.path.lower() - if path in _COUNTED_LOWER: - with _STATS_LOCK: - info = REQUEST_STATS.setdefault(path, {'count': 0, 'last': None}) - info['count'] += 1 - info['last'] = time.strftime('%Y-%m-%d %H:%M:%S') - - -@app.route('/api/topen', methods=['GET', 'POST']) -def api_topen(): - """接口请求统计(实时)(F112:锁内快照,杜绝 total 与明细不一致或迭代中 500)""" - try: - with _STATS_LOCK: - snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()} - endpoints = [] - total = sum(v['count'] for v in snapshot.values()) - for path, info in snapshot.items(): - endpoints.append({ - 'path': path, - 'name': ENDPOINT_NAMES.get(path, path), - 'count': info['count'], - 'percent': round(info['count'] / total * 100, 1) if total else 0, - 'last': info['last'], - }) - endpoints.sort(key=lambda x: x['count'], reverse=True) - return APIResponse.success({ - 'total': total, - 'endpoint_count': len(endpoints), - 'max_count': endpoints[0]['count'] if endpoints else 0, - 'endpoints': endpoints, - }, "获取请求统计成功") - except Exception as e: - api_service.logger.error(f"获取请求统计异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/queue', methods=['GET', 'POST']) -def api_queue(): - """实时排队状态(前端悬浮框轮询):排队人数 + 预计等待时间""" - try: - dl_sem = api_service.downloader._download_semaphore - avg_batch = None - with BATCH_DURATIONS_LOCK: - if BATCH_DURATIONS: - avg_batch = sum(BATCH_DURATIONS) / len(BATCH_DURATIONS) - data = { - 'batch_capacity': BATCH_ZIP_SEM.capacity, - 'batch_active': BATCH_ZIP_SEM.active, - 'batch_waiting': BATCH_ZIP_SEM.waiting, - 'download_capacity': dl_sem.capacity, - 'download_active': dl_sem.active, - 'download_waiting': dl_sem.waiting, - 'avg_batch_seconds': round(avg_batch, 1) if avg_batch else None, - } - return APIResponse.success(data, "获取排队状态成功") - except Exception as e: - api_service.logger.error(f"获取排队状态异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/batch_progress', methods=['GET', 'POST']) -@auth_system.login_required(free=True) -def api_batch_progress(): - """下载任务进度(前端悬浮窗轮询):已处理 X/Y + 百分比 + 预计剩余时间。 - F05:仅任务本人可查询;F38:终态保留期从完成时刻起算;F95:未知总数不是 100%""" - try: - data = api_service._safe_get_request_data() - task_id = str(data.get('task_id') or '').strip() - if not task_id: - return APIResponse.error("缺少 task_id 参数") - - now = time.time() - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS.get(task_id) - - if not task: - return APIResponse.error("任务不存在或已过期", 404) - if not _task_owned_by_current_user(task): - return APIResponse.error("无权查询该任务", 403) - - total = task.get('total', 0) - done = task.get('done', 0) - # F95:总数未知(total=0)的运行中任务进度应为 0%,不是 100%; - # 单曲任务运行中优先用下载器回报的字节级 live_percent(进度条不再卡 0%) - live = task.get('live_percent') - if task.get('status') == 'running' and isinstance(live, (int, float)): - percent = round(min(99.0, max(0.0, float(live))), 1) - elif total: - percent = round(done / total * 100, 1) - else: - percent = 100.0 if task.get('status') in ('done',) else 0.0 - elapsed = now - task.get('start', now) - # 预计剩余:按当前已完成进度速率外推(仅运行中且有进度时计算) - eta = None - if task.get('status') == 'running' and done > 0 and elapsed > 0 and total: - rate = done / elapsed - if rate > 0: - eta = round((total - done) / rate, 1) - result = task.get('result') or {} - has_result = bool(result.get('zip_path') or result.get('file_path')) - return APIResponse.success({ - 'task_id': task_id, - 'type': task.get('type', 'batch'), - 'total': total, - 'done': done, - 'failed': task.get('failed', 0), - 'percent': percent, - 'status': task.get('status', 'running'), - 'elapsed': round(elapsed, 1), - 'eta': eta, - 'msg': task.get('msg', ''), - 'refunded': task.get('refunded', 0), - # F24:取消/失败但已有部分成果 → 前端凭此直接领取 - 'has_result': has_result, - }, "获取任务进度成功") - except Exception as e: - api_service.logger.error(f"获取任务进度异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -# R5-P3:取消是状态变更动作,收敛 POST-only(SameSite=Lax 下顶层导航 GET 可携 Cookie -# 触发取消自己的任务,属滋扰面);前端既有调用就是 POST,零兼容影响 -@app.route('/api/task/cancel', methods=['POST']) -@auth_system.login_required(free=True) -def api_task_cancel(): - """取消下载任务(批量打包 / 单曲下载)—— 仅任务本人的站点账号可取消(F05)""" - try: - data = api_service._safe_get_request_data() - task_id = str(data.get('task_id') or '').strip() - if not task_id: - return APIResponse.error("缺少 task_id 参数") - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS.get(task_id) - if not task: - return APIResponse.error("任务不存在或已过期", 404) - # 归属校验:站点用户必须匹配(device_id 仅作辅助兼容) - if not _task_owned_by_current_user(task): - return APIResponse.error("无权取消该任务", 403) - if task.get('status') in ('done', 'error', 'canceled'): - return APIResponse.error(f"任务已结束({task.get('status')})", 400) - task['status'] = 'canceled' - task['msg'] = '任务已取消' - task['completed_at'] = time.time() # F38:手工取消也记录完成时刻(worker 收尾会再刷新) - task['cancel'].set() - return APIResponse.success({'task_id': task_id, 'status': 'canceled'}, "已请求取消") - except Exception as e: - api_service.logger.error(f"取消任务异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/task/heartbeat', methods=['GET', 'POST']) -@auth_system.login_required(free=True) -def api_task_heartbeat(): - """设备心跳:保持任务活跃;暂停中的任务自动恢复(重新进入排队)。 - F05:恢复操作仅对本人任务生效""" - try: - data = api_service._safe_get_request_data() - device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) - if not device_id: - return APIResponse.error("缺少 device_id 参数") - _touch_device(device_id) - me = auth_system.current_user() - my_uid = int(me['id']) if me else 0 - resumed = 0 - with DOWNLOAD_TASKS_LOCK: - for task in DOWNLOAD_TASKS.values(): - if (task.get('device_id') == device_id - and my_uid and int(task.get('user_id') or 0) == my_uid - and task.get('status') == 'paused'): - task['status'] = 'queued' - task['msg'] = '恢复中...' - task['pause'].clear() # 唤醒暂停中的 worker 继续下载 - resumed += 1 - # 清理过期设备记录 - now = time.time() - with DEVICE_LAST_SEEN_LOCK: - stale = [d for d, ts in DEVICE_LAST_SEEN.items() if now - ts > 86400] - for d in stale: - DEVICE_LAST_SEEN.pop(d, None) - return APIResponse.success({'resumed': resumed, 'online': True}, "心跳成功") - except Exception as e: - api_service.logger.error(f"心跳异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/task/list', methods=['GET', 'POST']) -@auth_system.login_required(free=True) -def api_task_list(): - """获取本设备未结束的任务列表(重新打开网站时恢复任务悬浮窗)。 - F05:仅返回当前登录用户本人的任务""" - try: - data = api_service._safe_get_request_data() - device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) - if not device_id: - return APIResponse.error("缺少 device_id 参数") - _touch_device(device_id) - me = auth_system.current_user() - my_uid = int(me['id']) if me else 0 - my_tasks = [] - with DOWNLOAD_TASKS_LOCK: - for tid, task in DOWNLOAD_TASKS.items(): - if (task.get('device_id') == device_id - and my_uid and int(task.get('user_id') or 0) == my_uid - and task.get('status') in ('queued', 'running', 'paused')): - my_tasks.append({ - 'task_id': tid, - 'type': task.get('type', 'batch'), - # P3-33:播客打包 type 同为 batch,用播客特有键区分 kind, - # 供前端恢复时显示正确单位(首/集) - 'kind': ('podcast' if ('program_id' in (task.get('params') or {}) - or 'radio_name' in (task.get('params') or {}) - or (task.get('params') or {}).get('radio_id')) - else 'music'), - 'total': task.get('total', 0), - 'done': task.get('done', 0), - 'status': task.get('status', 'queued'), - 'msg': task.get('msg', ''), - }) - return APIResponse.success({'tasks': my_tasks}, "获取任务列表成功") - except Exception as e: - api_service.logger.error(f"任务列表异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/fe_error', methods=['POST', 'GET']) -def api_fe_error(): - """前端错误日志上报(2026-09-17 v10 新增) - - 用途:收集各浏览器(含老内核/微信 QQ 内置/国产浏览器)的 JS 运行错误、 - Promise 拒绝与资源加载失败,便于排查"某些浏览器 API 行为不一致/样式错位"类问题。 - 安全:每 IP 每分钟 10 次;字段全部截断;仅写服务日志(music_api.log,已轮转),不入库。 - """ - try: - # 独立限流:10 次/分/IP(异常场景下用户连续报错也不会刷爆日志) - if not _rate_ok('fe:' + _rate_client_ip(), 10, 60): - return APIResponse.error('上报过于频繁', 429) - data = api_service._safe_get_request_data() - # P2-3:全部字段与 stack 同口径清洗换行/回车(否则可向 music_api.log 注入伪造多行内容) - msg = str(data.get('msg') or '')[:200].replace('\n', ' | ').replace('\r', '') - etype = str(data.get('type') or 'error')[:20].replace('\n', ' ').replace('\r', '') - stack = str(data.get('stack') or '')[:800].replace('\n', ' | ').replace('\r', '') - page = str(data.get('page') or '')[:120].replace('\n', ' | ').replace('\r', '') - env = str(data.get('env') or '')[:200].replace('\n', ' | ').replace('\r', '') - if not msg: - return APIResponse.error("缺少 msg 参数") - ip = _rate_client_ip() - api_service.logger.error( - f"[前端错误] type={etype} msg={msg} page={page} ip={ip} env={env} stack={stack}") - return APIResponse.success(None, "已记录") - except Exception as e: - api_service.logger.error(f"前端错误上报处理异常: {e}") - return APIResponse.error("记录失败", 500) - -# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录), -# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*) -# 均通过该会话鉴权。 - -# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。 -# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。 -# (下载本身的并发已由下载器内的全局信号量控制) -# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。 -# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁, -# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。 -BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY) - -# 以下运行时状态/常量已迁出(原样实现,此处 import 共享同一对象): -# DOWNLOAD_TASKS / DEVICE_LAST_SEEN / BATCH_DURATIONS → core.state -# PAUSE_AFTER_SECONDS / CANCEL_AFTER_SECONDS / HEARTBEAT_INTERVAL -# MAX_ACTIVE_TASKS_PER_USER / MAX_TOTAL_TASKS / MAX_TERMINAL_TASKS -# USER_COOKIE_FILE → core.config - - -def _register_download_task(task: dict[str, Any], reserved: int, task_id_hint: str = '') -> str | None: - """任务登记统一入口:归属校验、数量上限、账本承接。失败返回错误描述(str),成功返回 None。 - - - F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话) - - F20:每用户在途任务与全站任务上限 - - A4:task_id 的碰撞检查、换新与写入在同一把锁内完成(消除 TOCTOU 覆盖窗口) - - A2/B8:把请求级临时账本行改名挂到正式 task_id(worker 结算唯一入口走账本) - """ - user = auth_system.current_user() - if not user: - return '登录已失效,请重新登录' - task['user_id'] = int(user['id']) - task['billing_reserved'] = int(reserved) - with DOWNLOAD_TASKS_LOCK: - tid = (task_id_hint or '').strip() or uuid.uuid4().hex[:12] - # R5-P1(第 77 条):task_id 是 downloads/tasks/ 的路径成分(mkdir/rmtree - # 都用它),客户端值必须强制白名单——含路径分隔符/../绝对路径/超长一律换服务端 uuid - if not re.match(r'^[A-Za-z0-9_-]{1,32}$', tid): - tid = uuid.uuid4().hex[:12] - while tid in DOWNLOAD_TASKS: - tid = uuid.uuid4().hex[:12] - active = sum(1 for t in DOWNLOAD_TASKS.values() - if t.get('user_id') == user['id'] and t.get('status') in ('queued', 'running', 'paused')) - if active >= MAX_ACTIVE_TASKS_PER_USER: - return f'您有 {active} 个任务正在进行,请等待完成或取消后再提交' - # 第 97 条(P2-2):全站容量只数在途任务——终态记录留表 35 分钟属领取窗口, - # 不再挤占在途配额;终态单独用更大的滞留上限保内存有界 - active_total = sum(1 for t in DOWNLOAD_TASKS.values() - if t.get('status') in ('queued', 'running', 'paused')) - if active_total >= MAX_TOTAL_TASKS: - return '当前任务队列已满,请稍后再试' - if len(DOWNLOAD_TASKS) - active_total >= MAX_TERMINAL_TASKS: - return '当前任务队列已满,请稍后再试' - task['task_id'] = tid - DOWNLOAD_TASKS[tid] = task - # 账本承接:请求级临时行(login_required 预扣时创建)改名为正式 task_id; - # 无临时行(unlimited 用户路径)则直接开一行 reserved=0 的账。 - # 第 91 条(P2-2):承接抛异常(SQLite 锁超时等)时回滚任务注册——否则僵尸 - # queued 任务占用户在途名额最长 4 小时(无 worker、无账本行,装饰器只退请求级行) - _gkey = getattr(g, 'billing_ledger_key', None) - try: - if _gkey: - # R5-P3:rename 失败 = task_id 与 90 天保留期内的历史账本行撞名—— - # 复用会把本次预扣挂到已结算的旧行上(结算打到旧行返 0,预扣冻结成孤儿)。 - # 换服务端 uuid 重新注册任务键并重试 rename,彻底消除撞名面。 - renamed = auth_system.ledger_rename(_gkey, tid) - retries = 0 - while not renamed and retries < 5: - retries += 1 - with DOWNLOAD_TASKS_LOCK: - DOWNLOAD_TASKS.pop(tid, None) - tid = uuid.uuid4().hex[:12] - while tid in DOWNLOAD_TASKS: - tid = uuid.uuid4().hex[:12] - task['task_id'] = tid - DOWNLOAD_TASKS[tid] = task - renamed = auth_system.ledger_rename(_gkey, tid) - if renamed: - g.billing_ledger_key = tid # 后续失败退款由装饰器按"仍 open 才退"处理 - else: - # 第 91 条(P3-1):5 次撞名后不再兜底开新行——那会给同一笔预扣留下 - # 两个 open 账本行(结算/对账各认一行,破坏守恒)。直接失败:任务注册 - # 由下方 except 回滚,装饰器退回请求级预扣行(概率≈0 的极端路径) - raise RuntimeError('任务账本键撞名,请重新提交') - else: - auth_system.ledger_open(tid, user['id'], reserved) - except Exception: - with DOWNLOAD_TASKS_LOCK: - if DOWNLOAD_TASKS.get(tid) is task: - DOWNLOAD_TASKS.pop(tid, None) - raise - return None - - -def _start_task_thread(task_id: str, target, args=()) -> str | None: - """启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。 - A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。 - R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死 - (无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。""" - try: - th = threading.Thread(target=target, args=args, daemon=True) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['worker_thread'] = th - th.start() - return None - except Exception as e: - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.pop(task_id, None) - if t: - try: - auth_system.ledger_settle(task_id, 0, 'submit_failed') - except Exception: - pass - api_service.logger.error(f"任务线程启动失败: {task_id} {e}") - return f'任务启动失败:{e}' - - -def _task_owned_by_current_user(task: dict[str, Any]) -> bool: - """任务归属校验(F05:站点用户是任务的第一归属,device_id 仅作辅助)""" - user = auth_system.current_user() - if not user: - return False - return int(task.get('user_id') or 0) == int(user['id']) - - -def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None: - """任务进入终态的统一入口(F38:记录 completed_at,保留期从完成时刻起算, - 而不是从提交时刻起算)。 - R5-14/F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算), - 代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。 - 第 100 条(T1 不变量):终态先到先得——已终态(done/error/canceled)的任务 - 不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败 - 不得把"已取消"改写成"失败",completed_at 也不再刷新)。""" - need_settle = False - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t and t.get('status') in ('done', 'error', 'canceled'): - return - if t: - t['status'] = status - t['msg'] = msg or t.get('msg', '') - t['completed_at'] = time.time() - if status in ('canceled', 'error'): - th = t.get('worker_thread') - if th is not None and not th.is_alive(): - need_settle = True - # 第 97 条(P2-3):settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库 - # (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆; - # settle 幂等,锁外调用无双退 - if need_settle: - try: - auth_system.ledger_settle(task_id, 0, status) - except Exception: - pass - - -def _task_files(task: dict[str, Any]) -> list[str]: - """任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)""" - result = task.get('result') or {} - files = [] - for f in (result.get('zip_path'), result.get('file_path')): - if f: - files.append(f) - return files - - -def _sweep_download_cache(max_age_seconds: int = RESULT_RETENTION_SECONDS) -> None: - """下载缓存清扫(F27 配套:批量任务不再即时删除共享缓存,改由这里按年龄回收)。 - 只清扫 downloads 根目录的普通文件,不碰 tasks/ 子目录(那是任务专属交付物)。""" - try: - root = api_service.downloads_path - now = time.time() - for p in root.iterdir(): - try: - if p.is_file() and now - p.stat().st_mtime > max_age_seconds: - p.unlink() - except OSError: - pass - except Exception: - pass - - -_MONITOR_ERR_TS = {'ts': 0.0, 'n': 0} # 监控线程异常限频记录(持续异常不静默也不刷屏) -# 第 95 条(P1-1):监控线程周期兜底的三个节流字典——第 94 条补丁时定义丢失 -# (只剩使用),NameError 被外层 except 吞成 WARNING,孤儿对账/账本清理/tasks -# 补扫自那以后从未执行(music_api.log 实证 2064 条 WARNING)。红转绿:重启后 -# 日志零 NameError 且对账真实运行。 -_LEDGER_LAST_RECOVER = {'ts': 0.0} # 上次孤儿账本对账时间(每 60s 一次) -_LEDGER_LAST_CLEANUP = {'ts': 0.0} # 上次 settled 账本 90 天清理时间(每 24h 一次) -_TASKS_SWEEP_LAST = {'ts': 0.0} # 上次 tasks/ 孤儿目录补扫时间(每小时一次) - - -def _task_monitor_loop() -> None: - """后台监控: - - 心跳超时(40s)→ 暂停离线设备的批量任务,释放排队名额给下一位 - - 心跳超时 > 30 分钟 → 自动取消该设备的所有未完成任务并清理文件 - - 终态任务按 completed_at 保留 35 分钟(F38/P1-3:与前端「领取」入口对齐) - - 运行中/排队/暂停任务超过 4 小时仍未结束 → 置取消(F38:cancel Event 一并置位) - - 下载缓存按年龄清扫(F27 配套,同为 35 分钟口径) - - 顺带清理限流桶中的过期项(O-5:不再只依赖"新建桶时"的清理时机) - """ - while True: - time.sleep(HEARTBEAT_INTERVAL) - try: - now = time.time() - # ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)---- - # 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录 - # 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆 - finished_done: list[str] = [] - finished_retry: list[str] = [] - expired: list[tuple] = [] - with DOWNLOAD_TASKS_LOCK: - for tid, task in DOWNLOAD_TASKS.items(): - if task.get('status') not in ('done', 'error', 'canceled'): - continue - done_at = task.get('completed_at') or 0 - # 兼容无 completed_at 的旧记录:回退到 start - if not done_at: - done_at = task.get('start', 0) - if now - done_at <= RESULT_RETENTION_SECONDS: - continue - expired.append((tid, _task_files(task), - api_service.downloads_path / 'tasks' / tid)) - cleanup_results: list[tuple] = [] - for tid, files, task_dir in expired: - all_removed = True - for f in files: - try: - os.remove(f) - except FileNotFoundError: - pass - except OSError: - all_removed = False # F106:文件暂时不可删(被占用等) - if task_dir.exists(): - shutil.rmtree(str(task_dir), ignore_errors=True) - if task_dir.exists(): - all_removed = False - cleanup_results.append((tid, all_removed)) - with DOWNLOAD_TASKS_LOCK: - for tid, all_removed in cleanup_results: - task = DOWNLOAD_TASKS.get(tid) - if task is None: - finished_done.append(tid) # 锁外期间已被其他路径移除 - continue - if all_removed: - finished_done.append(tid) - else: - task['_cleanup_tries'] = int(task.get('_cleanup_tries') or 0) + 1 - if task['_cleanup_tries'] >= 8: - # 重试多次仍失败:放弃记录但大声记录日志(文件留给缓存清扫兜底) - api_service.logger.warning( - f"任务 {tid} 结果文件多次清理失败,已放弃(文件可能被占用)") - finished_done.append(tid) - else: - finished_retry.append(tid) - for tid in finished_done: - DOWNLOAD_TASKS.pop(tid, None) - if finished_done: - api_service.logger.info(f"监控线程清理过期终态任务: {finished_done}") - if finished_retry: - api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}") - # ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)---- - timeout_settle: list[str] = [] - with DOWNLOAD_TASKS_LOCK: - for tid, task in DOWNLOAD_TASKS.items(): - if (task.get('status') in ('queued', 'running', 'paused') - and now - task.get('start', 0) > 4 * 3600): - task['status'] = 'canceled' - task['msg'] = '任务超时未完成,已自动取消(费用按实际交付结算)' - task['completed_at'] = time.time() - try: - task['cancel'].set() - except Exception: - pass - # R5-14:worker 线程已死则无人再走终态结算——监控代为按账本 - # 结算退款(settle 幂等:worker 若已结算过,此处返回 0) - th = task.get('worker_thread') - if th is not None and not th.is_alive(): - timeout_settle.append(tid) - api_service.logger.warning(f"任务超时自动取消: {tid}") - # 第 97 条(P2-3):结算移到任务锁外(同 _mark_task_terminal 口径)—— - # 库忙 15s 不再随任务锁波及全站任务端点 - for tid in timeout_settle: - try: - auth_system.ledger_settle(tid, 0, 'timeout') - except Exception: - pass - # ---- 下载缓存清扫(保留期同交付口径 35 分钟)+ 限流桶过期项顺手清理(O-5)---- - _sweep_download_cache(RESULT_RETENTION_SECONDS) - _sweep_rate_buckets() - # ---- 第 91→93 条(P2-1/P2-2):账本周期兜底(原仅启动执行一次)---- - # 孤儿对账:长运行进程内 open 且账龄 > 5h(严格大于任务 4h 硬超时 + 监控 - # tick 抖动——账本行 created_at 整秒截断使账龄恒 ≥ 任务龄,旧 4h 相等口径 - # 存在抢退合法 4h+ 长任务的窗口,第 97 条 P2-4)的行每分钟周期检查退回; - # settled 行 90 天清理改为每小时低频执行,防长运行进程下表无界增长 - try: - # 第 94 条(P3-1):时间戳门槛替代 tick 取模——单轮工作段超过周期时 - # 取模落点会被整段跳过,时间戳门槛只延迟不漏跑 - if now - _LEDGER_LAST_RECOVER['ts'] > 60: - _LEDGER_LAST_RECOVER['ts'] = now - auth_system.ledger_recover_orphans(max_age_seconds=5 * 3600) - if now - _LEDGER_LAST_CLEANUP['ts'] > 24 * 3600: - _LEDGER_LAST_CLEANUP['ts'] = now - auth_system.ledger_cleanup_settled(90 * 86400) - except Exception as e: - api_service.logger.warning(f"账本周期兜底异常(下轮重试): {e}") - # ---- 第 91→94 条(P3-3):tasks/ 孤儿目录每小时低频补扫(>24h 且无记录)---- - try: - if now - _TASKS_SWEEP_LAST['ts'] > 3600: - _TASKS_SWEEP_LAST['ts'] = now - tasks_root = api_service.downloads_path / 'tasks' - if tasks_root.exists(): - for d in tasks_root.iterdir(): - try: - if (d.is_dir() and d.name not in DOWNLOAD_TASKS - and now - d.stat().st_mtime > 24 * 3600): - shutil.rmtree(str(d), ignore_errors=True) - api_service.logger.info(f"tasks 孤儿目录补扫移除: {d.name}") - except FileNotFoundError: - pass # 第 94 条(P3-3):单条竞态不中止整轮扫描 - except OSError: - pass - except Exception as e: - api_service.logger.warning(f"tasks 孤儿目录补扫异常(下轮重试): {e}") - # ---- 设备心跳离线处理 ---- - with DEVICE_LAST_SEEN_LOCK: - offline = {d: now - ts for d, ts in DEVICE_LAST_SEEN.items() if now - ts > PAUSE_AFTER_SECONDS} - if not offline: - continue - with DOWNLOAD_TASKS_LOCK: - for tid, task in DOWNLOAD_TASKS.items(): - dev = task.get('device_id') - gap = offline.get(dev) - if not gap: - continue - st = task.get('status') - if gap > CANCEL_AFTER_SECONDS and st in ('queued', 'running', 'paused'): - # 离线超 30 分钟:请求取消(C3:文件删除与退款统一交给 worker 的 - # 取消收尾/终态清理;worker 已不在时由启动对账按账本兜底) - task['status'] = 'canceled' - task['msg'] = '设备离线超过 30 分钟,任务已自动取消' - task['completed_at'] = time.time() - try: - task['cancel'].set() - except Exception: - pass - api_service.logger.info(f"设备离线超 30 分钟自动请求取消: {tid}") - elif task.get('type') == 'batch' and st in ('queued', 'running'): - # 心跳超时:先暂停,让出排队名额给下一位 - task['status'] = 'paused' - task['msg'] = '设备离线,任务暂停(重新打开页面自动恢复)' - task['pause'].set() - except Exception: - # 持续异常不能完全静默(否则监控失能无人知晓):限频记录,每 10 分钟最多一条 - _now = time.time() - _MONITOR_ERR_TS['n'] += 1 - if _now - _MONITOR_ERR_TS['ts'] > 600: - api_service.logger.warning( - f"任务监控循环异常(近 10 分钟累计 {_MONITOR_ERR_TS['n']} 次," - f"最后异常见堆栈): {traceback.format_exc()[-600:]}") - _MONITOR_ERR_TS['ts'] = _now - _MONITOR_ERR_TS['n'] = 0 - - -def _sweep_rate_buckets() -> None: - """限流桶周期清扫(O-5):把超过 10 分钟无新请求的桶移出,防止低流量长期运行下 - 缓慢累积(原实现只在"新建第 8001 个桶"时才触发清理,时机太晚)""" - now = time.time() - with _RATE_LOCK: - stale = [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > 600] - for k in stale: - _RATE_BUCKETS.pop(k, None) - - -def _touch_device(device_id: str) -> None: - """记录设备心跳时间(P2-10:设备表容量上限——device_id 是客户端任意串, - 登录用户可提交海量随机 ID 使字典无界增长,超限清最旧; - 第 98 条:入口已统一钳 64,此处兜底再钳一次防新调用点绕过)""" - device_id = str(device_id or '')[:64] - if not device_id: - return - with DEVICE_LAST_SEEN_LOCK: - if len(DEVICE_LAST_SEEN) >= 5000: - # 保留最近心跳的设备:按最后心跳时间排序,砍掉最旧的一半 - for d in sorted(DEVICE_LAST_SEEN, key=DEVICE_LAST_SEEN.get)[:2500]: - DEVICE_LAST_SEEN.pop(d, None) - DEVICE_LAST_SEEN[device_id] = time.time() - - -# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享) - - -# ============ 接口请求统计 ============ -# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享) - -# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用) -# F09:兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流, -# 防止用大写别名绕过业务限流 -COUNTED_PATHS = { - '/song', '/search', '/playlist', '/album', '/toplist', '/artist_songs', - '/comments', '/download', '/download/batch_zip', - '/podcast/parse', '/podcast/programs', '/podcast/search', '/podcast/hot', - '/podcast/download', '/podcast/download/batch_zip', - '/user/playlists', '/user/playlist', '/user/status', '/user/logout', - '/user/qr/create', '/user/qr/check', - # P1-2/P2-17/P3-6:账号探测与个人操作端点此前漏在限流名单外—— - # /account/vip 每请求最多 9 次上游探测、/user/comment 与 /user/collect 携 - # 任意 cookie 触发上游 weapi、/auth/me 可匿名无限刷,全部纳入免费 30/分桶 - '/account/vip', '/user/collect', '/user/comment', '/auth/me', - '/admin/qr/create', '/admin/qr/check', '/admin/cookie/status', - '/admin/cookie/delete', '/admin/logs', -'/admin/site/play_key/get', '/admin/site/play_key/rotate', - '/global/playlists', '/global/playlist', '/api/playlist/play', - '/song_v1', -} -_COUNTED_LOWER = {p.lower() for p in COUNTED_PATHS} - -ENDPOINT_NAMES = { - '/song': '歌曲信息', '/search': '搜索', '/playlist': '歌单详情', - '/album': '专辑详情', '/toplist': '榜单列表', '/artist_songs': '歌手歌曲', - '/comments': '评论', '/download': '音乐下载', '/download/batch_zip': '批量ZIP', - '/health': '健康检查', '/api/info': '服务信息', '/api/topen': '请求统计', - '/account/vip': '账号VIP信息', - '/podcast/parse': '播客解析', '/podcast/programs': '播客单集列表', - '/podcast/search': '播客搜索', '/podcast/hot': '热门播客', - '/podcast/download': '播客单集下载', '/podcast/download/batch_zip': '播客整台打包', - '/user/playlists': '个人歌单', '/user/playlist': '个人歌单详情', - '/user/status': '个人登录状态', '/user/logout': '个人退出', - '/user/qr/create': '个人二维码', '/user/qr/check': '个人登录轮询', - '/admin/qr/create': '管理员二维码', '/admin/qr/check': '管理员登录轮询', - '/admin/cookie/status': 'Cookie状态', '/admin/cookie/delete': 'Cookie管理', - '/admin/logs': '操作记录', - '/global/playlists': '全局账号歌单', '/global/playlist': '全局歌单详情', - '/api/playlist/play': '公开歌单播放API', -} - -# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享) - - -def _record_log(action: str, ip: str, song: str) -> None: - """记录用户操作(解析/下载)。P3-4:歌名截断(来自上游/用户输入,不设上限会撑内存与后台页)""" - REQ_LOG.append({ - 'action': action, - 'ip': ip, - 'song': str(song or '')[:100], - 'time': time.strftime('%Y-%m-%d %H:%M:%S'), - }) - if len(REQ_LOG) > 2000: - del REQ_LOG[:len(REQ_LOG) - 2000] - - -def _parse_cookie_str(raw) -> dict[str, str]: - """将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典""" - cookies: dict[str, str] = {} - if not raw: - return cookies - for part in str(raw).split(';'): - if '=' in part: - k, v = part.strip().split('=', 1) - if k: - cookies[k] = v - return cookies - - -# _GLOBAL_MUSIC_U_CACHE 已迁出至 core.state(此处 import 同名共享) - - -def _global_music_u() -> str: - """全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值)""" - now = time.time() - if _GLOBAL_MUSIC_U_CACHE['v'] is None or now - _GLOBAL_MUSIC_U_CACHE['ts'] > 300: - try: - ck = api_service._get_cookies() - _GLOBAL_MUSIC_U_CACHE['v'] = (ck.get('MUSIC_U') or '').strip() - except Exception: - _GLOBAL_MUSIC_U_CACHE['v'] = '' - _GLOBAL_MUSIC_U_CACHE['ts'] = now - return _GLOBAL_MUSIC_U_CACHE['v'] - - -def _global_music_u_cache_reset() -> None: - """第 94 条(P3-5):重新扫码/删除 Cookie 后同步清空全局 MUSIC_U 缓存—— - 否则 5 分钟窗口内冒用闸拿着旧值比对,新 Cookie 被放行、旧 Cookie 被误拦""" - _GLOBAL_MUSIC_U_CACHE['v'] = None - _GLOBAL_MUSIC_U_CACHE['ts'] = 0.0 - - -def _resolve_user_cookies(data: dict[str, Any]) -> dict[str, str]: - """解析个人账号 Cookie:仅使用浏览器传入的 user_cookie(不落服务器,杜绝跨设备访问他人账号)。 - 第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上 - 零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie - 泄露被当个人 Cookie 冒用),一律拒绝并大声记录,防止以全局账号身份执行任何 - 操作(评论区发言、收藏变更、多 IP 下载触发上游风控均属"危害全局账号")。""" - ck = _parse_cookie_str(data.get('user_cookie')) - mu = (ck.get('MUSIC_U') or '').strip() - gmu = _global_music_u() - if mu and gmu and hmac.compare_digest(mu, gmu): - try: - _cu = auth_system.current_user() - auth_system.add_log('fail', _cu['username'] if _cu else '匿名', - auth_system._client_ip(), '全局账号冒用拦截', - '浏览器提交的 MUSIC_U 与全局账号一致,已按未登录拒绝') - except Exception: - pass - api_service.logger.warning( - f"[全局账号冒用拦截] {request.path} 提交的 MUSIC_U 与全局账号一致,已按未登录处理") - return {} - return ck - - -def _build_download_base(filename_format: str, music_info: dict[str, Any]) -> str: - """按文件名格式生成下载基础名(不含扩展名)""" - name = music_info.get('name', '未知歌曲') - artist = music_info.get('artist_string', '') or '未知歌手' - - def _safe(s: str) -> str: - # P2-14:交付文件名必须剔除控制字符——歌名含 \r\n 时 send_file 的 - # Content-Disposition 会抛 ValueError,任务完成已扣费但领取必 500 - # (F79 当年只修了下载器磁盘名这条同型路径,交付名漏网) - s = re.sub(r'[\x00-\x1f\x7f]', '', s) - return ''.join(c for c in s if c not in r'<>:"/\|?*').strip() or 'unknown' - - if filename_format == 'song': - return _safe(name) - if filename_format == 'song_artist': - return _safe(f"{name} - {artist}") - return _safe(f"{artist} - {name}") # artist_song(默认) - - -# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享) - - -def _verify_admin_password(data) -> tuple[dict[str, Any], int] | None: - """校验管理员权限:仅站点管理员会话(旧版口令已废除,2026-09-15)""" - _user = auth_system.current_user() - if _user is not None and _user['role'] == 'admin': - return None - return APIResponse.error('需要管理员权限,请使用管理员账号登录', 403) - - -def _vip_name(vip_type: int) -> str: - """根据 vipType 返回会员类型名称(兼容新旧取值)""" - return { - 0: '普通用户', - 1: '音乐包', - 11: '黑胶VIP', - 100: '黑胶VIP', - 10: '黑胶SVIP', - 110: '黑胶SVIP' - }.get(vip_type, '普通用户') - - -def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: - """检测账号会员类型与可解析音质: - 优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。 - 第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行 - 8 级探测(最坏 16 分钟线程滞留),等锁超时者直接用账号接口给降级结果""" - if not _VIP_PROBE_LOCK.acquire(timeout=0.5): - info = account_info(cookies) - if info: - return info.get('vipType', 0), 'account', [] - return 0, 'probe', [] - try: - probe = detect_vip_by_probe(cookies) - levels_ok = probe.get('levels_ok', []) - info = account_info(cookies) - if info: - return info.get('vipType', 0), 'account', levels_ok - return probe.get('vip_type', 0), 'probe', levels_ok - finally: - _VIP_PROBE_LOCK.release() - - -# 服务器网易云账号昵称缓存与单飞锁已迁出至 core.state(此处 import 同名共享): -# _SERVER_NICK_CACHE(10 分钟) / _SERVER_NICK_LOCK / _VIP_PROBE_LOCK - - -def _mask_server_nickname(nick: str) -> str: - """非站点管理员可见的响应中,文本内出现的服务器账号昵称替换为站点名 - (覆盖 creator 精确字段与歌单名等含昵称的文本),管理员本人仍可见真实昵称。 - P1-4:查询失败时保留上一次已知昵称继续遮蔽(原实现清空缓存导致失败开放, - 网易接口抖动的 10 分钟窗口内真昵称原样放行)。 - R5-11:昵称成功获取后同时持久化进站点库(settings 表,私有数据、不进公开包)—— - 内存缓存丢失(进程重启)或服务器 Cookie 失效导致上游拿不到昵称时, - 回落读库里的最后已知昵称继续遮蔽,不再"从未成功获取就无限期放行"。""" - if not nick: - return nick - _cu = auth_system.current_user() - if _cu is not None and _cu['role'] == 'admin': - return nick - now = time.time() - if _SERVER_NICK_CACHE['nick'] is None or now - _SERVER_NICK_CACHE['ts'] > 600: - # 第 91 条(P3-9):单飞锁——缓存过期时并发请求只放一个去打上游,其余等结果 - if not _SERVER_NICK_LOCK.acquire(timeout=10): - server_nick = _SERVER_NICK_CACHE['nick'] - return nick.replace(server_nick, '云村小助手') if (server_nick and server_nick in nick) else nick - try: - # 双检:等锁期间别的请求可能已完成刷新 - if _SERVER_NICK_CACHE['nick'] is None or time.time() - _SERVER_NICK_CACHE['ts'] > 600: - fetched = '' - try: - fetched = (account_info(api_service._get_cookies()) or {}).get('nickname', '') - except Exception: - fetched = '' - if fetched: - # 仅在成功拿到非空昵称时覆盖(失败保留旧值,遮蔽不失效),并持久化兜底 - _SERVER_NICK_CACHE['nick'] = fetched - _SERVER_NICK_CACHE['ts'] = time.time() - try: - auth_system.set_setting('server_nickname', fetched) - except Exception: - pass - else: - # 上游拿不到(Cookie 失效/网络故障):回落站点库里的最后已知昵称继续遮蔽 - try: - persisted = auth_system.get_setting('server_nickname', '') - except Exception: - persisted = '' - if persisted: - _SERVER_NICK_CACHE['nick'] = persisted - _SERVER_NICK_CACHE['ts'] = time.time() # 库值视为可信,走正常 10 分钟重取节奏 - else: - # 库里也从未有过(全新部署且从未成功获取):缩短重试间隔,本次无遮蔽依据只能放行 - _SERVER_NICK_CACHE['ts'] = time.time() - 600 + 30 - finally: - _SERVER_NICK_LOCK.release() - server_nick = _SERVER_NICK_CACHE['nick'] - if server_nick and server_nick in nick: - return nick.replace(server_nick, '云村小助手') - return nick - - -# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、 -# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。 -# 用户主动动作(任务列表/取消)保留日志。 -_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat', - '/audio_stream', '/api/fe_error'} - - -@app.before_request -def before_request(): - """请求前处理(高频轮询/流式端点降噪:3 秒排队轮询 + 1.5 秒进度轮询会刷爆轮转日志)""" - if request.path in _QUIET_LOG_PATHS: - return - # 记录请求信息(P3-3:UA 截断并清洗换行——4KB 垃圾 UA 或伪日志行不再整段进日志; - # 第 97 条:path 同口径清洗——%0A 解码后的请求路径可伪造日志行) - ua = (request.headers.get('User-Agent', 'Unknown') or 'Unknown')[:200].replace('\n', ' ').replace('\r', '') - req_path = request.path.replace('\n', ' ').replace('\r', '') - api_service.logger.info( - f"{request.method} {req_path} - IP: {request.remote_addr} - User-Agent: {ua}" - ) - - -# ============ 音频直链多重防护(HMAC 签名 + 时效 + 代理流)============ -# 已迁出至 core.security(框架无关,密钥经 _init_audio_secret 注入): -# _AUDIO_TOKEN_TTL 常量 → core.config.AUDIO_TOKEN_TTL(仍按 RESULT_RETENTION_SECONDS+60 计) -# _is_audio_url / _make_audio_token / _verify_audio_token / _protect_audio_urls → 此处 import 同名共享 -# 注:核心层用 sign_payload 替代原 _sign_payload(仅内部调用,未在此保留旧名) - - -@app.route('/audio_stream') -@auth_system.login_required(free=True) -def audio_stream(): - """签名音频代理流:校验 HMAC 签名与时效后流式转发(支持 Range 拖动进度) - F40:下游中断/HEAD 也保证上游 Response 显式关闭;F92:解压后正文不再沿用压缩前的长度头""" - if request.method == 'HEAD': - # HEAD 无正文:不去请求上游(原实现会真的发起 GET 却不消费) - return Response(status=200, headers={'Accept-Ranges': 'bytes'}) - token = request.args.get('token', '') - url = _verify_audio_token(token) - if not url: - return APIResponse.error('音频链接已过期或无效,请重新解析', 403) - upstream_headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://music.163.com/', - } - range_header = request.headers.get('Range') - if range_header: - upstream_headers['Range'] = range_header - try: - session = getattr(audio_stream, '_session', None) - if session is None: - session = requests.Session() - session.trust_env = False # 忽略环境代理,直连音频 CDN - audio_stream._session = session - upstream = session.get(url, headers=upstream_headers, stream=True, timeout=20) - except Exception as e: - api_service.logger.warning(f'音频代理获取失败: {type(e).__name__}: {e}') - return APIResponse.error('音频获取失败', 502) - - # requests 对 gzip 等编码做了透明解压:iter_content 吐出的是解码后字节, - # 上游 Content-Length/Content-Range 是压缩前口径,直接转发会造成头体不一致(F92) - upstream_encoded = bool(upstream.headers.get('Content-Encoding')) - - def _stream_and_close(): - try: - yield from upstream.iter_content(chunk_size=16 * 1024) - finally: - # F40:下游断开/提前关闭时也释放上游连接 - try: - upstream.close() - except Exception: - pass - - resp = Response( - stream_with_context(_stream_and_close()), - status=upstream.status_code, - content_type=upstream.headers.get('Content-Type', 'audio/mpeg'), - ) - if not upstream_encoded: - for h in ('Content-Length', 'Content-Range', 'Accept-Ranges'): - if h in upstream.headers: - resp.headers[h] = upstream.headers[h] - else: - # 解压转发:长度未知,交给分块传输;不携带 Range 语义 - resp.headers.pop('Content-Length', None) - if 'Accept-Ranges' in upstream.headers: - resp.headers['Accept-Ranges'] = upstream.headers['Accept-Ranges'] - resp.headers['Cache-Control'] = 'no-store' - return resp - - -@app.after_request -def after_request(response: Response) -> Response: - """请求后处理 - 设置CORS头""" - response.headers.add('Access-Control-Allow-Origin', config.cors_origins) - response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') - response.headers.add('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') - response.headers.add('Access-Control-Max-Age', '3600') - - # P3-16:零成本安全响应头(nosniff 防 MIME 嗅探、DENY 防点击劫持、 - # Referrer-Policy 防跨站 Referer 泄漏);O-3:429 统一带 Retry-After 供客户端退避 - response.headers.setdefault('X-Content-Type-Options', 'nosniff') - response.headers.setdefault('X-Frame-Options', 'DENY') - response.headers.setdefault('Referrer-Policy', 'same-origin') - # 第 91 条(P3):CSP 基线(站点全部脚本/样式内联,需 unsafe-inline;音频同源 + - # blob 预览;图片允许 data:/http:/https:——网易封面直链为 http 协议)—— - # 防注入脚本外联的纵深防御 - response.headers.setdefault( - 'Content-Security-Policy', - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " - "img-src 'self' data: http: https:; media-src 'self' blob:; connect-src 'self'; " - "font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'") - # 第 91 条(P3):抹掉 Werkzeug/Python 精确版本号(fuzz 实测泄露) - response.headers['Server'] = 'NeteaseUrl' - if response.status_code == 429: - response.headers.setdefault('Retry-After', '30') - - # 音频直链保护:JSON 响应中的网易云音频地址替换为签名代理地址 - if response.is_json and response.status_code == 200: - try: - data = response.get_json(silent=True) - if isinstance(data, (dict, list)) and _protect_audio_urls(data): - response.data = json.dumps(data, ensure_ascii=False) - except Exception: - pass - - # 记录响应信息(高频轮询/流式端点降噪,与 before_request 同口径) - if request.path not in _QUIET_LOG_PATHS: - api_service.logger.info(f"响应状态: {response.status_code}") - return response - - -@app.errorhandler(400) -def handle_bad_request(e): - """处理400错误""" - return APIResponse.error("请求参数错误", 400) - - -@app.errorhandler(404) -def handle_not_found(e): - """处理404错误""" - return APIResponse.error("请求的资源不存在", 404) - - -@app.errorhandler(405) -def handle_method_not_allowed(e): - """处理405错误(P3-28:fetch 消费方拿到统一 JSON,而非 werkzeug 默认 HTML)""" - return APIResponse.error("不支持的请求方法", 405) - - -@app.errorhandler(413) -def handle_payload_too_large(e): - """处理413错误(P2-16:请求体超过上限时给出可读提示而非 werkzeug HTML)""" - return APIResponse.error("请求体过大,请减少提交内容后重试", 413) - - -@app.errorhandler(500) -def handle_internal_error(e): - """处理500错误""" - api_service.logger.error(f"服务器内部错误: {e}") - return APIResponse.error("服务器内部错误", 500) - - -@app.route('/') -def index() -> str: - """首页路由""" - resp = make_response(render_template('index.html')) - # 禁止缓存首页,确保前端更新即时生效(避免旧 HTML 缓存引发的功能异常) - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - return resp - - -@app.route('/health', methods=['GET']) -def health_check(): - """健康检查API""" - try: - # 检查 Cookie 状态:直接读 cookie.txt - cookies = load_cookies() - cookie_status = 'valid' if cookies else 'invalid' - - health_info = { - 'service': 'running', - 'timestamp': int(time.time()) if 'time' in sys.modules else None, - 'cookie_status': cookie_status, - 'cookie_count': len(cookies), - 'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径 - 'version': APP_VERSION - } - - return APIResponse.success(health_info, "API服务运行正常") - - except Exception as e: - api_service.logger.error(f"健康检查失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/song', methods=['GET', 'POST']) -@app.route('/Song_V1', methods=['GET', 'POST']) # 向后兼容 -@auth_system.login_required('歌曲解析', free=True) -def get_song_info(): - """获取歌曲信息API""" - try: - # 获取请求参数 - data = api_service._safe_get_request_data() - song_ids = data.get('ids') or data.get('id') - url = data.get('url') - level = data.get('level', 'lossless') - info_type = data.get('type', 'url') - - # 参数验证 - if not song_ids and not url: - return APIResponse.error("必须提供 'ids'、'id' 或 'url' 参数") - - # 提取音乐ID - music_id = api_service._extract_music_id(song_ids or url) - # P2-2:ID 白名单(与 /album、/artist_songs、/comments 同口径—— - # 垃圾输入不再透传上游并把上游报错原文回显给公网) - if not str(music_id).strip().isdigit(): - return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接") - - # 验证音质参数 - valid_levels = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] - if level not in valid_levels: - return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_levels)}") - - # 验证类型参数 - valid_types = ['url', 'name', 'lyric', 'json'] - if info_type not in valid_types: - return APIResponse.error(f"无效的类型参数,支持: {', '.join(valid_types)}") - - # 解析账号选择:user=游客个人账号(个人歌单登录,Cookie 由浏览器传入),默认 admin=全局管理员账号 - account = data.get('account', 'admin') - if account == 'user': - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) - else: - cookies = api_service._get_cookies() - - # 根据类型获取不同信息 - if info_type == 'url': - result = url_v1(music_id, level, cookies) - if result and result.get('data') and len(result['data']) > 0: - # 第 99 条:无版权/VIP 歌上游返回 data:[null]——元素为 null 时走下方 - # 404 分支而非 AttributeError 500(下载器同族已修,解析路由补齐) - song_data = result['data'][0] or {} - if not song_data: - return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404) - response_data = { - 'id': song_data.get('id'), - 'url': song_data.get('url'), - 'level': song_data.get('level'), - 'quality_name': api_service._get_quality_display_name(song_data.get('level', level)), - 'size': song_data.get('size'), - 'size_formatted': api_service._format_file_size(song_data.get('size', 0)), - 'type': song_data.get('type'), - 'bitrate': song_data.get('br') - } - return APIResponse.success(response_data, "获取歌曲URL成功") - else: - return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404) - - elif info_type == 'name': - result = name_v1(music_id) - return APIResponse.success(result, "获取歌曲信息成功") - - elif info_type == 'lyric': - result = lyric_v1(music_id, cookies) - return APIResponse.success(result, "获取歌词成功") - - elif info_type == 'json': - # 获取完整的歌曲信息(用于前端解析) - song_info = name_v1(music_id) - url_info = url_v1(music_id, level, cookies) - try: - lyric_info = lyric_v1(music_id, cookies) - except Exception: - lyric_info = None # 歌词接口抖动不阻断解析 - - if not song_info or 'songs' not in song_info or not song_info['songs']: - return APIResponse.error("未找到歌曲信息", 404) - - song_data = song_info['songs'][0] - - # S1/F94:可选字段为 null(而非缺失)时同样容错,不让有效歌曲解析失败 - _al = song_data.get('al') or {} - _ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or [])))) - - # 构建前端期望的响应格式 - response_data = { - 'id': music_id, - 'name': song_data.get('name') or '', - 'ar_name': _ar_names, - 'al_name': _al.get('name') or '', - 'pic': _al.get('picUrl') or '', - 'level': level, - 'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '', - 'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '', - 'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or '' - } - - # 添加URL和大小信息 - if url_info and url_info.get('data') and len(url_info['data']) > 0: - url_data = url_info['data'][0] - response_data.update({ - 'url': url_data.get('url', ''), - 'size': api_service._format_file_size(url_data.get('size', 0)), - 'level': url_data.get('level', level) - }) - else: - response_data.update({ - 'url': '', - 'size': '获取失败' - }) - - # 记录用户解析操作(IP + 歌曲名) - try: - _record_log('解析', auth_system._client_ip(), response_data.get('name') or str(music_id)) - except Exception: - pass - - return APIResponse.success(response_data, "获取歌曲信息成功") - - except APIException as e: - api_service.logger.error(f"API调用失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取歌曲信息异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/search', methods=['GET', 'POST']) -@app.route('/Search', methods=['GET', 'POST']) # 向后兼容 -@auth_system.login_required('搜索', free=True) -def search_music_api(): - """搜索音乐API""" - try: - # 获取请求参数 - data = api_service._safe_get_request_data() - keyword = data.get('keyword') or data.get('keywords') or data.get('q') - limit = max(1, _safe_int(data.get('limit'), 30)) - offset = max(0, _safe_int(data.get('offset'), 0)) - search_type = data.get('type', '1') # 1-歌曲, 10-专辑, 100-歌手, 1000-歌单 - - # 参数验证 - validation_error = api_service._validate_request_params({'keyword': keyword}) - if validation_error: - return validation_error - - # 限制搜索数量 - if limit > 100: - limit = 100 - - cookies = api_service._get_cookies() - # F45:offset 透传给上游,翻页不再永远从第一页开始 - result = search_music(keyword, cookies, limit, int(search_type), offset=offset) - - # 向后兼容:歌曲搜索结果补充 artist_string 字段;歌单结果遮蔽服务器账号昵称 - if result: - for song in result: - if song.get('type', 'song') == 'song' and 'artists' in song: - song['artist_string'] = song['artists'] - if song.get('type') == 'playlist': - if song.get('creator'): - song['creator'] = _mask_server_nickname(song['creator']) - if song.get('name'): - song['name'] = _mask_server_nickname(song['name']) - - return APIResponse.success(result, "搜索完成") - - except ValueError as e: - return APIResponse.error(f"参数格式错误: {e!s}") - except Exception as e: - api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/playlist', methods=['GET', 'POST']) -@app.route('/Playlist', methods=['GET', 'POST']) # 向后兼容 -@auth_system.login_required('歌单解析', free=True) -def get_playlist(): - """获取歌单详情API""" - try: - # 获取请求参数 - data = api_service._safe_get_request_data() - playlist_id = data.get('id') - - # 参数验证 - validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) - if validation_error: - return validation_error - - # P2-2:ID 白名单(同 /album 口径——原实现垃圾 ID 透传上游并把上游报错回显) - playlist_id = api_service._extract_music_id(playlist_id) - if not str(playlist_id).strip().isdigit(): - return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") - - cookies = api_service._get_cookies() - result = playlist_detail(playlist_id, cookies) - # 非管理员遮蔽服务器账号昵称(creator / 歌单名等文本字段) - if isinstance(result, dict): - if result.get('creator'): - result['creator'] = _mask_server_nickname(result['creator']) - if result.get('name'): - result['name'] = _mask_server_nickname(result['name']) - - # 适配前端期望的响应格式 - response_data = { - 'status': 'success', - 'playlist': result - } - - return APIResponse.success(response_data, "获取歌单详情成功") - - except Exception as e: - api_service.logger.error(f"获取歌单异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/album', methods=['GET', 'POST']) -@app.route('/Album', methods=['GET', 'POST']) # 向后兼容 -@auth_system.login_required('专辑解析', free=True) -def get_album(): - """获取专辑详情API""" - try: - # 获取请求参数 - data = api_service._safe_get_request_data() - album_id = data.get('id') - - # 参数验证 - validation_error = api_service._validate_request_params({'album_id': album_id}) - if validation_error: - return validation_error - - # ID 白名单:必须能归一化为纯数字(原实现直接拼进上游 URL 路径, - # 恶意输入可构造路径穿越让服务器携 Cookie 请求任意 GET 接口) - album_id = api_service._extract_music_id(album_id) - if not str(album_id).strip().isdigit(): - return APIResponse.error("无效的专辑ID,请输入数字ID或分享链接") - - cookies = api_service._get_cookies() - result = album_detail(album_id, cookies) - - # 适配前端期望的响应格式 - response_data = { - 'status': 200, - 'album': result - } - - return APIResponse.success(response_data, "获取专辑详情成功") - - except Exception as e: - api_service.logger.error(f"获取专辑异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/toplist', methods=['GET', 'POST']) -@auth_system.login_required('榜单获取', free=True) -def get_toplist_api(): - """获取网易云官方榜单列表API""" - try: - result = toplist_list() - return APIResponse.success(result, "获取榜单列表成功") - except APIException as e: - api_service.logger.error(f"获取榜单失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取榜单异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/artist_songs', methods=['GET', 'POST']) -@auth_system.login_required('歌手解析', free=True) -def get_artist_songs_api(): - """获取歌手信息和热门歌曲API""" - try: - data = api_service._safe_get_request_data() - artist_id = data.get('id') or data.get('artist_id') - - validation_error = api_service._validate_request_params({'artist_id': artist_id}) - if validation_error: - return validation_error - - artist_id = api_service._extract_music_id(artist_id) - # ID 白名单:归一化后必须为纯数字(防路径拼接注入面,同 /album) - if not str(artist_id).strip().isdigit(): - return APIResponse.error("无效的歌手ID,请输入数字ID或分享链接") - result = artist_songs(artist_id) - return APIResponse.success(result, "获取歌手歌曲成功") - except APIException as e: - api_service.logger.error(f"获取歌手歌曲失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取歌手歌曲异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -# ============ 播客(电台 / 单集声音)============ - -@app.route('/podcast/parse', methods=['GET', 'POST']) -@auth_system.login_required('播客解析', free=True) -def podcast_parse_api(): - """播客解析:电台链接/ID → 电台详情+单集列表;单集链接/ID → 单集详情+音频直链""" - try: - data = api_service._safe_get_request_data() - raw = data.get('id') or data.get('url') - validation_error = api_service._validate_request_params({'id': raw}) - if validation_error: - return validation_error - page_size = max(1, min(_safe_int(data.get('page_size'), 30), 100)) - quality = str(data.get('quality') or 'auto') - - cookies = api_service._get_cookies() - result = podcast_api.parse_podcast_ref(raw, cookies, page_size=page_size) - - # 单集解析:附音频直链(音质自动降级) - if result['kind'] == 'program': - program = result['program'] - song_id = program.get('main_song_id') - url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) if song_id else None - if url_info: - program['audio'] = { - 'url': url_info['url'], - 'level': url_info['level'], - 'level_name': api_service._get_quality_display_name(url_info['level']), - 'type': url_info['type'], - 'size': url_info['size'], - 'size_formatted': api_service._format_file_size(url_info['size']), - 'br': url_info['br'], - } - else: - program['audio'] = None - try: - _record_log('解析', auth_system._client_ip(), program.get('name') or str(raw)) - except Exception: - pass - else: - try: - _record_log('解析', auth_system._client_ip(), result['radio'].get('name') or str(raw)) - except Exception: - pass - - return APIResponse.success(result, "播客解析成功") - except APIException as e: - api_service.logger.warning(f"播客解析失败: {e}") - return APIResponse.error(str(e), 404) - except Exception as e: - api_service.logger.error(f"播客解析异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/podcast/programs', methods=['GET', 'POST']) -@auth_system.login_required('播客单集列表', free=True) -def podcast_programs_api(): - """播客单集列表(分页加载)""" - try: - data = api_service._safe_get_request_data() - radio_id = data.get('radio_id') or data.get('id') - validation_error = api_service._validate_request_params({'radio_id': radio_id}) - if validation_error: - return validation_error - limit = max(1, min(_safe_int(data.get('limit'), 30), 100)) - offset = max(0, _safe_int(data.get('offset'), 0)) - asc = str(data.get('asc', 'true')).lower() in ('1', 'true', 'yes') - - cookies = api_service._get_cookies() - radio_id = api_service._extract_music_id(str(radio_id)) - # R5-P3:数字白名单——垃圾 radio_id 会在 podcast_api 的 int() 处抛 ValueError 变 500 - if not str(radio_id).isdigit(): - return APIResponse.error("无效的电台 ID") - result = podcast_api.get_radio_programs(radio_id, cookies, limit=limit, offset=offset, asc=asc) - return APIResponse.success(result, "获取单集列表成功") - except APIException as e: - return APIResponse.error(str(e), 404) - except Exception as e: - api_service.logger.error(f"获取播客单集列表异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/podcast/search', methods=['GET', 'POST']) -@auth_system.login_required('播客搜索', free=True) -def podcast_search_api(): - """播客搜索(电台 / 单集声音)""" - try: - data = api_service._safe_get_request_data() - keyword = data.get('keyword') or data.get('q') - validation_error = api_service._validate_request_params({'keyword': keyword}) - if validation_error: - return validation_error - limit = max(1, min(_safe_int(data.get('limit'), 20), 50)) - offset = max(0, _safe_int(data.get('offset'), 0)) - search_type = data.get('type', 'all') - if search_type not in ('all', 'radio', 'voice'): - search_type = 'all' - - cookies = api_service._get_cookies() - result = podcast_api.search_podcast(keyword, cookies, limit=limit, - offset=offset, search_type=search_type) - return APIResponse.success(result, "播客搜索完成") - except APIException as e: - return APIResponse.error(str(e), 404) - except Exception as e: - api_service.logger.error(f"播客搜索异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/podcast/hot', methods=['GET', 'POST']) -@auth_system.login_required(free=True) -def podcast_hot_api(): - """热门播客 + 播客分类列表""" - try: - data = api_service._safe_get_request_data() - limit = max(1, min(_safe_int(data.get('limit'), 30), 100)) - offset = max(0, _safe_int(data.get('offset'), 0)) - cate_id = data.get('cate_id') or None - - cookies = api_service._get_cookies() - radios = podcast_api.get_hot_radios(cookies, limit=limit, offset=offset, cate_id=cate_id) - try: - categories = podcast_api.get_categories() - except APIException: - categories = [] - # S2:满页视为可能还有更多,供前端"加载更多"按钮显隐(空页/短页则没有更多) - return APIResponse.success({ - 'radios': radios, - 'categories': categories, - 'has_more': len(radios) >= limit, - }, "获取热门播客成功") - except APIException as e: - return APIResponse.error(str(e), 404) - except Exception as e: - api_service.logger.error(f"获取热门播客异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/podcast/download', methods=['POST']) -@auth_system.login_required('播客单集下载') -def podcast_download_api(): - """播客单集下载:后台任务下载音频到服务器,完成后经 /download/result 交付 - - 参数: id(单集ID/链接), quality(auto/exhigh/standard), zip(打包音频+封面+信息), task_id, device_id - """ - try: - data = api_service._safe_get_request_data() - raw = data.get('id') - validation_error = api_service._validate_request_params({'id': raw}) - if validation_error: - return validation_error - quality = str(data.get('quality') or 'auto') - if quality not in ('auto', 'standard', 'exhigh', 'lossless', 'hires'): - quality = 'auto' - need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') - - # 单集ID提取(支持 program?id= / sound?id= / 短链 / 纯数字) - ref = podcast_api.extract_podcast_ref(raw) - if ref['kind'] == 'radio': - return APIResponse.error("这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」") - - # F64:个人账号选择贯通播客入口。第 91 条(P3-2):删除 user_cookie.txt - # 服务器回退——旧版部署残留文件可被任意登录用户复用(跨设备访问他人账号), - # 与 /song、/download 的"仅浏览器传入"口径统一 - account = data.get('account', 'admin') - if account == 'user': - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) - else: - cookies = api_service._get_cookies() - task_id_hint = str(data.get('task_id') or '').strip() - device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) - _touch_device(device_id) - _cur_user = auth_system.current_user() - _reserved = 0 if (not _cur_user or _cur_user['role'] == 'admin' - or int(_cur_user['duration_seconds'] or 0) == -1) else 1 - task = { - 'type': 'single', - 'billing_user_id': int(_cur_user['id']) if _cur_user else 0, - 'billing_reserved': _reserved, - 'total': 1, - 'done': 0, - 'failed': 0, - 'status': 'queued', - 'start': time.time(), - 'msg': '排队中...', - 'cancel': threading.Event(), - 'pause': threading.Event(), - 'device_id': device_id, - 'result': None, - 'params': { - 'program_id': ref['id'], - 'quality': quality, - 'need_zip': need_zip, - 'cookies': cookies, - 'remote_ip': auth_system._client_ip(), - }, - } - reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint) - if reg_err: - return APIResponse.error(reg_err, 429) - task_id = task['task_id'] - start_err = _start_task_thread(task_id, _podcast_download_worker, (task_id,)) - if start_err: - return APIResponse.error(start_err, 500) - return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "播客单集下载任务已提交") - except APIException as e: - return APIResponse.error(str(e), 404) - except Exception as e: - api_service.logger.error(f"播客单集下载提交异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -def _podcast_billing_count() -> int: - """播客打包计费:指定单集列表按数量扣;整台电台提交时先扣 1 次, - worker 解析完实际单集数后补扣差额(一首单集 = 1 次) - F22:与路由完全同口径——ids 模式先做与路由一致的 limit 截断,避免多扣""" - try: - data = api_service._safe_get_request_data() - if data.get('radio_id'): - return 1 - try: - limit = int(data.get('limit', 200) or 200) - except (TypeError, ValueError): - limit = 200 - limit = max(1, min(limit, 500)) - raw = data.get('ids') - if isinstance(raw, str): - raw_list = raw.split(',') - else: - raw_list = list(raw or []) - # 与路由完全同口径(提取→去重):URL 形式与纯数字形式的同一单集不再重复计费 - ids, seen = [], set() - for x in raw_list: - try: - mid = api_service._extract_music_id(x) - except Exception: - mid = str(x).strip() - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - return max(1, min(len(ids), limit)) - except Exception: - return 1 - - -@app.route('/podcast/download/batch_zip', methods=['POST']) -@auth_system.login_required('播客打包', cost=_podcast_billing_count) -def podcast_batch_zip_api(): - """整台播客打包:后台任务逐集下载并入 ZIP,完成后经 /download/batch_zip/result 交付 - - 参数: radio_id(电台ID/链接) 或 ids(单集ID列表,逗号分隔), limit(最多集数), quality, task_id, device_id - """ - try: - data = api_service._safe_get_request_data() - raw_radio = data.get('radio_id') - raw_ids = data.get('ids') - if not raw_radio and not raw_ids: - return APIResponse.error("缺少 radio_id 或 ids 参数") - try: - limit = int(data.get('limit', 200) or 200) - except (TypeError, ValueError): - limit = 200 - limit = max(1, min(limit, 500)) - quality = str(data.get('quality') or 'auto') - if quality not in ('auto', 'standard', 'exhigh', 'lossless', 'hires'): - quality = 'auto' - - cookies = api_service._get_cookies() - - if raw_radio: - ref = podcast_api.extract_podcast_ref(raw_radio) - if ref['kind'] == 'program': - return APIResponse.error("这是单集链接,请使用「下载单集」或直接输入电台链接/ID") - radio_id = ref['id'] - radio_name = '' - try: - radio_name = podcast_api.get_radio_detail(radio_id, cookies).get('name', '') - except APIException: - pass - else: - # 直接传单集ID列表(从解析结果勾选);F46:数组元素统一 str() 清洗 - if isinstance(raw_ids, str): - id_list = [x.strip() for x in raw_ids.split(',') if x.strip()] - else: - id_list = [str(x).strip() for x in (raw_ids or []) if str(x).strip()] - # 与音乐批量同口径(S10):提取 ID 后去重,重复单集不多扣也不重复下载 - _seen = set() - deduped = [] - for x in id_list: - mid = api_service._extract_music_id(x) - if mid and mid not in _seen: - _seen.add(mid) - deduped.append(mid) - id_list = deduped[:limit] - if not id_list: - return APIResponse.error("缺少 ids 参数") - radio_id = None - radio_name = str(data.get('name') or '播客单集') - - task_id_hint = str(data.get('task_id') or '').strip() - device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) - _touch_device(device_id) - _cu = auth_system.current_user() - _billed = _podcast_billing_count() - _unlimited = (_cu is None or _cu['role'] == 'admin' - or int(_cu['duration_seconds'] or 0) == -1) - _reserved = 0 if _unlimited else _billed - task = { - 'type': 'batch', - 'billing_user_id': int(_cu['id']) if _cu else 0, - 'billing_reserved': _reserved, - 'total': 0, # worker 解析完单集列表后回填 - 'done': 0, - 'failed': 0, - 'status': 'queued', - 'start': time.time(), - 'msg': '排队中...', - 'cancel': threading.Event(), - 'pause': threading.Event(), - 'device_id': device_id, - 'result': None, - 'params': { - 'radio_id': radio_id, - 'ids': None if radio_id else id_list, - 'limit': limit, - 'quality': quality, - 'radio_name': radio_name, - 'cookies': cookies, - 'remote_ip': auth_system._client_ip(), - # 整台电台模式:worker 解析完实际单集数后按差额补扣(一首 = 1 次) - 'billing_uid': (_cu['id'] if _cu is not None else None), - 'billed': _billed, - }, - } - reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint) - if reg_err: - return APIResponse.error(reg_err, 429) - task_id = task['task_id'] - start_err = _start_task_thread(task_id, _podcast_zip_worker, (task_id,)) - if start_err: - return APIResponse.error(start_err, 500) - return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "播客打包任务已提交") - except APIException as e: - return APIResponse.error(str(e), 404) - except Exception as e: - api_service.logger.error(f"播客打包提交异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -def _podcast_safe_name(s: str, fallback: str = 'unknown') -> str: - """文件名安全化(去除非法字符)""" - s = ''.join(c for c in str(s or '') if c not in r'<>:"/\|?*' and ord(c) >= 32).strip() - return s or fallback - - -def _podcast_stream_download(url: str, target: Path, total_size: int, - cancel_ev: threading.Event, - task_id: str = '', progress_label: str = '') -> int: - """流式下载播客音频到 target(先写 .part 再改名),返回实际字节数 - - 自动加入全局下载信号量排队;每 0.5s 更新一次任务进度消息。 - """ - import requests as _requests - done = 0 - last_update = 0.0 - part = target.with_name(target.name + '.part') - # B2:等待全局下载名额期间可被取消(与音乐批量 BATCH_ZIP_SEM 同语义) - sem = api_service.downloader._download_semaphore - if not sem.acquire_cancellable(cancel_ev): - raise DownloadCanceled("任务已取消") - try: - with _requests.get(url, stream=True, timeout=(15, 60), headers={ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36', - 'Referer': 'https://music.163.com/', - }) as r: - r.raise_for_status() - total = total_size or int(r.headers.get('Content-Length') or 0) - with open(part, 'wb') as f: - for chunk in r.iter_content(chunk_size=256 * 1024): - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - if chunk: - f.write(chunk) - done += len(chunk) - now = time.time() - if now - last_update > 0.5: - last_update = now - if total: - pct = min(99, int(done / total * 100)) - msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}" - else: - msg = f"{progress_label} 已下载 {api_service._format_file_size(done)}" - if task_id: - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t and t['status'] == 'running': - t['msg'] = msg - # 单集任务的字节级进度(批量任务保持按集数口径) - if t.get('type') == 'single' and total: - t['live_percent'] = min(99.0, float(pct)) - finally: - sem.release() - if part.exists(): - part.replace(target) - # 完整性校验:CDN 提前断流时 iter_content 会"正常"结束,文件静默截断 - # (F29 同口径:API 报告的期望大小时精确相等——95% 容差会交付截断的坏文件还扣次数; - # 期望未知时退化为非空校验,Content-Length 兜底值可能是压缩口径不做精确比对) - if total_size and done != total_size: - try: - os.remove(str(target)) - except OSError: - pass - raise DownloadException(f"下载不完整({done}/{total_size} 字节),请重试") - if not total_size and done <= 0: - try: - os.remove(str(target)) - except OSError: - pass - raise DownloadException("下载内容为空,请重试") - return done - - -def _podcast_resolve_audio(program: dict[str, Any], quality: str, - cookies: dict[str, str]) -> dict[str, Any]: - """解析单集音频直链,失败抛 DownloadException""" - song_id = program.get('main_song_id') - if not song_id: - raise DownloadException("该单集缺少音频信息(mainSong)") - url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) - if not url_info or not url_info.get('url'): - raise DownloadException("未获取到音频直链(单集可能已下架或受版权限制)") - return url_info - - -@app.route('/comments', methods=['GET', 'POST']) -@auth_system.login_required('评论获取', free=True) -def get_song_comments_api(): - """获取歌曲评论API""" - try: - data = api_service._safe_get_request_data() - song_id = data.get('id') or data.get('song_id') - limit = max(1, min(_safe_int(data.get('limit'), 20), 100)) - offset = max(0, _safe_int(data.get('offset'), 0)) - - validation_error = api_service._validate_request_params({'song_id': song_id}) - if validation_error: - return validation_error - - if limit > 100: - limit = 100 - - song_id = api_service._extract_music_id(song_id) - # ID 白名单:归一化后必须为纯数字(防 URL 路径拼接注入面,同 /album) - if not str(song_id).strip().isdigit(): - return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接") - result = song_comments(song_id, limit, offset) - return APIResponse.success(result, "获取评论成功") - except ValueError as e: - return APIResponse.error(f"参数格式错误: {e!s}") - except APIException as e: - api_service.logger.error(f"获取评论失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取评论异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -# ============ 管理员设置(二维码登录 / Cookie 管理) ============ - -def _admin_client() -> QRLoginClient: - """创建二维码登录客户端(基于 cookie.txt)""" - return QRLoginClient() - - -@app.route('/admin/qr/create', methods=['POST']) -def admin_qr_create(): - """生成登录二维码(返回 SVG)""" - try: - data = api_service._safe_get_request_data() - auth_error = _verify_admin_password(data) - if auth_error: - return auth_error - - client = _admin_client() - unikey = run_sync(client.qr_manager.generate_qr_key()) - if not unikey: - return APIResponse.error("生成二维码失败", 500) - - import qrcode - from qrcode.image.svg import SvgPathImage - qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M) - qr.add_data(f'https://music.163.com/login?codekey={unikey}') - qr.make(fit=True) - svg_bytes = qr.make_image(image_factory=SvgPathImage).to_string() - if isinstance(svg_bytes, bytes): - svg_bytes = svg_bytes.decode('utf-8') - - return APIResponse.success({ - 'unikey': unikey, - 'qr_svg': svg_bytes, - 'expires_in': 180 - }, "二维码生成成功") - except Exception as e: - api_service.logger.error(f"生成二维码异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/admin/qr/check', methods=['POST']) -def admin_qr_check(): - """轮询二维码登录状态,成功时自动保存 Cookie""" - try: - data = api_service._safe_get_request_data() - auth_error = _verify_admin_password(data) - if auth_error: - return auth_error - - unikey = data.get('unikey') - if not unikey: - return APIResponse.error("缺少 unikey 参数") - - client = _admin_client() - code, cookie_dict = run_sync(client.qr_manager.check_qr_login(unikey)) - - if code == 803: - # 登录成功,自动保存 Cookie - musu_u = cookie_dict.get('MUSIC_U', '') - if not musu_u: - return APIResponse.error("登录成功但未获取到 Cookie", 500) - # 第 94 条(P1-2):__csrf 一并入库——weapi 写接口(发评论等)的 - # csrf_token 取自 Cookie 的 __csrf,缺失则写接口恒被上游拒绝 - cookie = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;" - if cookie_dict.get('__csrf'): - cookie += f"__csrf={cookie_dict['__csrf']};" - if not client.save_cookie(cookie): - return APIResponse.error("登录成功但 Cookie 保存失败", 500) - _vip_cache_reset() # 账号已变更,VIP 探测缓存立即失效 - _global_music_u_cache_reset() # 第 92 条闸的全局 MUSIC_U 缓存同步刷新 - api_service.logger.info("二维码登录成功,Cookie 已自动保存") - return APIResponse.success( - {'status': 'success', 'cookie_info': client._get_cookie_info()}, - "登录成功,Cookie 已保存") - elif code == 801: - return APIResponse.success({'status': 'waiting'}, "等待扫码") - elif code == 802: - return APIResponse.success({'status': 'scanned'}, "已扫码,请在手机上确认登录") - elif code == 800: - return APIResponse.success({'status': 'expired'}, "二维码已过期") - else: - return APIResponse.success({'status': 'error', 'code': code}, f"登录状态异常({code})") - except Exception as e: - api_service.logger.error(f"检查登录状态异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/admin/cookie/status', methods=['POST']) -def admin_cookie_status(): - """获取当前 Cookie 状态""" - try: - data = api_service._safe_get_request_data() - auth_error = _verify_admin_password(data) - if auth_error: - return auth_error - - client = _admin_client() - return APIResponse.success(client._get_cookie_info(), "获取 Cookie 状态成功") - except Exception as e: - api_service.logger.error(f"获取 Cookie 状态异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/admin/cookie/delete', methods=['POST']) -def admin_cookie_delete(): - """删除(清除)Cookie,备份后删除,可重新扫码登录""" - try: - data = api_service._safe_get_request_data() - auth_error = _verify_admin_password(data) - if auth_error: - return auth_error - - client = _admin_client() - # F60:检查登出返回值,失败如实报告(原实现无论成败都回"已清除") - ok = client.logout() - if not ok: - info = client._get_cookie_info() - return APIResponse.error( - f"Cookie 清除失败(文件仍存在:{info.get('file_exists')}," - f"可能被占用或为挂载点,请手动删除)", 500) - _vip_cache_reset() # Cookie 已删除,VIP 探测缓存立即失效 - _global_music_u_cache_reset() # 第 92 条闸的全局 MUSIC_U 缓存同步刷新 - api_service.logger.info("管理员已清除 Cookie") - return APIResponse.success(client._get_cookie_info(), "Cookie 已清除") - except Exception as e: - api_service.logger.error(f"清除 Cookie 异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/admin/logs', methods=['POST']) -def admin_logs(): - """用户 IP 解析/下载记录(分页)""" - try: - data = api_service._safe_get_request_data() - auth_error = _verify_admin_password(data) - if auth_error: - return auth_error - - page = max(1, _safe_int(data.get('page'), 1)) - page_size = max(1, min(50, _safe_int(data.get('page_size'), 10))) - - logs = list(reversed(REQ_LOG)) # 最新在前 - total = len(logs) - start = (page - 1) * page_size - items = logs[start:start + page_size] - - return APIResponse.success({ - 'total': total, - 'page': page, - 'page_size': page_size, - 'pages': max(1, (total + page_size - 1) // page_size), - 'items': items, - }, "获取操作记录成功") - except ValueError: - return APIResponse.error("参数格式错误") - except Exception as e: - api_service.logger.error(f"获取操作记录异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -# ============ 个人歌单(游客扫码登录自己的账号) ============ - -def _user_client() -> QRLoginClient: - """创建个人用户二维码登录客户端(第 91 条后不再落盘 user_cookie.txt, - USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)""" - return QRLoginClient(USER_COOKIE_FILE) - - -@app.route('/user/qr/create', methods=['POST']) -def user_qr_create(): - """生成个人登录二维码(返回 SVG),游客无需密码""" - try: - client = _user_client() - unikey = run_sync(client.qr_manager.generate_qr_key()) - if not unikey: - return APIResponse.error("生成二维码失败", 500) - - import qrcode - from qrcode.image.svg import SvgPathImage - qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M) - qr.add_data(f'https://music.163.com/login?codekey={unikey}') - qr.make(fit=True) - svg_bytes = qr.make_image(image_factory=SvgPathImage).to_string() - if isinstance(svg_bytes, bytes): - svg_bytes = svg_bytes.decode('utf-8') - - return APIResponse.success({ - 'unikey': unikey, - 'qr_svg': svg_bytes, - 'expires_in': 180 - }, "二维码生成成功") - except Exception as e: - api_service.logger.error(f"生成个人二维码异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/qr/check', methods=['POST']) -def user_qr_check(): - """轮询个人登录状态(第 91 条后不写服务器文件;站点账号登录时同步一份 - 到站点库供管理后台查看,第 98 条注释口径与 UI 对齐)""" - try: - data = api_service._safe_get_request_data() - unikey = data.get('unikey') - if not unikey: - return APIResponse.error("缺少 unikey 参数") - - client = _user_client() - code, cookie_dict = run_sync(client.qr_manager.check_qr_login(unikey)) - - if code == 803: - musu_u = cookie_dict.get('MUSIC_U', '') - if not musu_u: - return APIResponse.error("登录成功但未获取到 Cookie", 500) - # 个人登录信息仅返回给浏览器(存 localStorage),不写入服务器文件。 - # 第 94 条(P1-2):__csrf 一并返回(weapi 写接口的 csrf_token 来源) - cookie = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;" - if cookie_dict.get('__csrf'): - cookie += f"__csrf={cookie_dict['__csrf']};" - # 站点登录用户:同步记录网易云 Cookie 供后台管理查看 - try: - _site_user = auth_system.current_user() - if _site_user is not None: - auth_system.set_user_netease_cookie(_site_user['username'], cookie) - except Exception: - pass - api_service.logger.info("个人账号扫码登录成功(Cookie 已返回浏览器本地保存)") - return APIResponse.success( - {'status': 'success', 'cookie': cookie}, - "登录成功,Cookie 已保存在本机浏览器") - elif code == 801: - return APIResponse.success({'status': 'waiting'}, "等待扫码") - elif code == 802: - return APIResponse.success({'status': 'scanned'}, "已扫码,请在手机上确认登录") - elif code == 800: - return APIResponse.success({'status': 'expired'}, "二维码已过期") - else: - return APIResponse.success({'status': 'error', 'code': code}, f"登录状态异常({code})") - except Exception as e: - api_service.logger.error(f"检查个人登录状态异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/status', methods=['POST']) -def user_status(): - """获取个人用户登录状态与账号信息(Cookie 由浏览器本地保存并随请求传入)""" - try: - data = api_service._safe_get_request_data() - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.success({'cookie': {'is_valid': False}, 'user': None}, "个人账号未登录") - user = account_info(cookies) - return APIResponse.success({'cookie': {'is_valid': user is not None}, 'user': user}, "获取个人登录状态成功") - except Exception as e: - api_service.logger.error(f"获取个人登录状态异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/logout', methods=['POST']) -def user_logout(): - """退出个人账号登录(浏览器清除本地 Cookie 即可,服务器无可清除内容)""" - try: - return APIResponse.success({'is_valid': False}, "已退出登录") - except Exception as e: - api_service.logger.error(f"个人退出登录异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/playlists', methods=['POST']) -def user_playlists_api(): - """获取个人用户收藏/创建的歌单(含「我喜欢的音乐」)""" - try: - data = api_service._safe_get_request_data() - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录你的网易云账号", 401) - - account = account_info(cookies) - if not account or not account.get('userId'): - return APIResponse.error("获取账号信息失败,Cookie 可能已失效,请重新登录", 401) - - playlists = user_playlists(account['userId'], cookies) - # R5-P3:列表内歌单名/创建者昵称过遮蔽(与 /playlist、/search 口径一致) - if isinstance(playlists, list): - for pl in playlists: - if isinstance(pl, dict): - if pl.get('name'): - pl['name'] = _mask_server_nickname(pl['name']) - cr = pl.get('creator') - if isinstance(cr, dict) and cr.get('nickname'): - cr['nickname'] = _mask_server_nickname(cr['nickname']) - return APIResponse.success({'user': account, 'playlists': playlists}, "获取个人歌单成功") - except APIException as e: - api_service.logger.error(f"获取个人歌单失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取个人歌单异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/playlist', methods=['POST']) -def user_playlist_api(): - """获取个人歌单详情(使用个人 Cookie,支持「我喜欢的音乐」等私有歌单)""" - try: - data = api_service._safe_get_request_data() - playlist_id = data.get('id') - - validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) - if validation_error: - return validation_error - - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录你的网易云账号", 401) - - playlist_id = api_service._extract_music_id(playlist_id) - # 第 91 条(P3-3):与 /playlist 同口径的数字白名单 - if not str(playlist_id).strip().isdigit(): - return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") - result = playlist_detail(playlist_id, cookies) - # R5-P3:详情内歌单名/创建者昵称同样过遮蔽(铁律:服务器昵称对非 admin 不可见) - if isinstance(result, dict): - pl = result.get('playlist') - if isinstance(pl, dict): - if pl.get('name'): - pl['name'] = _mask_server_nickname(pl['name']) - cr = pl.get('creator') - if isinstance(cr, dict) and cr.get('nickname'): - cr['nickname'] = _mask_server_nickname(cr['nickname']) - return APIResponse.success({'playlist': result}, "获取个人歌单详情成功") - except APIException as e: - api_service.logger.error(f"获取个人歌单详情失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取个人歌单详情异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/collect', methods=['POST']) -@auth_system.login_required(free=True) -def user_collect(): - """收藏/取消收藏歌单或专辑(官方接口,真实收藏到个人账号)""" - try: - data = api_service._safe_get_request_data() - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录你的网易云账号", 401) - - collect_id = data.get('id') - collect_type = data.get('type', 'playlist') # playlist | album - action = _safe_int(data.get('action'), 1) # 1=收藏, 2=取消收藏(垃圾值不 500) - - if not collect_id: - return APIResponse.error("缺少 id 参数") - if collect_type not in ('playlist', 'album'): - return APIResponse.error("type 仅支持 playlist/album") - if action not in (1, 2): - return APIResponse.error("action 仅支持 1(收藏)/2(取消)") - - collect_id = api_service._extract_music_id(collect_id) - # 第 91 条(P3-3):数字白名单 - if not str(collect_id).strip().isdigit(): - return APIResponse.error("无效的ID,请输入数字ID或分享链接") - if collect_type == 'album': - result = album_subscribe(collect_id, cookies, action) - else: - result = playlist_subscribe(collect_id, cookies, action) - - return APIResponse.success({'result': result}, "收藏成功" if action == 1 else "取消收藏成功") - except APIException as e: - api_service.logger.error(f"收藏操作失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"收藏操作异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/user/comment', methods=['POST']) -@auth_system.login_required(free=True) -def user_comment(): - """发送/回复歌曲评论(官方 weapi 接口,使用个人账号)""" - try: - data = api_service._safe_get_request_data() - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录你的网易云账号", 401) - - song_id = data.get('song_id') or data.get('id') - content = (data.get('content') or '').strip() - action = _safe_int(data.get('action'), 1) # 1=发送评论, 2=回复评论(垃圾值不 500) - comment_id = data.get('comment_id') or data.get('reply_to') - - if not song_id: - return APIResponse.error("缺少歌曲 ID") - if not content: - return APIResponse.error("评论内容不能为空") - if len(content) > 140: - return APIResponse.error("评论内容不能超过 140 字") - if action not in (1, 2): - return APIResponse.error("action 仅支持 1(评论)/2(回复)") - if action == 2 and not comment_id: - return APIResponse.error("回复评论缺少目标评论 ID") - - song_id = api_service._extract_music_id(song_id) - # R5-P3:thread_id 由 song_id 拼接,垃圾值先拦下(与 /download 同口径) - if not str(song_id).isdigit(): - return APIResponse.error("无效的歌曲 ID") - thread_id = f"R_SO_4_{song_id}" - # 回复目标 ID 归一化:垃圾值不再触发 int() 500 - reply_target = _safe_int(comment_id, 0) if comment_id else 0 - if action == 2 and reply_target <= 0: - return APIResponse.error("回复评论的目标评论 ID 无效") - result = post_comment(thread_id, content, action, - reply_target if reply_target > 0 else None, cookies) - return APIResponse.success({'result': result}, "评论成功" if action == 1 else "回复成功") - except APIException as e: - api_service.logger.error(f"评论操作失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"评论操作异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============ -# VALID_QUALITY_LEVELS 已迁至 core.config(顶部 import 同名共享,此处不再重复定义) - - -@app.route('/global/playlists', methods=['GET', 'POST']) -@auth_system.admin_required -def global_playlists_api(): - """获取全局账号收藏/创建的歌单(含「我喜欢的音乐」)""" - try: - cookies = api_service._get_cookies() - account = account_info(cookies) - if not account or not account.get('userId'): - return APIResponse.error("全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录", 401) - - playlists = user_playlists(account['userId'], cookies) - return APIResponse.success({'user': account, 'playlists': playlists}, "获取全局账号歌单成功") - except APIException as e: - api_service.logger.error(f"获取全局账号歌单失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取全局账号歌单异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/global/playlist', methods=['GET', 'POST']) -@auth_system.admin_required -def global_playlist_api(): - """获取全局账号歌单详情(使用全局 Cookie,支持「我喜欢的音乐」等私有歌单)""" - try: - data = api_service._safe_get_request_data() - playlist_id = data.get('id') - - validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) - if validation_error: - return validation_error - - cookies = api_service._get_cookies() - playlist_id = api_service._extract_music_id(playlist_id) - # 第 91 条(P3-3):数字白名单 - if not str(playlist_id).strip().isdigit(): - return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") - result = playlist_detail(playlist_id, cookies) - return APIResponse.success({'playlist': result}, "获取全局账号歌单详情成功") - except APIException as e: - api_service.logger.error(f"获取全局账号歌单详情失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取全局账号歌单详情异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/playlist/play', methods=['GET', 'POST']) -def playlist_play_api(): - """公开的歌单播放 API(方案 C:需 API 密钥,管理后台「播放API密钥」可刷新) - - 返回歌单信息与每首歌的可播放直链(使用全局账号解析音质)。 - GET/POST 均支持,参数: - id - 必填,歌单 ID(或网易云分享链接) - key - 必填,播放 API 密钥(管理后台生成/刷新,旧密钥刷新即失效) - level - 选填,音质等级,默认 lossless(支持 standard/exhigh/lossless/hires/sky/jyeffect/jymaster/dolby) - limit - 选填,最多返回的歌曲数量,默认 100,最大 500(0 或超过按 500 封顶,第 99 条文案与实现对齐) - """ - try: - data = api_service._safe_get_request_data() - playlist_id = data.get('id') - level = data.get('level', 'lossless') - raw_limit = data.get('limit', 100) - - # 方案 C(第 78 条):密钥前置校验——放在一切上游调用之前, - # 无密钥的匿名请求 0 次上游调用(消除 1:501 的放大面) - provided = str(data.get('key') or request.args.get('key') or '') - expected = auth_system.get_setting('playlist_play_key', '') - if not expected or not provided or not hmac.compare_digest(provided, expected): - return APIResponse.error("需要有效的 API 密钥(请联系站点管理员获取)", 401) - - validation_error = api_service._validate_request_params({'playlist_id': playlist_id}) - if validation_error: - return validation_error - - if level not in VALID_QUALITY_LEVELS: - return APIResponse.error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}") - - try: - limit = int(raw_limit) - except (TypeError, ValueError): - return APIResponse.error("limit 参数必须为整数") - if limit < 0: - return APIResponse.error("limit 参数不能为负数") - if limit == 0: - # F18:limit=0 表示"全部",但必须封顶 500(原实现可对 501 首歌单逐首放大上游请求) - limit = 500 - if limit > 500: - limit = 500 - - playlist_id = api_service._extract_music_id(playlist_id) - # 第 91 条(P3-3):数字白名单 - if not str(playlist_id).strip().isdigit(): - return APIResponse.error("无效的歌单ID,请输入数字ID或分享链接") - cookies = api_service._get_cookies() - - detail = playlist_detail(playlist_id, cookies) - all_tracks = detail.get('tracks', []) - tracks = all_tracks if limit == 0 else all_tracks[:limit] - - # 逐首解析播放直链,跳过无版权/无法解析的歌曲 - # O-2:小并发(4)解析——原实现 500 首串行逐首请求可达数分钟; - # 4 并发对齐批量下载窗口的节奏,不放大风控面 - from concurrent.futures import ThreadPoolExecutor - playable = [] - skipped = 0 - - def _resolve_track(t): - try: - url_info = url_v1(t['id'], level, cookies) - if url_info and url_info.get('data') and url_info['data'][0].get('url'): - d = url_info['data'][0] - return { - 'id': t['id'], - 'name': t.get('name', ''), - 'artists': t.get('artists', ''), - 'album': t.get('album', ''), - 'picUrl': t.get('picUrl', ''), - 'url': d.get('url', ''), - 'level': d.get('level', level), - 'level_name': api_service._get_quality_display_name(d.get('level', level)), - 'size': d.get('size', 0), - 'size_formatted': api_service._format_file_size(d.get('size', 0)), - 'type': d.get('type'), - 'bitrate': d.get('br'), - } - return None # 该曲不可播放 - except Exception: - return None - - with ThreadPoolExecutor(max_workers=4) as _pool: - for item in _pool.map(_resolve_track, tracks): - if item is not None: - playable.append(item) - else: - skipped += 1 - - response_data = { - 'playlist': { - 'id': detail.get('id'), - 'name': _mask_server_nickname(detail.get('name', '')), - 'coverImgUrl': detail.get('coverImgUrl', ''), - 'creator': _mask_server_nickname(detail.get('creator', '')), - 'trackCount': detail.get('trackCount', len(all_tracks)), - }, - 'account': 'admin', - 'level': level, - 'level_name': api_service._get_quality_display_name(level), - 'limit': limit or len(tracks), - 'total': len(all_tracks), - 'playable': len(playable), - 'skipped': skipped, - 'tracks': playable, - } - return APIResponse.success(response_data, "获取歌单播放列表成功") - except APIException as e: - api_service.logger.error(f"获取歌单播放列表失败: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - except Exception as e: - api_service.logger.error(f"获取歌单播放列表异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -# 全局账号 VIP 探测缓存已迁出至 core.state(此处 import 同名共享): -# _VIP_CACHE(5 分钟;第 91 条修复的缩进缺陷原样保留正确语义) -# _VIP_USER_CACHE(user 路径按 cookie 哈希的 5 分钟缓存) - - -def _vip_cache_reset() -> None: - """重新扫码登录/删除 Cookie 后调用,强制下次探测走真实接口""" - _VIP_CACHE['data'] = None - _VIP_CACHE['ts'] = 0.0 - - -@app.route('/account/vip', methods=['GET', 'POST']) -def account_vip_info(): - """获取当前解析账号的 VIP 类型(admin=全局管理员账号 / user=个人账号)""" - try: - data = api_service._safe_get_request_data() - account = data.get('account', 'admin') - - if account == 'user': - user_cookies = _resolve_user_cookies(data) - if not user_cookies: - return APIResponse.success( - {'account': 'user', 'logged_in': False, 'vip_type': None, 'vip_name': '未登录', - 'levels_ok': []}, - "个人账号未登录") - # 第 91 条(P3-7):user 路径加 5 分钟缓存——_detect_vip 每次调用 = - # 8 次音质探测 + 1 次账号接口,匿名 30/分/IP 打满即 270 次上游调用/分 - ck = '|'.join(f'{k}={v}' for k, v in sorted(user_cookies.items())) - ck_hash = hashlib.md5(ck.encode('utf-8')).hexdigest() - cached_user = _VIP_USER_CACHE.get(ck_hash) - if cached_user is not None and time.time() - cached_user['ts'] <= cached_user.get('ttl', 300): - return APIResponse.success(dict(cached_user['data'], account='user'), - "获取个人账号信息成功(缓存)") - vip_type, method, levels_ok = _detect_vip(user_cookies) - info = account_info(user_cookies) - user_payload = { - 'account': 'user', 'logged_in': True, 'vip_type': vip_type, - 'vip_name': _vip_name(vip_type), 'detected_by': method, - 'levels_ok': levels_ok, - 'nickname': (info or {}).get('nickname', ''), - } - # 第 100 条(对抗复审):降级结果(探测列表为空 = 单飞超时/预算耗尽)只缓存 - # 30s——上游恢复后 VIP 账号不再被"普通用户"钉满 5 分钟;正常结果仍 300s - _vip_ttl = 30 if not levels_ok else 300 - if len(_VIP_USER_CACHE) > 500: - _VIP_USER_CACHE.clear() - _VIP_USER_CACHE[ck_hash] = {'ts': time.time(), 'ttl': _vip_ttl, 'data': user_payload} - return APIResponse.success(user_payload, "获取个人账号信息成功") - - # admin(默认)—— 探测结果缓存 5 分钟 - cached = _VIP_CACHE['data'] - if cached is None or time.time() - _VIP_CACHE['ts'] > _VIP_CACHE.get('ttl', 300): - admin_cookies = api_service._get_cookies() - vip_type, method, levels_ok = _detect_vip(admin_cookies) - info = account_info(admin_cookies) - cached = { - 'vip_type': vip_type, 'vip_name': _vip_name(vip_type), - 'detected_by': method, 'levels_ok': levels_ok, - 'nickname': (info or {}).get('nickname', ''), - 'logged_in': info is not None, - 'note': '' if info else '账号接口未返回会员信息,已通过歌曲音质探测接口检测', - } - _VIP_CACHE['data'] = cached - _VIP_CACHE['ts'] = time.time() - _VIP_CACHE['ttl'] = 30 if not levels_ok else 300 # 第 100 条:降级结果短缓存 - # 服务器账号昵称仅站点管理员可见;VIP 等级与可解析音质对所有人开放 - _cu = auth_system.current_user() - _is_admin = _cu is not None and _cu['role'] == 'admin' - payload = dict(cached) - payload['account'] = 'admin' - payload['nickname'] = cached['nickname'] if _is_admin else '' - return APIResponse.success(payload, "获取全局账号信息成功") - except Exception as e: - api_service.logger.error(f"获取账号VIP信息异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/download', methods=['POST']) -@app.route('/Download', methods=['POST']) # 向后兼容(F10:付费动作仅接受 POST,杜绝跨站 GET 顶层导航扣费) -@auth_system.login_required('音乐下载') -def download_music_api(): - """下载音乐API""" - try: - # 获取请求参数 - data = api_service._safe_get_request_data() - music_id = data.get('id') - quality = data.get('quality', 'lossless') - return_format = data.get('format', 'file') # file 或 json - - # 参数验证 - validation_error = api_service._validate_request_params({'music_id': music_id}) - if validation_error: - return validation_error - # R5-P3:ID 数字白名单(与 /song 同口径;付费入口优先收紧) - music_id = str(music_id).strip() - if not music_id.isdigit(): - return APIResponse.error("无效的歌曲 ID") - - # 验证音质参数 - valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] - if quality not in valid_qualities: - return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}") - - # 验证返回格式 - if return_format not in ['file', 'json']: - return APIResponse.error("返回格式只支持 'file' 或 'json'") - - music_id = api_service._extract_music_id(music_id) - - # 解析账号选择:user=游客个人账号,默认 admin=全局管理员账号 - account = data.get('account', 'admin') - if account == 'user': - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) - else: - cookies = api_service._get_cookies() - - # 下载配置参数 - filename_format = data.get('filename_format', 'artist_song') # song | artist_song | song_artist - write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') - meta_fields = _parse_meta_fields(data.get('meta_fields')) - lyric_format = data.get('lyric_format', 'lrc') # lrc | srt - # F-C:白名单——垃圾 lyric_format 会拼出覆盖音频条目的 ZIP arcname - if lyric_format not in ('lrc', 'srt'): - lyric_format = 'lrc' - need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') - - # return_format=json:同步下载并返回歌曲信息(不产生后台任务) - if return_format == 'json': - # 一次预取 URL/详情/歌词(3 接口并行),同时供下载与响应使用, - # 避免下载器内部再走一遍 API - try: - info = api_service.downloader.get_music_info(music_id, quality, cookies) - except DownloadException as e: - # R5-P3:对外泛化(原始错误含上游主机/URL 细节),细节留日志 - api_service.logger.error(f"获取音乐信息失败: {e}") - return APIResponse.error("获取失败:歌曲不存在或暂无版权", 404) - # 按前端选择的命名格式生成本次响应的文件名 - base = _build_download_base(filename_format, {'name': info.name, 'artist_string': info.artists}) - filename = f"{base}.{info.file_type}" - try: - download_result = api_service.downloader.download_music_file( - music_id, quality, - write_meta=write_meta, - meta_fields=meta_fields, - cookies=cookies, - music_info=info, - ) - if not download_result.success: - # R5-P3:对外泛化(error_message 可能含上游细节),原文留日志 - api_service.logger.error(f"下载失败: {download_result.error_message}") - return APIResponse.error("下载失败,请稍后重试或更换音质", 500) - api_service.logger.info(f"下载完成: {filename}") - except DownloadException as e: - api_service.logger.error(f"下载异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - try: - _record_log('下载', auth_system._client_ip(), info.name) - except Exception: - pass - response_data = { - 'music_id': music_id, - 'name': info.name, - 'artist': info.artists.replace('/', ', '), - 'album': info.album, - 'quality': quality, - 'quality_name': api_service._get_quality_display_name(quality), - 'file_type': info.file_type, - 'file_size': info.file_size, - 'file_size_formatted': api_service._format_file_size(info.file_size), - # P3-29:不再回传服务器绝对路径(路径信息披露);交付语义只需文件名, - # 文件在 downloads 根目录按 35 分钟口径由缓存清扫回收 - 'filename': filename, - 'duration': info.duration * 1000 - } - # A2/B8:json 模式无 task_id,成功交付即按请求级账本键结算 delivered=1 - _gkey = getattr(g, 'billing_ledger_key', None) - if _gkey: - try: - auth_system.ledger_settle(_gkey, 1, 'delivered') - except Exception as _e: - # 结算失败不改交付结果;账本行留 open,重启对账兜底退款(宁商家小损不坑用户) - api_service.logger.error(f"json 交付结算失败(重启对账兜底): {_gkey} {_e}") - return APIResponse.success(response_data, "下载完成") - - # return_format=file:注册后台任务,立即返回 task_id(支持进度/取消/完成后自动清理) - task_id = str(data.get('task_id') or '').strip() - device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) - _touch_device(device_id) - _cur_user = auth_system.current_user() - _reserved = 0 if (not _cur_user or _cur_user['role'] == 'admin' - or int(_cur_user['duration_seconds'] or 0) == -1) else 1 - task = { - 'type': 'single', - 'billing_user_id': int(_cur_user['id']) if _cur_user else 0, - 'billing_reserved': _reserved, - 'total': 1, - 'done': 0, - 'failed': 0, - 'status': 'queued', - 'start': time.time(), - 'msg': '排队中...', - 'cancel': threading.Event(), - 'pause': threading.Event(), - 'device_id': device_id, - 'result': None, - 'params': { - 'music_id': music_id, - 'quality': quality, - 'filename_format': filename_format, - 'write_meta': write_meta, - 'meta_fields': meta_fields, - 'lyric_format': lyric_format, - 'need_zip': need_zip, - 'cookies': cookies, - 'remote_ip': auth_system._client_ip(), - }, - } - reg_err = _register_download_task(task, _reserved, task_id_hint=task_id) - if reg_err: - return APIResponse.error(reg_err, 429) - task_id = task['task_id'] - start_err = _start_task_thread(task_id, _single_download_worker, (task_id,)) - if start_err: - return APIResponse.error(start_err, 500) - return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "下载任务已提交") - except Exception as e: - api_service.logger.error(f"下载音乐异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -def _billing_ids_count() -> int: - """按次计费:解析请求中的 ids 数量(一首歌 = 1 次;100 首歌单打包 = 100 次) - F23:与路由的清洗规则完全一致(提取→去重),预扣数=实际执行数,重复 ID 不多扣""" - try: - data = api_service._safe_get_request_data() - raw = data.get('ids') - if isinstance(raw, str): - raw_list = raw.split(',') - else: - raw_list = list(raw or []) - ids, seen = [], set() - for x in raw_list: - try: - mid = api_service._extract_music_id(x) - except Exception: - mid = str(x).strip() - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - # 与路由同口径:单任务上限 500(超出部分路由会 400 拒绝并退款, - # 预扣数先封顶可让"请分批提交"提示优先于"次数不足") - return max(1, min(len(ids), 500)) - except Exception: - return 1 - - -@app.route('/download/batch_zip', methods=['POST']) -@auth_system.login_required('批量ZIP下载', cost=_billing_ids_count) -def download_batch_zip(): - """提交批量打包任务(后台线程执行,立即返回 task_id,前端轮询进度/取消) - - 改为后台任务的原因: - - 原方案在请求线程里一次性构建并回传大 ZIP,长连接易被代理/浏览器超时中断(~60% 时失败) - - 后台执行 + 轮询,连接断开不影响任务继续;音频入包即删,缓解磁盘占用 - """ - try: - data = api_service._safe_get_request_data() - task_id_hint = str(data.get('task_id') or '').strip() - raw_ids = data.get('ids') - if isinstance(raw_ids, str): - raw_id_list = [x.strip() for x in raw_ids.split(',') if x.strip()] - else: - # F46:数组分支元素可能是数字,统一 str() 后再清洗(原实现直接 x.strip() 会 500) - raw_id_list = [str(x).strip() for x in (raw_ids or []) if str(x).strip()] - if not raw_id_list: - return APIResponse.error("缺少 ids 参数") - # R5-P3:ID 数字白名单(与 /download 同口径;垃圾 ID 不再透传上游) - non_digit = [x for x in raw_id_list if not x.isdigit()] - if non_digit: - return APIResponse.error("ids 中含无效歌曲 ID") - - quality = data.get('quality', 'lossless') - # 音质白名单校验(与 /download 同口径:垃圾音质直接 400,而不是让整批全失败再退费) - valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby'] - if quality not in valid_qualities: - return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}") - filename_format = data.get('filename_format', 'artist_song') - write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') - meta_fields = _parse_meta_fields(data.get('meta_fields')) - lyric_format = data.get('lyric_format', 'lrc') - # F-C:白名单——垃圾 lyric_format 会在 ZIP 内覆盖音频条目 - if lyric_format not in ('lrc', 'srt'): - lyric_format = 'lrc' - try: - concurrency = int(data.get('concurrency', 8) or 8) - concurrency = max(1, min(10, concurrency)) - except (TypeError, ValueError): - concurrency = 8 - - account = data.get('account', 'admin') - if account == 'user': - # 个人账号 Cookie 由浏览器传入(与 /song 等接口一致)。 - # 第 91 条(P3-2):删除 user_cookie.txt 服务器回退(跨账号复用残留 Cookie 的洞) - cookies = _resolve_user_cookies(data) - if not cookies: - return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401) - else: - cookies = api_service._get_cookies() - - # 提前解析并去重 ID,避免同一首歌被重复下载 - ids = [] - seen = set() - for raw_id in raw_id_list: - try: - mid = api_service._extract_music_id(raw_id) - except Exception: - mid = str(raw_id).strip() - if mid and mid not in seen: - seen.add(mid) - ids.append(mid) - if not ids: - return APIResponse.error("缺少 ids 参数") - # 数量上限:防止超大歌单长期占满批量队列(播客打包同为 500 上限) - if len(ids) > 500: - return APIResponse.error("单个批量任务最多 500 首,请分批提交") - - # 注册任务,后台线程执行(避免长连接被中断) - device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN) - _touch_device(device_id) - _cur_user = auth_system.current_user() - _unlimited = (not _cur_user or _cur_user['role'] == 'admin' - or int(_cur_user['duration_seconds'] or 0) == -1) - _reserved = 0 if _unlimited else len(ids) - task = { - 'type': 'batch', - 'billing_user_id': int(_cur_user['id']) if _cur_user else 0, - 'billing_reserved': _reserved, - 'billing_cost': len(ids), # 名义歌曲数(对账用);实际退款以账本 reserved 为准 - 'total': len(ids), - 'done': 0, - 'failed': 0, - 'status': 'queued', - 'start': time.time(), - 'msg': '排队中...', - 'cancel': threading.Event(), - 'pause': threading.Event(), - 'device_id': device_id, - 'result': None, - 'params': { - 'ids': ids, - 'quality': quality, - 'filename_format': filename_format, - 'write_meta': write_meta, - 'meta_fields': meta_fields, - 'lyric_format': lyric_format, - 'concurrency': concurrency, - 'cookies': cookies, - }, - } - reg_err = _register_download_task(task, _reserved, task_id_hint=task_id_hint) - if reg_err: - return APIResponse.error(reg_err, 429) - task_id = task['task_id'] - start_err = _start_task_thread(task_id, _batch_zip_worker, (task_id,)) - if start_err: - return APIResponse.error(start_err, 500) - return APIResponse.success({'task_id': task_id, 'status': 'queued'}, "批量打包任务已提交") - except Exception as e: - api_service.logger.error(f"批量打包提交异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int: - """批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款; - 重复调用/重启后调用都只会退一次)""" - try: - refund = auth_system.ledger_settle(task_id, ok_count, final_status) - except Exception: - # 第 97 条(P3):本函数多在异常处理器内被调用——结算再抛错(库忙超时) - # 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等) - api_service.logger.error( - f"批量结算异常(留对账兜底): {task_id}\n{traceback.format_exc()}") - return 0 - if refund: - try: - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) or {} - uid = int(t.get('billing_user_id') or 0) - u = auth_system.get_user_by_id(uid) if uid else None - uname = u['username'] if u else 'system' - remaining = u['duration_seconds'] if u else '?' - auth_system.add_log('success', uname, 'system', '批量退费', - f'失败部分 {refund} 次已退回,剩余 {remaining} 次') - except Exception: - pass - return refund - - -def _batch_zip_worker(task_id: str) -> None: - """后台批量打包:窗口化下载 → 写 ZIP(音频免压缩),支持取消与断线暂停 - - - 断线暂停:设备心跳超时后,当前窗口下载完即释放 BATCH_ZIP_SEM 名额给下一位, - 保存进度进入暂停等待;重新打开网站(心跳恢复)后自动回到队列继续下载 - - 音频入包即删,缓解磁盘占用 - """ - import tempfile as _tempfile - from concurrent.futures import ThreadPoolExecutor, as_completed - - zip_tmp_path: str | None = None - ok_count = 0 - failed: list[str] = [] - used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖) - info_pool = None - info_futures = {} - try: - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS[task_id] - task['status'] = 'running' - task['msg'] = '下载打包中...' - params = task['params'] - cancel_ev = task['cancel'] - pause_ev = task['pause'] - ids = list(params['ids']) - quality = params['quality'] - filename_format = params['filename_format'] - write_meta = params['write_meta'] - meta_fields = params['meta_fields'] - lyric_format = params['lyric_format'] - concurrency = params['concurrency'] - cookies = params['cookies'] - batch_start = time.time() - - # P2-7:预取按窗口进行——原实现一次性提交全部(最多 500 首 ×3 接口)预取任务, - # 大歌单瞬间以 10 并发连发上游约 1500 次请求打风控;改为每个下载窗口只预取本窗口 - # 的歌(与下载节奏对齐),_download_one 语义不变(等预取结果再下载) - from concurrent.futures import ThreadPoolExecutor as _InfoPool - info_pool = _InfoPool(max_workers=min(4, max(1, concurrency))) - - def _prefetch(window_ids): - for mid in window_ids: - if mid not in info_futures: - info_futures[mid] = info_pool.submit( - api_service.downloader.get_music_info, mid, quality, cookies - ) - - def _download_one(mid): - """并行下载单元:等待预取信息 → 下载 → 复用预取歌词与缓存封面(零重复请求)""" - info = info_futures[mid].result() - result = api_service.downloader.download_music_file( - mid, quality, - write_meta=write_meta, - meta_fields=meta_fields, - cookies=cookies, - cancel_event=cancel_ev, - music_info=info, - ) - extra = {'lyric': '', 'cover': b''} - if result.success: - # 歌词已随 get_music_info 预取返回,无需再调一次歌词接口 - extra['lyric'] = getattr(info, 'lyric', '') or '' - # 封面走共享缓存(同一专辑只拉一次网络);取消后不再发起新请求 - if not cancel_ev.is_set() and info.pic_url: - cover = fetch_cover_bytes(info.pic_url) - if cover: - extra['cover'] = cover - return result, extra - - def _update_progress() -> None: - with DOWNLOAD_TASKS_LOCK: - DOWNLOAD_TASKS[task_id]['done'] = ok_count + len(failed) - DOWNLOAD_TASKS[task_id]['failed'] = len(failed) - - def _wait_if_paused() -> None: - """暂停时等待恢复或取消(不持有 BATCH_ZIP_SEM)""" - if not (pause_ev.is_set() or task['status'] == 'paused'): - return - with DOWNLOAD_TASKS_LOCK: - task['status'] = 'paused' - task['done'] = ok_count + len(failed) - task['msg'] = '已暂停(重新打开页面自动恢复)' - while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'): - time.sleep(1) - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - pause_ev.clear() - with DOWNLOAD_TASKS_LOCK: - task['status'] = 'running' - task['msg'] = '继续下载...' - - # P3-26:ZIP 构建放 downloads 卷(原实现落系统 %TEMP%——500 首无损可达 GB 级, - # 小系统盘一次大单即塞爆;单曲/播客早已指定 dir,唯批量漏了) - # F-A(第 76 条):临时包建在任务专属目录 tasks//——原放根目录会被 - # 缓存清扫线程按 mtime 回收(Linux 上已打开文件也能被 unlink,Docker 部署 - # 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物 - _task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id - _task_dir_for_zip.mkdir(parents=True, exist_ok=True) - zip_valid = False # F-B:ZIP 中央目录完整写出才置 True,否则一律按失败全额退费 - with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip', - dir=str(_task_dir_for_zip), - delete=False) as zip_tmp: - zip_tmp_path = zip_tmp.name - # 音频本身已压缩,用 ZIP_STORED(免压缩)大幅提速;歌词等文本仍压缩 - with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf: - # P3-27:不用 with 管理下载池——取消时 with 退出会 wait=True 等在途 - # future(其内部阻塞在预取 HTTP 上最长 30s),改为不等待 + 取消排队任务 - pool = ThreadPoolExecutor(max_workers=concurrency) - try: - while ids: - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - # 断线暂停:释放名额等待恢复/取消,恢复后重新进入排队 - _wait_if_paused() - # 每个窗口持有 BATCH_ZIP_SEM(同一时刻全局最多 3 个批量任务在下载) - # F91:等待名额期间可被取消,不再无限阻塞线程与退款 - if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev): - raise DownloadCanceled("任务已取消") - try: - if cancel_ev.is_set() or pause_ev.is_set() or task['status'] == 'paused': - continue # 立即释放名额,回到循环顶部处理暂停 - window = ids[:concurrency] - ids = ids[concurrency:] - _prefetch(window) # P2-7:只预取本窗口 - future_map = { - pool.submit(_download_one, mid): mid - for mid in window - } - for future in as_completed(future_map): - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - mid = future_map[future] - try: - result, extra = future.result() - except DownloadCanceled: - raise - except Exception as e: - failed.append(f"{mid}({e})") - _update_progress() - continue - if not result.success: - failed.append(str(mid)) - _update_progress() - continue - fp = Path(result.file_path) - if not fp.exists(): - # 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务 - failed.append(str(mid)) - _update_progress() - continue - mi = result.music_info - base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists}) - # 同名去重:歌单里同一首歌出现两次/同名不同版本时, - # 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首) - # F-C:音频/歌词/封面三个 arcname 都要查重——同名同歌手但 - # 音源扩展不同(flac vs mp3)时歌词/封面条目也会互相覆盖 - final_name = f"{base}.{mi.file_type}" - lrc_name = f"{base}.{lyric_format}" - jpg_name = f"{base}.jpg" - k = 2 - while (final_name in used_arcnames - or lrc_name in used_arcnames - or jpg_name in used_arcnames): - final_name = f"{base} ({k}).{mi.file_type}" - lrc_name = f"{base} ({k}).{lyric_format}" - jpg_name = f"{base} ({k}).jpg" - k += 1 - used_arcnames.update((final_name, lrc_name, jpg_name)) - # F-D:exists 与 zf.write 之间的缓存竞态(另一任务缓存 miss - # 删文件)按单曲失败计,不再让整批转 error - try: - zf.write(str(fp), arcname=final_name, compress_type=zipfile.ZIP_STORED) - except OSError as e: - failed.append(f"{mid}(缓存文件读取失败: {e})") - _update_progress() - continue - # F27:缓存文件是跨任务共享资源,任务不得删除; - # 磁盘回收交给监控线程的下载缓存清扫(按文件年龄) - # 歌词与封面已在并行下载线程中拉取,直接写入 ZIP(无网络等待) - lrc = extra.get('lyric', '') - if lrc: - try: - zf.writestr(lrc_name, _format_lyric(lrc, lyric_format), compress_type=zipfile.ZIP_DEFLATED) - except Exception: - pass - if extra.get('cover'): - try: - zf.writestr(jpg_name, extra['cover'], compress_type=zipfile.ZIP_STORED) - except Exception: - pass - ok_count += 1 - _update_progress() - finally: - BATCH_ZIP_SEM.release() - finally: - # P3-27:取消/异常时不等待在途下载线程(其可能阻塞在预取 HTTP 上), - # 排队任务一并取消;在途线程自行收尾(.part 残片由续传/清扫兜底) - try: - pool.shutdown(wait=False, cancel_futures=True) - except TypeError: - pool.shutdown(wait=False) - # 第 93 条复审 P2-1 修正:zip_valid = True 必须在内层 with zipfile 退出之后 - # (中央目录在 close 时才写出)——原缩进 16 在 with 内部,守卫不可达 - zip_valid = True - - if ok_count > 0: - with BATCH_DURATIONS_LOCK: - BATCH_DURATIONS.append(time.time() - batch_start) - - # F-B:ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛 - if not zip_valid: - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - zip_tmp_path = None - refunded = _settle_batch_billing(task_id, 0, 'error') - _mark_task_terminal(task_id, 'error', - f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded - else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回") - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['failed'] = t.get('failed', 0) + ok_count - t['refunded'] = refunded - api_service.logger.error(f"批量ZIP构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次") - return - - # F41:全部失败不发布空 ZIP、不标 done —— 按失败收敛并全额退费 - if ok_count == 0 and failed: - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - zip_tmp_path = None - refunded = _settle_batch_billing(task_id, 0, 'error') - _mark_task_terminal(task_id, 'error', - f"全部失败({len(failed)} 首)" + (f",已退回 {refunded} 次" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - DOWNLOAD_TASKS[task_id]['failed'] = len(failed) - DOWNLOAD_TASKS[task_id]['refunded'] = refunded - api_service.logger.info(f"批量ZIP全部失败: {task_id} 失败{len(failed)} 退回{refunded}次") - return - - refunded = _settle_batch_billing(task_id, ok_count) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - # S3:发布前锁内复查取消/失效状态——取消请求落在"检查后、拿锁前"的窗口也不得覆盖终态 - if t is None or t.get('status') in ('canceled', 'error'): - raise DownloadCanceled("任务已取消") - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - t['status'] = 'done' - t['completed_at'] = time.time() - t['done'] = ok_count + len(failed) - t['failed'] = len(failed) - t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '') - t['refunded'] = refunded - t['result'] = { - 'zip_path': zip_tmp_path, - 'filename': f"批量下载_{ok_count}首.zip", - 'audio_files': [], # F27:缓存文件不归任务所有,不进入清理清单 - } - api_service.logger.info(f"批量ZIP完成: 成功{ok_count} 失败{len(failed)} 退回{refunded}次") - except DownloadCanceled: - # F24:部分成功后取消——已入包部分照常交付(成果守恒),未完成部分按账本退费 - # F-B:ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛 - delivered = ok_count if zip_valid else 0 - refunded = _settle_batch_billing(task_id, delivered, 'canceled') - if delivered > 0 and zip_tmp_path: - _mark_task_terminal(task_id, 'canceled', - f"已取消(已打包 {ok_count} 首可领取)" + - (f",退回 {refunded} 次" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['refunded'] = refunded - t['result'] = { - 'zip_path': zip_tmp_path, - 'filename': f"批量下载_{ok_count}首.zip", - 'audio_files': [], - } - else: - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - _mark_task_terminal(task_id, 'canceled', - '任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['refunded'] = refunded - api_service.logger.info(f"批量ZIP已取消: {task_id}(成功{ok_count} 退回{refunded}次)") - except Exception as e: - # F24:通用异常与取消同一套交付/结算规则:有成果则交付,按账本退未交付部分 - # F-B:ZIP 未正常完成(写坏)时按全额退费,不发布坏包 - # 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化 - api_service.logger.error(f"批量打包异常: {task_id} {e}\n{traceback.format_exc()}") - delivered = ok_count if zip_valid else 0 - refunded = _settle_batch_billing(task_id, delivered, 'error') - if delivered > 0 and zip_tmp_path: - _mark_task_terminal(task_id, 'error', - "部分歌曲已打包完成,可领取" + - (f",退回 {refunded} 次" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['refunded'] = refunded - t['result'] = { - 'zip_path': zip_tmp_path, - 'filename': f"批量下载_{ok_count}首.zip", - 'audio_files': [], - } - else: - _mark_task_terminal(task_id, 'error', - '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['refunded'] = refunded - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - api_service.logger.error(f"批量ZIP任务异常: {task_id} {e}(成功{ok_count} 退回{refunded}次)\n{traceback.format_exc()}") - finally: - # 回收信息预取线程池,避免线程泄漏(未启动的预取任务一并取消) - if info_pool is not None: - try: - info_pool.shutdown(wait=False, cancel_futures=True) - except TypeError: - info_pool.shutdown(wait=False) - - -def _podcast_download_worker(task_id: str) -> None: - """后台播客单集下载:解析单集 → 音质降级取直链 → 流式下载(可选 ZIP 打包) - - 任务 type 为 'single',完成后经 /download/result 交付,与单曲下载共用交付通道。 - F28:落盘文件进入任务专属目录且带节目 ID,跨任务永不重名;清理只动本任务目录。 - """ - import tempfile as _tempfile - - file_path_str: str | None = None - zip_tmp_path: str | None = None - task_dir: Path | None = None - try: - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS[task_id] - task['status'] = 'running' - task['msg'] = '解析单集信息...' - params = task['params'] - cancel_ev = task['cancel'] - program_id = params['program_id'] - quality = params['quality'] - need_zip = params['need_zip'] - cookies = params['cookies'] - remote_ip = params.get('remote_ip') or '-' - - program = podcast_api.get_program_detail(program_id, cookies) - url_info = _podcast_resolve_audio(program, quality, cookies) - - serial = int(program.get('serial_num') or 0) - name = program.get('name') or program.get('main_song_name') or '播客单集' - prefix = f"{serial:03d} - " if serial else '' - base = _podcast_safe_name(f"{prefix}{name}") - ext = (url_info.get('type') or 'mp3').lower().lstrip('.') - # F28:磁盘名附节目 ID + 任务目录隔离(两个任务同下载一集互不覆盖、互不删除) - task_dir = api_service.downloads_path / 'tasks' / task_id - task_dir.mkdir(parents=True, exist_ok=True) - disk_name = f"{base} [{program_id}].{ext}" - target = task_dir / disk_name - filename = f"{base}.{ext}" # 交付名保持干净(不带 ID) - - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS[task_id] - t['msg'] = '下载中...' - _podcast_stream_download(url_info['url'], target, url_info.get('size', 0), - cancel_ev, task_id, '下载中') - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - file_path_str = str(target) - - result: dict[str, Any] - if need_zip: - fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip', - dir=str(task_dir)) - os.close(fd) - with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: - zf.write(file_path_str, arcname=filename) - cover_url = program.get('cover_url') or '' - if cover_url: - try: - cover = fetch_cover_bytes(cover_url) - if cover: - zf.writestr(f"{base}.jpg", cover) - except Exception: - pass - # F17:info.txt 不再写直链(真实 URL 不应随交付文件外泄) - info_txt = ( - f"单集: {name}\n" - f"播客: {program.get('radio_name') or ''}\n" - f"主播: {program.get('dj_name') or ''}\n" - f"时长: {program.get('duration_text') or ''}\n" - f"音质: {api_service._get_quality_display_name(url_info.get('level', quality))}\n" - ) - zf.writestr("info.txt", info_txt) - # F42:打包阶段后同样以取消检查收口 - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - result = { - 'file_path': file_path_str, - 'zip_path': zip_tmp_path, - 'filename': f"{base}.zip", - 'mimetype': 'application/zip', - 'audio_files': [file_path_str], - } - else: - result = { - 'file_path': file_path_str, - 'filename': filename, - 'mimetype': f"audio/{ext}", - 'audio_files': [file_path_str], - } - - # F42:最终发布前的取消检查(末段取消不得覆盖为 done) - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is None or t.get('status') in ('canceled', 'error'): - raise DownloadCanceled("任务已取消") - t['status'] = 'done' - t['completed_at'] = time.time() - t['done'] = 1 - t['msg'] = '下载完成' - t['result'] = result - # C1:交付已落定;结算失败不再把任务改标 error(同单曲) - try: - auth_system.ledger_settle(task_id, 1, 'delivered') - except Exception as _e: - api_service.logger.error(f"播客单集交付结算失败(重启对账兜底): {task_id} {_e}") - try: - _record_log('下载', remote_ip, f"[播客] {name}") - except Exception: - pass - api_service.logger.info(f"播客单集下载完成: {filename} ({task_id})") - except DownloadCanceled: - _mark_task_terminal(task_id, 'canceled', '任务已取消') - try: - auth_system.ledger_settle(task_id, 0, 'canceled') - except Exception: - pass - if task_dir is not None: - shutil.rmtree(str(task_dir), ignore_errors=True) - api_service.logger.info(f"播客单集下载已取消: {task_id}") - except DownloadException as e: - _mark_task_terminal(task_id, 'error', str(e)) - try: - auth_system.ledger_settle(task_id, 0, 'error') - except Exception: - pass - if task_dir is not None: - shutil.rmtree(str(task_dir), ignore_errors=True) - api_service.logger.error(f"播客单集下载失败: {task_id} {e}") - except Exception as e: - # 第 97 条(P3):非预期异常原文 logger 先行,用户可见文案泛化(第 84 条教训) - api_service.logger.error(f"播客单集下载异常: {task_id} {e}\n{traceback.format_exc()}") - _mark_task_terminal(task_id, 'error', '下载失败,请稍后重试') - try: - auth_system.ledger_settle(task_id, 0, 'error') - except Exception: - pass - if task_dir is not None: - shutil.rmtree(str(task_dir), ignore_errors=True) - - -def _podcast_cleanup_files(paths: list[str | None]) -> None: - """清理播客任务产生的临时/残留文件""" - for p in paths: - if not p: - continue - try: - os.remove(p) - except OSError: - pass - # 连带清理可能残留的 .part 文件 - try: - part = Path(str(p) + '.part') - if part.exists(): - part.unlink() - except OSError: - pass - - -def _podcast_zip_worker(task_id: str) -> None: - """整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)""" - import tempfile as _tempfile - - zip_tmp_path: str | None = None - ok_count = 0 - failed: list[str] = [] - # 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等 - # 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程 - zip_valid = False - try: - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS[task_id] - task['status'] = 'running' - task['msg'] = '解析单集列表...' - params = task['params'] - cancel_ev = task['cancel'] - pause_ev = task['pause'] - radio_id = params['radio_id'] - preset_ids = params.get('ids') - limit = params['limit'] - quality = params['quality'] - cookies = params['cookies'] - remote_ip = params.get('remote_ip') or '-' - - # 组装单集清单:整台电台(正序全量)或指定单集ID列表 - if radio_id: - episodes = podcast_api.list_radio_all_program_ids(radio_id, cookies, max_count=limit) - if not episodes: - raise DownloadException("该电台暂无可下载的单集") - else: - episodes = [] - for pid in (preset_ids or []): - try: - prog = podcast_api.get_program_detail(pid, cookies) - episodes.append({ - 'id': prog['id'], - 'name': prog['name'], - 'main_song_id': prog['main_song_id'], - 'serial_num': prog['serial_num'], - 'cover_url': prog['cover_url'], - 'detail': prog, - }) - except APIException as ae: - failed.append(f"{pid}({ae})") - episodes = episodes[:limit] - if not episodes: - raise DownloadException("没有可下载的单集") - - radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客') - with DOWNLOAD_TASKS_LOCK: - DOWNLOAD_TASKS[task_id]['total'] = len(episodes) - - # 按次计费补扣(F21):整台电台提交时只预扣 1 次,此处按实际单集数补足差额。 - # 补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付 - try: - _buid = params.get('billing_uid') - _extra = len(episodes) - int(params.get('billed') or 1) - if _buid and _extra > 0: - if not auth_system.ledger_supplement(task_id, _extra): - raise DownloadException( - f"补扣下载次数失败(需补扣 {_extra} 次),任务已取消,预扣部分将退回") - if _buid: - with DOWNLOAD_TASKS_LOCK: - DOWNLOAD_TASKS[task_id]['billing_user_id'] = int(_buid) - DOWNLOAD_TASKS[task_id]['billing_cost'] = len(episodes) - except DownloadCanceled: - raise - except DownloadException: - raise - except Exception as e: - raise DownloadException(f"补扣下载次数异常: {e}") - - def _wait_if_paused() -> None: - """暂停时等待恢复或取消""" - if not (pause_ev.is_set() or task['status'] == 'paused'): - return - with DOWNLOAD_TASKS_LOCK: - task['status'] = 'paused' - task['done'] = ok_count + len(failed) - task['msg'] = '已暂停(重新打开页面自动恢复)' - while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'): - time.sleep(1) - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - pause_ev.clear() - with DOWNLOAD_TASKS_LOCK: - task['status'] = 'running' - task['msg'] = '继续下载...' - - def _update_progress(msg: str = '') -> None: - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS[task_id] - t['done'] = ok_count + len(failed) - t['failed'] = len(failed) - if msg: - t['msg'] = msg - - # 第 91 条(P1-1):对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常 - # 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头,第 97 条); - # 未 valid 一律不发布、不按 ok_count 结算 - # P3-26:同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP% - # R5-P3/W-A 同口径:建在任务专属目录 tasks//,根目录缓存清扫不再触及交付物 - _podcast_task_dir = api_service.downloads_path / 'tasks' / task_id - _podcast_task_dir.mkdir(parents=True, exist_ok=True) - with _tempfile.NamedTemporaryFile(prefix='podcast_batch_', suffix='.zip', - dir=str(_podcast_task_dir), - delete=False) as zip_tmp: - zip_tmp_path = zip_tmp.name - used_arcnames = set() # F73:包内条目名去重(同名单集/同号不同集不得互相覆盖) - with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf: - for idx, ep in enumerate(episodes, 1): - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - _wait_if_paused() - # 并发对齐音乐批量:播客打包同样占用全局批量名额(BATCH_ZIP_SEM,全局 3), - # 逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受 - # 全局下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发 - if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev): - raise DownloadCanceled("任务已取消") - try: - label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}" - _update_progress(f"{label} 解析中...") - try: - prog = ep.get('detail') or podcast_api.get_program_detail(ep['id'], cookies) - url_info = _podcast_resolve_audio(prog, quality, cookies) - _update_progress(f"{label} 下载中...") - ext = (url_info.get('type') or 'mp3').lower().lstrip('.') - serial = int(ep.get('serial_num') or 0) - prefix = f"{serial:03d} - " if serial else '' - base = _podcast_safe_name(f"{prefix}{ep['name']}") - # 第 91 条(P3-5):中间文件移入任务目录——落 downloads 根目录会被 - # 缓存清扫按 mtime 打断慢速单集(Linux 上 unlink 打开中的文件) - tmp_audio = _podcast_task_dir / f"podcast_zip_tmp_{task_id}_{idx}.{ext}" - try: - _podcast_stream_download(url_info['url'], tmp_audio, - url_info.get('size', 0), cancel_ev) - # F73:重名条目自动追加序号 - arcname = f"{base}.{ext}" - k = 2 - while arcname in used_arcnames: - arcname = f"{base} ({k}).{ext}" - k += 1 - used_arcnames.add(arcname) - zf.write(str(tmp_audio), arcname=arcname, compress_type=zipfile.ZIP_STORED) - finally: - # P3-5:finally 连带清理 .part 残片(原实现只删正文, - # 取消/失败时残片要等 1 小时缓存清扫兜底) - _podcast_cleanup_files([str(tmp_audio)]) - # 首集附带封面,便于识别 - if idx == 1 and ep.get('cover_url'): - try: - cover = fetch_cover_bytes(ep['cover_url']) - if cover: - zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED) - except Exception: - pass - ok_count += 1 - _update_progress(f"{label} 完成") - except DownloadCanceled: - raise - except Exception as e: - failed.append(f"{ep['name']}({e})") - _update_progress() - finally: - BATCH_ZIP_SEM.release() - - # 第 91 条(P1-1):走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布 - zip_valid = True - - if ok_count == 0: - raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}") - - # 第 91 条(P1-1):ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛 - if not zip_valid: - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - zip_tmp_path = None - refunded = _settle_batch_billing(task_id, 0, 'error') - _mark_task_terminal(task_id, 'error', - f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded - else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回") - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['failed'] = t.get('failed', 0) + ok_count - t['refunded'] = refunded - api_service.logger.error(f"播客打包构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次") - return - - # F42:最终发布前的取消检查(末段取消不得覆盖为 done) - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - refunded = _settle_batch_billing(task_id, ok_count) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - # S3:发布前锁内复查取消/失效状态(同音乐批量) - if t is None or t.get('status') in ('canceled', 'error'): - raise DownloadCanceled("任务已取消") - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - t['status'] = 'done' - t['completed_at'] = time.time() - t['done'] = ok_count + len(failed) - t['failed'] = len(failed) - t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '') - t['refunded'] = refunded - t['result'] = { - 'zip_path': zip_tmp_path, - 'filename': f"播客_{radio_name}_{ok_count}集.zip", - 'audio_files': [], - } - try: - first_name = episodes[0].get('name') if episodes else '' - _record_log('下载', remote_ip, f"[播客打包] {radio_name} · {first_name}") - except Exception: - pass - api_service.logger.info(f"播客打包完成: {radio_name} 成功{ok_count} 失败{len(failed)} ({task_id})") - except DownloadCanceled: - # F24:部分成功后取消——已入包部分照常交付,未完成部分按账本退费 - # 第 91 条(P1-1):ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛 - delivered = ok_count if zip_valid else 0 - refunded = _settle_batch_billing(task_id, delivered, 'canceled') - if delivered > 0 and zip_tmp_path: - _mark_task_terminal(task_id, 'canceled', - f"已取消(已打包 {ok_count} 集可领取)" + - (f",退回 {refunded} 次" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['refunded'] = refunded - t['result'] = { - 'zip_path': zip_tmp_path, - 'filename': f"播客_{radio_name or '打包'}_{ok_count}集.zip", - 'audio_files': [], - } - else: - _mark_task_terminal(task_id, 'canceled', - '任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is not None: - t['refunded'] = refunded - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - api_service.logger.info(f"播客打包已取消: {task_id}(成功{ok_count} 退回{refunded}次)") - except DownloadException as e: - delivered = ok_count if zip_valid else 0 - refunded = _settle_batch_billing(task_id, delivered) - with DOWNLOAD_TASKS_LOCK: - _t = DOWNLOAD_TASKS.get(task_id) - # 第 100 条(T1 不变量):终态先到先得——已终态(如用户取消/超时兜底) - # 不再被覆盖为 error(堵 canceled→error 非法迁移边) - if _t and _t.get('status') not in ('done', 'error', 'canceled'): - _t['status'] = 'error' - _t['completed_at'] = time.time() # F38:保留期从完成时刻起算 - _t['msg'] = str(e) + (f"(已退回 {refunded} 次下载次数)" if refunded else '') - _t['refunded'] = refunded - elif _t: - _t['refunded'] = refunded - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - api_service.logger.error(f"播客打包失败: {task_id} {e}(退回{refunded}次)") - except Exception as e: - # 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化 - # (DownloadException 的文案是我们自己拼的用户可读文本,保留 str(e)) - api_service.logger.error(f"播客打包异常: {task_id} {e}\n{traceback.format_exc()}") - delivered = ok_count if zip_valid else 0 - refunded = _settle_batch_billing(task_id, delivered) - with DOWNLOAD_TASKS_LOCK: - _t = DOWNLOAD_TASKS.get(task_id) - # 第 100 条(T1 不变量):同上——已终态不再覆盖为 error - if _t and _t.get('status') not in ('done', 'error', 'canceled'): - _t['status'] = 'error' - _t['completed_at'] = time.time() # F38:保留期从完成时刻起算 - _t['msg'] = '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '') - _t['refunded'] = refunded - elif _t: - _t['refunded'] = refunded - if zip_tmp_path: - try: - os.remove(zip_tmp_path) - except OSError: - pass - api_service.logger.error(f"播客打包异常: {task_id} {e}(退回{refunded}次)\n{traceback.format_exc()}") - - -# P2-8:交付结果的延迟清理不再用"每请求 spawn 一个 sleep 35 分钟线程"的方案 -# (高并发下线程堆积;且线程是内存态,重启即丢、无人兜底)。统一交给监控线程: -# 终态任务记录+结果文件+任务目录按 completed_at+35 分钟清理,下载根目录的 -# ZIP/文件另有按 mtime 的缓存清扫双保险(同一 35 分钟口径)。 - - -@app.route('/download/batch_zip/result', methods=['GET', 'POST']) -@auth_system.login_required(free=True) -def download_batch_zip_result(): - """获取批量打包结果:任务完成后流式返回 ZIP;ZIP 发完即删,任务记录按 35 分钟保留期清理""" - try: - data = api_service._safe_get_request_data() - task_id = str(data.get('task_id') or '').strip() - if not task_id: - return APIResponse.error("缺少 task_id 参数") - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS.get(task_id) - if not task or task.get('type') != 'batch': - return APIResponse.error("任务不存在或已过期", 404) - # F05:站点用户是任务的第一归属(device_id 仅辅助),非本人任务 403 - if not _task_owned_by_current_user(task): - return APIResponse.error("无权下载该任务", 403) - status = task.get('status') - result = task.get('result') or {} - has_deliverable = bool(result.get('zip_path')) and os.path.exists(result.get('zip_path')) - if status in ('queued', 'running'): - return APIResponse.error("任务还在进行中,请稍后再试", 202) - if status == 'paused': - return APIResponse.error("任务已暂停,请重新打开页面后恢复", 200) - # F24:取消/失败但已有部分成果的任务,成果必须可领取(钱不白花) - if status in ('canceled', 'error') and not has_deliverable: - return APIResponse.error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'), - 200 if status == 'canceled' else 500) - zip_path = result.get('zip_path') - if not zip_path or not os.path.exists(zip_path): - return APIResponse.error("打包文件不存在或已被清理", 404) - zip_name = result.get('filename') or '批量下载.zip' - response = send_file( - zip_path, as_attachment=True, - download_name=zip_name, - mimetype='application/zip' - ) - # 第 97 条(P3):私有交付物禁止共享缓存存储(no-cache 允许存储后重验) - response.headers['Cache-Control'] = 'no-store' - response.headers['X-Download-Filename'] = quote(zip_name, safe='') - # P2-8:清理统一交监控线程(completed_at + 35 分钟),交付请求不再挂 sleep 线程 - return response - except Exception as e: - api_service.logger.error(f"批量结果下载异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str: - """把共享缓存文件复制/硬链接为任务专属交付文件(F27:任务成果与共享缓存分离, - 任何任务的清理都不会再删掉别的任务的成果)""" - task_dir = api_service.downloads_path / 'tasks' / task_id - task_dir.mkdir(parents=True, exist_ok=True) - dst = task_dir / filename - if dst.exists(): - return str(dst) - try: - os.link(str(src), str(dst)) # 同卷硬链接:零拷贝 - except OSError: - shutil.copy2(str(src), str(dst)) - return str(dst) - - -# 第 91 条(P3):_cleanup_task_dir 死代码删除——worker 直接 rmtree、监控线程直接 -# rmtree,本函数全文件零调用(审计确认)。 - - -def _single_download_worker(task_id: str) -> None: - """后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理""" - import tempfile as _tempfile - - file_path_str: str | None = None - zip_tmp_path: str | None = None - task_dir_path: str | None = None - try: - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS[task_id] - task['status'] = 'running' - task['msg'] = '下载中...' - params = task['params'] - cancel_ev = task['cancel'] - music_id = params['music_id'] - quality = params['quality'] - filename_format = params['filename_format'] - write_meta = params['write_meta'] - meta_fields = params['meta_fields'] - lyric_format = params['lyric_format'] - need_zip = params['need_zip'] - cookies = params['cookies'] - remote_ip = params.get('remote_ip') or '-' - - # 一次预取歌曲完整信息(URL/详情/歌词三接口并行), - # 替代原先 name_v1 + url_v1 + 下载器内部 get_music_info 的重复串行调用 - mi = api_service.downloader.get_music_info(music_id, quality, cookies) - music_info = { - 'id': music_id, - 'name': mi.name, - 'artist_string': mi.artists, - 'album': mi.album, - 'pic_url': mi.pic_url, - 'file_type': mi.file_type, - 'file_size': mi.file_size, - 'duration': mi.duration * 1000, - 'download_url': mi.download_url, - } - base = _build_download_base(filename_format, music_info) - filename = f"{base}.{music_info['file_type']}" - - # 字节级进度回调(限频 0.4s):写进任务 live_percent,/api/batch_progress - # 对运行中的单曲任务优先返回它——原实现 done 按首计数,单曲进度条全程 0% - _cb_ts = {'t': 0.0} - - def _progress_cb(written, total_bytes): - try: - now = time.time() - if now - _cb_ts['t'] < 0.4 and written < (total_bytes or 0): - return - _cb_ts['t'] = now - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if not t or t.get('status') != 'running': - return - if total_bytes: - pct = min(99.0, written / total_bytes * 100) - t['live_percent'] = pct - t['msg'] = (f"下载中 {int(pct)}% · " - f"{api_service._format_file_size(written)}/{api_service._format_file_size(total_bytes)}") - else: - t['msg'] = f"下载中 · 已接收 {api_service._format_file_size(written)}" - except Exception: - pass # 进度上报故障绝不影响下载 - - download_result = api_service.downloader.download_music_file( - music_id, quality, - write_meta=write_meta, - meta_fields=meta_fields, - cookies=cookies, - cancel_event=cancel_ev, - music_info=mi, - progress_cb=_progress_cb, - ) - if not download_result.success: - raise DownloadException(download_result.error_message or "下载失败") - # F42:下载完成后的每一阶段都可能已被取消,发布前统一校验 - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - cache_path = Path(download_result.file_path) - # F27:交付文件用任务专属副本,共享缓存不进清理清单 - file_path_str = _task_delivery_copy(task_id, cache_path, filename) - task_dir_path = str(Path(file_path_str).parent) - - if need_zip: - fd, zip_tmp_path = _tempfile.mkstemp(prefix='single_zip_', suffix='.zip', - dir=str(Path(file_path_str).parent)) - os.close(fd) - with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: - zf.write(file_path_str, arcname=filename) - # 歌词已在预取时一并拿到,无需再请求一次 - if mi.lyric: - try: - zf.writestr(f"{base}.{lyric_format}", _format_lyric(mi.lyric, lyric_format)) - except Exception: - pass - # 封面走共享缓存(与元数据写入共用同一次网络请求) - cover = fetch_cover_bytes(mi.pic_url) - if cover: - zf.writestr(f"{base}.jpg", cover) - info_txt = ( - f"歌曲: {music_info['name']}\n" - f"歌手: {music_info['artist_string']}\n" - f"专辑: {music_info['album']}\n" - f"音质: {api_service._get_quality_display_name(quality)}\n" - ) - zf.writestr("info.txt", info_txt) - # F42:打包阶段同样以取消检查收口 - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - result = { - 'file_path': file_path_str, - 'zip_path': zip_tmp_path, - 'filename': f"{base}.zip", - 'mimetype': 'application/zip', - 'audio_files': [file_path_str], - } - else: - result = { - 'file_path': file_path_str, - 'filename': filename, - 'mimetype': f"audio/{music_info['file_type']}", - 'audio_files': [file_path_str], - } - - # F42:最终发布前的取消检查(末段取消不得覆盖为 done) - if cancel_ev.is_set(): - raise DownloadCanceled("任务已取消") - with DOWNLOAD_TASKS_LOCK: - t = DOWNLOAD_TASKS.get(task_id) - if t is None or t.get('status') in ('canceled', 'error'): - raise DownloadCanceled("任务已取消") - t['status'] = 'done' - t['completed_at'] = time.time() - t['done'] = 1 - t['msg'] = '下载完成' - t['result'] = result - # C1:交付已落定;结算失败不再把任务改标 error(钱由重启对账兜底,宁可商家小损不坑用户) - try: - auth_system.ledger_settle(task_id, 1, 'delivered') - except Exception as _e: - api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}") - try: - _record_log('下载', remote_ip, music_info['name']) - except Exception: - pass - api_service.logger.info(f"单曲下载完成: {filename} ({task_id})") - except DownloadCanceled: - _mark_task_terminal(task_id, 'canceled', '任务已取消') - try: - auth_system.ledger_settle(task_id, 0, 'canceled') - except Exception: - pass - if task_dir_path: - shutil.rmtree(task_dir_path, ignore_errors=True) - api_service.logger.info(f"单曲下载已取消: {task_id}") - except DownloadException as e: - _mark_task_terminal(task_id, 'error', str(e)) - try: - auth_system.ledger_settle(task_id, 0, 'error') - except Exception: - pass - if task_dir_path: - shutil.rmtree(task_dir_path, ignore_errors=True) - api_service.logger.error(f"单曲下载失败: {task_id} {e}") - except Exception as e: - # 第 97 条(P3):非预期异常的 repr 可能含上游主机/连接细节——用户可见文案 - # 泛化为固定文案,原文 logger 先行留档(第 84 条教训:先记录再泛化) - api_service.logger.error(f"单曲下载异常: {task_id} {e}\n{traceback.format_exc()}") - _mark_task_terminal(task_id, 'error', '下载失败,请稍后重试') - try: - auth_system.ledger_settle(task_id, 0, 'error') - except Exception: - pass - if task_dir_path: - shutil.rmtree(task_dir_path, ignore_errors=True) - - -@app.route('/download/result', methods=['GET', 'POST']) -@auth_system.login_required(free=True) -def download_single_result(): - """获取单曲下载结果:任务完成后流式返回文件;交付后按 35 分钟保留期清理""" - try: - data = api_service._safe_get_request_data() - task_id = str(data.get('task_id') or '').strip() - if not task_id: - return APIResponse.error("缺少 task_id 参数") - with DOWNLOAD_TASKS_LOCK: - task = DOWNLOAD_TASKS.get(task_id) - if not task or task.get('type') != 'single': - return APIResponse.error("任务不存在或已过期", 404) - # F05:站点用户是任务的第一归属(device_id 仅辅助),非本人任务 403 - if not _task_owned_by_current_user(task): - return APIResponse.error("无权下载该任务", 403) - status = task.get('status') - if status in ('queued', 'running'): - return APIResponse.error("任务还在进行中,请稍后再试", 202) - if status == 'paused': - # 与批量结果端点同口径:暂停任务给出明确提示(而非 404"文件不存在") - return APIResponse.error("任务已暂停,请重新打开页面后恢复", 200) - result = task.get('result') or {} - target = result.get('zip_path') or result.get('file_path') - if status in ('canceled', 'error') and not (target and os.path.exists(target)): - return APIResponse.error(task.get('msg') or ('任务已取消' if status == 'canceled' else '任务失败'), - 200 if status == 'canceled' else 500) - if not target or not os.path.exists(target): - return APIResponse.error("文件不存在或已被清理", 404) - filename = result.get('filename') or 'download' - response = send_file( - target, as_attachment=True, - download_name=filename, - mimetype=result.get('mimetype') or 'application/octet-stream' - ) - # 第 97 条(P3):私有交付物禁止共享缓存存储(no-cache 允许存储后重验) - response.headers['Cache-Control'] = 'no-store' - response.headers['X-Download-Filename'] = quote(filename, safe='') - # P2-8:任务目录清理统一交监控线程(completed_at + 35 分钟) - return response - except Exception as e: - api_service.logger.error(f"单曲结果下载异常: {e}\n{traceback.format_exc()}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -@app.route('/api/info', methods=['GET']) -def api_info(): - """API信息接口""" - try: - info = { - 'name': '云村小助手API服务', - 'version': APP_VERSION, - 'description': '云村小助手 · 网易云音乐解析/下载服务', - 'endpoints': { - '/health': 'GET - 健康检查', - '/song': 'GET/POST - 获取歌曲信息', - '/search': 'GET/POST - 搜索音乐', - '/playlist': 'GET/POST - 获取歌单详情', - '/album': 'GET/POST - 获取专辑详情', - '/download': 'POST - 下载音乐(付费动作仅接受 POST)', - '/podcast/parse': 'GET/POST - 播客解析(电台/单集)', - '/podcast/programs': 'GET/POST - 播客单集列表(分页)', - '/podcast/search': 'GET/POST - 播客搜索', - '/podcast/hot': 'GET/POST - 热门播客与分类', - '/podcast/download': 'POST - 播客单集下载', - '/podcast/download/batch_zip': 'POST - 整台播客打包ZIP', - '/global/playlists': 'GET/POST - 全局账号歌单列表', - '/global/playlist': 'GET/POST - 全局账号歌单详情', - '/api/playlist/play': 'GET/POST - 公开歌单播放API(id/level/limit)', - '/api/info': 'GET - API信息' - }, - 'supported_qualities': [ - 'standard', 'exhigh', 'lossless', - 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby' - ], - 'config': { - 'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径 - # 第 98 条:原 max_file_size/request_timeout 为架空字段且与实际口径 - # 不符——改为真实生效的请求体上限(业务 2MB/备份上传 200MB) - 'max_body_bytes': '2MB(备份上传 200MB)' - } - } - - return APIResponse.success(info, "API信息获取成功") - - except Exception as e: - api_service.logger.error(f"获取API信息异常: {e}") - return APIResponse.error("服务暂时不可用,请稍后再试", 500) - - -def start_api_server(): - """启动API服务器""" - try: - # F26:启动对账——账本中仍 open 的孤儿任务(上次进程中断遗留)按实扣全额退回。 - # 宽限期 2 小时,避免误伤同库其他存活进程的进行中任务 - try: - recovered = auth_system.ledger_recover_orphans(max_age_seconds=2 * 3600) - if recovered: - api_service.logger.info(f"启动对账:已结算孤儿任务 {recovered} 笔(详见管理日志)") - except Exception as e: - api_service.logger.error(f"启动对账异常: {e}") - # P3-20:清理 90 天前已结算的账本行(只增不删会让表无限膨胀) - try: - auth_system.ledger_cleanup_settled(90 * 86400) - except Exception as e: - api_service.logger.error(f"账本历史清理异常: {e}") - # F27 配套:启动时清扫上次运行遗留的下载缓存(35 分钟口径,P1-3 统一保留期) - _sweep_download_cache(RESULT_RETENTION_SECONDS) - # P2-18:清扫 tasks/ 孤儿目录——交付清理线程与任务记录都是内存态,进程在 - # 保留期内重启会让任务目录无主残留(实测发生过);超过 24 小时的必是孤儿 - try: - tasks_root = api_service.downloads_path / 'tasks' - if tasks_root.exists(): - now = time.time() - for d in tasks_root.iterdir(): - try: - if d.is_dir() and now - d.stat().st_mtime > 24 * 3600: - shutil.rmtree(str(d), ignore_errors=True) - except OSError: - pass - except Exception as e: - api_service.logger.error(f"tasks 孤儿目录清扫异常: {e}") - # 启动设备心跳监控:心跳超时自动暂停离线设备的批量任务,让出排队名额 - threading.Thread(target=_task_monitor_loop, daemon=True).start() - - # 启动数据库定时备份线程(间隔可在管理后台设置) - auth_system.start_auto_backup() - - _safe_console_print("\n" + "="*60) - _safe_console_print("🚀 云村小助手API服务启动中...") - _safe_console_print("="*60) - _safe_console_print(f"📡 服务地址: http://{config.host}:{config.port}") - _safe_console_print(f"📁 下载目录: {api_service.downloads_path.absolute()}") - _safe_console_print(f"📋 日志级别: {config.log_level}") - _safe_console_print("\n📚 API端点:") - _safe_console_print(" ├─ GET /health - 健康检查") - _safe_console_print(" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)") - _safe_console_print(" ├─ POST /song - 获取歌曲信息") - _safe_console_print(" ├─ POST /playlist - 获取歌单详情") - _safe_console_print(" ├─ POST /album - 获取专辑详情") - _safe_console_print(" ├─ POST /toplist - 获取官方榜单列表") - _safe_console_print(" ├─ POST /artist_songs - 获取歌手信息与热门歌曲") - _safe_console_print(" ├─ POST /comments - 获取歌曲评论") - _safe_console_print(" ├─ POST /download - 下载音乐") - _safe_console_print(" ├─ GET/POST /global/playlists - 全局账号歌单列表") - _safe_console_print(" ├─ GET/POST /global/playlist - 全局账号歌单详情") - _safe_console_print(" ├─ GET/POST /api/playlist/play - 公开歌单播放API") - _safe_console_print(" └─ GET /api/info - API信息") - _safe_console_print("\n🎵 支持的音质:") - _safe_console_print(" standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby") - _safe_console_print("="*60) - _safe_console_print(f"⏰ 启动时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") - _safe_console_print("🌟 服务已就绪,等待请求...\n") - - # 第 102 条(owner 拍板):生产服务器 waitress——安装了即自动启用; - # 未安装(开发机零依赖口径)回退 Werkzeug 开发服务器。 - # 注意 trusted_proxy='127.0.0.1':waitress 默认清除"非可信代理"的 - # X-Forwarded-For——Caddy 在本机回环转发属可信,必须放行其 XFF, - # 否则 _client_ip 的 XFF 最右段口径(第 97/100 条)失效。 - try: - from waitress import serve - except ImportError: - api_service.logger.warning( - "未安装 waitress,回退 Werkzeug 开发服务器(生产部署建议:pip install waitress)") - _safe_console_print("⚠️ 未安装 waitress,当前为 Werkzeug 开发服务器(生产环境建议安装)") - app.run( - host=config.host, - port=config.port, - debug=config.debug, - threaded=True - ) - else: - # 第 102 条(对抗复审实锤修复):trusted_proxy 必须配 trusted_proxy_headers—— - # waitress 3.x 只配前者时仅隐式信任 x-forwarded-proto,会把 Caddy 追加的 - # X-Forwarded-For 当"不可信头"从 environ 清掉 → 全站 IP 塌缩 127.0.0.1。 - # 显式信任 XFF 后,waitress 将 XFF 最右段重写进 REMOTE_ADDR,与应用口径一致。 - _safe_console_print(f"🍳 waitress 生产服务器已就绪: {config.host}:{config.port}(16 threads)") - serve( - app, - host=config.host, - port=config.port, - threads=16, - channel_timeout=120, # 弱网下载/大 ZIP 流式响应容限(不活动超时) - trusted_proxy='127.0.0.1', # Caddy 本机反代可信 - trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'}, - clear_untrusted_proxy_headers=True, - ident='NeteaseUrl', # 不暴露 waitress 版本指纹 - ) - - except KeyboardInterrupt: - _safe_console_print("\n\n👋 服务已停止") - except Exception as e: - api_service.logger.error(f"启动服务失败: {e}") - _safe_console_print(f"❌ 启动失败: {e}") - sys.exit(1) - - -if __name__ == '__main__': - start_api_server() - diff --git a/music_api.py b/music_api.py index 3ecedb8..1048998 100644 --- a/music_api.py +++ b/music_api.py @@ -207,7 +207,7 @@ class HTTPClient: except requests.HTTPError as e: # 第 91 条(P2-6):4xx(429/460 风控)不重试——原实现 HTTPError 落入 # 下方 RequestException 分支被重试一次,与 R5-20 意图相反 - raise APIException(f"HTTP请求失败: {e}") + raise APIException(f"HTTP请求失败: {e}") from e except requests.RequestException as e: last_err = e if attempt == 0: @@ -240,7 +240,7 @@ class HTTPClient: return response except requests.HTTPError as e: # 第 91 条(P2-6):4xx 不重试(同上) - raise APIException(f"HTTP请求失败: {e}") + raise APIException(f"HTTP请求失败: {e}") from e except requests.RequestException as e: last_err = e if attempt == 0: @@ -306,7 +306,7 @@ class NeteaseAPI: return result except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析响应数据失败: {e}") + raise APIException(f"解析响应数据失败: {e}") from e async def get_song_detail(self, song_id: int) -> dict[str, Any]: """获取歌曲详细信息 @@ -340,9 +340,9 @@ class NeteaseAPI: return result except requests.RequestException as e: - raise APIException(f"获取歌曲详情请求失败: {e}") + raise APIException(f"获取歌曲详情请求失败: {e}") from e except json.JSONDecodeError as e: - raise APIException(f"解析歌曲详情响应失败: {e}") + raise APIException(f"解析歌曲详情响应失败: {e}") from e async def get_lyric(self, song_id: int, cookies: dict[str, str]) -> dict[str, Any]: """获取歌词信息 @@ -386,9 +386,9 @@ class NeteaseAPI: return result except requests.RequestException as e: - raise APIException(f"获取歌词请求失败: {e}") + raise APIException(f"获取歌词请求失败: {e}") from e except json.JSONDecodeError as e: - raise APIException(f"解析歌词响应失败: {e}") + raise APIException(f"解析歌词响应失败: {e}") from e async def search_music(self, keywords: str, cookies: dict[str, str], limit: int = 10, search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]: @@ -488,9 +488,9 @@ class NeteaseAPI: return items except requests.RequestException as e: - raise APIException(f"搜索请求失败: {e}") + raise APIException(f"搜索请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析搜索响应失败: {e}") + raise APIException(f"解析搜索响应失败: {e}") from e async def get_playlist_detail(self, playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]: """获取歌单详情 @@ -583,9 +583,9 @@ class NeteaseAPI: return info except requests.RequestException as e: - raise APIException(f"获取歌单详情请求失败: {e}") + raise APIException(f"获取歌单详情请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析歌单详情响应失败: {e}") + raise APIException(f"解析歌单详情响应失败: {e}") from e async def get_album_detail(self, album_id: int, cookies: dict[str, str]) -> dict[str, Any]: """获取专辑详情 @@ -639,9 +639,9 @@ class NeteaseAPI: return info except requests.RequestException as e: - raise APIException(f"获取专辑详情请求失败: {e}") + raise APIException(f"获取专辑详情请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析专辑详情响应失败: {e}") + raise APIException(f"解析专辑详情响应失败: {e}") from e async def get_toplist(self) -> list[dict[str, Any]]: """获取网易云官方榜单列表 @@ -680,9 +680,9 @@ class NeteaseAPI: }) return toplists except requests.RequestException as e: - raise APIException(f"获取榜单请求失败: {e}") + raise APIException(f"获取榜单请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析榜单响应失败: {e}") + raise APIException(f"解析榜单响应失败: {e}") from e async def get_artist_hot_songs(self, artist_id: int) -> dict[str, Any]: """获取歌手信息和热门歌曲 @@ -734,9 +734,9 @@ class NeteaseAPI: 'songs': songs } except requests.RequestException as e: - raise APIException(f"获取歌手信息请求失败: {e}") + raise APIException(f"获取歌手信息请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析歌手信息响应失败: {e}") + raise APIException(f"解析歌手信息响应失败: {e}") from e async def get_song_comments(self, song_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]: """获取歌曲评论 @@ -801,9 +801,9 @@ class NeteaseAPI: 'comments': [_comment_dict(c) for c in (result.get('comments') or [])], } except requests.RequestException as e: - raise APIException(f"获取评论请求失败: {e}") + raise APIException(f"获取评论请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析评论响应失败: {e}") + raise APIException(f"解析评论响应失败: {e}") from e async def get_account_info(self, cookies: dict[str, str]) -> dict[str, Any] | None: """获取当前账号信息(用于个人登录状态展示) @@ -905,9 +905,9 @@ class NeteaseAPI: offset += page_size return playlists except requests.RequestException as e: - raise APIException(f"获取用户歌单请求失败: {e}") + raise APIException(f"获取用户歌单请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析用户歌单响应失败: {e}") + raise APIException(f"解析用户歌单响应失败: {e}") from e async def detect_vip_by_probe(self, cookies: dict[str, str], probe_song_id: int = 347230) -> dict[str, Any]: @@ -997,9 +997,9 @@ class NeteaseAPI: raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}") return result except requests.RequestException as e: - raise APIException(f"收藏歌单请求失败: {e}") + raise APIException(f"收藏歌单请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析收藏歌单响应失败: {e}") + raise APIException(f"解析收藏歌单响应失败: {e}") from e async def subscribe_album(self, album_id, cookies: dict[str, str], action: int = 1) -> dict[str, Any]: @@ -1031,9 +1031,9 @@ class NeteaseAPI: raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}") return result except requests.RequestException as e: - raise APIException(f"收藏专辑请求失败: {e}") + raise APIException(f"收藏专辑请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析收藏专辑响应失败: {e}") + raise APIException(f"解析收藏专辑响应失败: {e}") from e async def post_comment(self, thread_id: str, content: str, action: int = 1, comment_id: int | None = None, @@ -1088,9 +1088,9 @@ class NeteaseAPI: raise APIException(f"评论失败: {result.get('message', '未知错误')}") return result except requests.RequestException as e: - raise APIException(f"评论请求失败: {e}") + raise APIException(f"评论请求失败: {e}") from e except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析评论响应失败: {e}") + raise APIException(f"解析评论响应失败: {e}") from e def netease_encrypt_id(self, id_str: str) -> str: """网易云加密图片ID算法 @@ -1170,7 +1170,7 @@ class QRLoginManager: else: raise APIException(f"生成二维码key失败: {result.get('message', '未知错误')}") except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析二维码key响应失败: {e}") + raise APIException(f"解析二维码key响应失败: {e}") from e async def create_qr_login(self) -> str | None: """创建登录二维码并在控制台显示 @@ -1251,7 +1251,7 @@ class QRLoginManager: return result.get('code', -1), cookie_dict except (json.JSONDecodeError, KeyError) as e: - raise APIException(f"解析登录状态响应失败: {e}") + raise APIException(f"解析登录状态响应失败: {e}") from e async def qr_login(self) -> str | None: """完整的二维码登录流程 diff --git a/music_downloader.py b/music_downloader.py index 0dbdbc1..50a38fa 100644 --- a/music_downloader.py +++ b/music_downloader.py @@ -28,9 +28,7 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path -import requests -from requests.adapters import HTTPAdapter - +from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests) from core.async_bridge import run_sync from music_api import APIException, NeteaseAPI, load_cookies @@ -77,13 +75,10 @@ def _sniff_audio_header(path) -> bool: def get_http_session() -> requests.Session: - """获取当前线程的共享 requests.Session(带连接池 + keep-alive)""" + """获取当前线程的共享同步会话(连接池由底层 aiohttp 统一维护)。""" session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None) if session is None: session = requests.Session() - adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0) - session.mount("https://", adapter) - session.mount("http://", adapter) session.headers.update({"User-Agent": DEFAULT_UA}) _SESSION_LOCAL.session = session return session @@ -555,9 +550,9 @@ class MusicDownloader: ) except APIException as e: - raise DownloadException(f"API调用失败: {e}") + raise DownloadException(f"API调用失败: {e}") from e except Exception as e: - raise DownloadException(f"获取音乐信息时发生错误: {e}") + raise DownloadException(f"获取音乐信息时发生错误: {e}") from e # ----------- 文件路径 ----------- diff --git a/podcast_api.py b/podcast_api.py index d7fb63d..d9189ef 100644 --- a/podcast_api.py +++ b/podcast_api.py @@ -21,8 +21,7 @@ import re import time from typing import Any -import requests - +from core import sync_http as requests # 同步门面(底层 aiohttp) from music_api import APIConstants, APIException, url_v1 @@ -69,9 +68,9 @@ def _post_json(url: str, data: dict[str, Any], cookies: dict[str, str] | None = if not isinstance(result, dict): raise APIException('播客接口响应异常(非标准 JSON 对象)') except requests.RequestException as e: - raise APIException(f"播客接口请求失败: {e}") + raise APIException(f"播客接口请求失败: {e}") from e except json.JSONDecodeError as e: - raise APIException(f"解析播客接口响应失败: {e}") + raise APIException(f"解析播客接口响应失败: {e}") from e if result.get('code') not in (200, None): raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}") return result @@ -300,7 +299,7 @@ def get_radio_detail(radio_id: Any, cookies: dict[str, str] | None = None) -> di try: rid = int(str(radio_id).strip()) except (TypeError, ValueError): - raise APIException('无效的电台ID') + raise APIException('无效的电台ID') from None result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies) # 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空 data = result.get('djRadio') or result.get('data') or {} @@ -325,7 +324,7 @@ def get_radio_programs(radio_id: Any, cookies: dict[str, str] | None = None, try: rid = int(str(radio_id).strip()) except (TypeError, ValueError): - raise APIException('无效的电台ID') + raise APIException('无效的电台ID') from None limit = max(1, min(int(limit or 30), 100)) result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API, {'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)), @@ -346,7 +345,7 @@ def get_program_detail(program_id: Any, cookies: dict[str, str] | None = None) - try: pid = int(str(program_id).strip()) except (TypeError, ValueError): - raise APIException('无效的单集ID') + raise APIException('无效的单集ID') from None result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies) program = result.get('program') or {} if not program: @@ -372,9 +371,9 @@ def get_categories() -> list[dict[str, Any]]: raise APIException('播客接口响应体异常(超过大小上限)') result = response.json() except requests.RequestException as e: - raise APIException(f"获取播客分类失败: {e}") + raise APIException(f"获取播客分类失败: {e}") from e except json.JSONDecodeError as e: - raise APIException(f"解析播客分类响应失败: {e}") + raise APIException(f"解析播客分类响应失败: {e}") from e if not isinstance(result, dict): # 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError raise APIException('解析播客分类响应失败(非标准 JSON 对象)') diff --git a/pyproject.toml b/pyproject.toml index 777cb6f..cb09aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,31 @@ name = "yunmusic-assistant" version = "3.0.0" description = "云村小助手 · 网易云音乐解析/下载服务(FastAPI + asyncio + aiohttp)" requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", + "aiohttp>=3.10,<4.0", + "aiosqlite>=0.20,<1.0", + "aiofiles>=24.1", + "itsdangerous>=2.2,<3.0", + "cryptography>=50.0,<51.0", + "qrcode>=7.0,<8.3", + "python-multipart>=0.0.9", + "jinja2>=3.1,<4.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "ruff>=0.6", + "pyright>=1.1.390", + "httpx>=0.27", +] + +# 应用工程(非库):不把自身装进环境,避免 uv 扫描顶层模块 +[tool.uv] +package = false # ============ Ruff:静态检查 + 导入排序 + 格式化 ============ [tool.ruff] @@ -12,52 +37,49 @@ line-length = 120 extend-exclude = [".venv", ".zcode", "templates", "static", "data"] [tool.ruff.lint] -# E/F 基础错误、I 导入排序、B bugbear、UP 升级、ASYNC 异步陷阱、S 安全、RUF +# E/F 基础错误、I 导入排序、B bugbear、UP 升级、C4 推导式、ASYNC 异步陷阱、S 安全、RUF select = ["E", "F", "W", "I", "B", "UP", "C4", "ASYNC", "S", "RUF"] ignore = [ "E501", # 行宽交给 formatter,不强制 - "E402", # main.py 存在「先定义后 import」的历史结构,迁移期暂放行 "S101", # 生产代码外的 assert(测试/断言脚本) "S104", # 绑定 0.0.0.0(服务端有意为之) - "S110", # try/except/pass(本项目大量「失败静默降级」策略,见交接文档) + "S110", # try/except/pass(本项目「失败静默降级」策略) "S112", # try/except/continue "S324", # hashlib.md5 用于非安全场景(上游签名口径) "S603", "S607", # subprocess(打包/运维脚本) "UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定) "RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报) - # ---- 以下为「有意保留」,随文件在迁移中重写时再行处理,非待修缺陷 ---- - "B904", # raise-from 异常链:纯风格,40 处均在历史巨石文件,逐处改写收益低风险高 + # ---- 以下为「有意保留」,已被逐条论证为协议/设计约束,非待修缺陷 ---- "RUF012", # 常量类可变默认值:APIConstants/PodcastConstants 是常量表,非实例状态 "S305", # AES-ECB:网易云 weapi 协议**要求**的加密模式,非可自由选择 - "S311", # randrange:用于生成上游 requestId(协议随机数),非安全用途 + "S311", # randrange:生成上游 requestId(协议随机数),非安全用途 "ASYNC250", # input():qr_login 交互式 CLI 的人类输入,异步化无意义 + "S310", # urllib:仅用于音频 Range 代理的 URL 校验路径 ] [tool.ruff.lint.per-file-ignores] "tests/**" = ["S", "ASYNC"] ".zcode/**" = ["S", "ASYNC", "F"] -"_mkpkg.py" = ["S"] # ASYNC109:timeout 参数是有意 API——直接转发给 aiohttp 的 ClientTimeout, # 不是「用 sleep 当超时」的反模式。 "core/http_client.py" = ["ASYNC109"] "core/aiohttp_compat.py" = ["ASYNC109"] [tool.ruff.lint.isort] -known-first-party = ["core", "web", "music_api", "auth_system"] +known-first-party = ["core", "web", "services", "music_api", "auth_system"] [tool.ruff.format] quote-style = "preserve" # ============ Pyright:类型检查 ============ [tool.pyright] -include = ["core", "web"] +# 全量纳入(Flask 巨石删除后不再有例外清单) +include = ["core", "web", "services"] exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"] venvPath = "." venv = ".venv" pythonVersion = "3.12" typeCheckingMode = "basic" -# 迁移期口径:存量 Flask 巨石(main.py/auth_*.py)暂不纳入严格检查, -# 新写的 core/ 与 web/ 要求类型干净。随迁移推进逐步把模块移入 include。 reportMissingImports = true reportOptionalMemberAccess = "warning" reportAttributeAccessIssue = "warning" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 32e5cfc..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -# 开发依赖(开发/CI 专用,不进生产镜像) -pytest>=8.0 -pytest-asyncio>=0.24 -ruff>=0.6 -pyright>=1.1.390 -httpx>=0.27 diff --git a/requirements-fastapi.txt b/requirements-fastapi.txt deleted file mode 100644 index ab4622c..0000000 --- a/requirements-fastapi.txt +++ /dev/null @@ -1,14 +0,0 @@ -# 生产运行时依赖(FastAPI 版) -# 口径同旧 requirements.txt:加版本上界,防上游大版本静默破坏(第 95 条 P2-6)。 -# -# 迁移说明:本文件是 FASTAPI 目标栈。当前仓库同时保留 requirements.txt(Flask 旧栈) -# 直到路由全部迁移完成、生产服务器切换为止,两者可并存。 -fastapi>=0.115,<1.0 -uvicorn[standard]>=0.30,<1.0 -aiohttp>=3.10,<4.0 -aiosqlite>=0.20,<1.0 -aiofiles>=24.1 -itsdangerous>=2.2,<3.0 -cryptography>=50.0,<51.0 -qrcode>=7.0,<8.3 -python-multipart>=0.0.9 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d0f41bc..0000000 --- a/requirements.txt +++ /dev/null @@ -1,13 +0,0 @@ -# F68/F62:与实测运行环境一致的依赖清单(Python 3.12 / 2026-09 实测版本)。 -# 代码实际直接依赖为以下 5 个包(其余为传递依赖,随包管理自动解析)。 -# 第 95 条(P2-6):加上界——防上游大版本静默破坏生产(Flask 3→4、Werkzeug 接口变更等)。 -# 升级前先在开发机全量验证后再放宽上界。 -# 第 102 条(owner 拍板):新增 waitress 生产服务器(未安装时 main.py 自动回退 -# Werkzeug 开发服务器,开发机可不装)。 -# 第 100 条(P2-5):cryptography 区间以开发机 venv 实装 50.0.1 为准重定——旧 <44 -# 与实测环境矛盾,新机按旧清单会装到未验证的 43.x。 -Flask>=3.0,<4.0 -requests>=2.31,<3.0 -cryptography>=50.0,<51.0 -qrcode>=7.0,<8.3 -waitress>=3.0,<4.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..705b09c --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""服务层:从原 Flask 巨石中剥离的框架无关实现。 + +包含 MusicAPIService、下载/打包 worker、任务登记与监控、下载缓存清扫等。 +不含任何 Flask 依赖(路由层已全部迁至 web/routers)。 +""" diff --git a/services/runtime.py b/services/runtime.py new file mode 100644 index 0000000..3b3784b --- /dev/null +++ b/services/runtime.py @@ -0,0 +1,1957 @@ +# -*- coding: utf-8 -*- +"""网易云音乐API服务主程序 + +提供网易云音乐相关API服务,包括: +- 歌曲信息获取 +- 音乐搜索 +- 歌单和专辑详情 +- 音乐下载 +- 健康检查 +""" + +import collections +import logging +import os +import re +import shutil +import sys +import threading +import time +import traceback +import zipfile +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +try: + import auth_system + import podcast_api + from music_api import ( + APIException, + NeteaseAPI, + account_info, + detect_vip_by_probe, + load_cookies, + ) + from music_downloader import ( + CountingSemaphore, + DownloadCanceled, + DownloadException, + MusicDownloader, + fetch_cover_bytes, + ) + from qr_login import QRLoginClient +except ImportError as e: + # 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印 + print("ImportError: " + str(e)) + print("Please make sure all dependency modules exist and are importable.") + sys.exit(1) + + +# ============ 框架无关核心层(core/)============ +# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现** +# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。 +# QRLoginManager 等已异步化的上游方法,Flask 同步路由经 run_sync 桥接调用。 +from core.config import ( + BATCH_ZIP_CONCURRENCY, + CANCEL_AFTER_SECONDS, + HEARTBEAT_INTERVAL, + PAUSE_AFTER_SECONDS, + RESULT_RETENTION_SECONDS, + USER_COOKIE_FILE, + Settings, +) +from core.state import ( + BATCH_DURATIONS, + BATCH_DURATIONS_LOCK, + DEVICE_LAST_SEEN, + DEVICE_LAST_SEEN_LOCK, + DOWNLOAD_TASKS, + DOWNLOAD_TASKS_LOCK, + REQ_LOG, +) +from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE +from core.state import RATE_BUCKETS as _RATE_BUCKETS +from core.state import RATE_LOCK as _RATE_LOCK +from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK +from core.utils import expand_shortlink as _expand_shortlink +from core.utils import format_lyric as _format_lyric + +# 向后兼容别名:既有代码/文档沿用 APIConfig 名称 +APIConfig = Settings + + +class APIResponse: + """API响应工具类""" + + @staticmethod + def success(data: Any = None, message: str = 'success', status_code: int = 200) -> tuple[dict[str, Any], int]: + """成功响应""" + response = { + 'status': status_code, + 'success': True, + 'message': message + } + if data is not None: + response['data'] = data + return response, status_code + + @staticmethod + def error(message: str, status_code: int = 400, error_code: str | None = None) -> tuple[dict[str, Any], int]: + """错误响应""" + response = { + 'status': status_code, + 'success': False, + 'message': message + } + if error_code: + response['error_code'] = error_code + return response, status_code + + +class MusicAPIService: + """音乐API服务类""" + + def __init__(self, config: APIConfig): + self.config = config + self.logger = self._setup_logger() + self.netease_api = NeteaseAPI() + # F97:下载目录传给下载器——原实现下载器固定 downloads, + # 服务配置指向自定义目录时音乐落盘位置与配置分离 + self.downloader = MusicDownloader(download_dir=config.downloads_dir) + + # 创建下载目录 + self.downloads_path = Path(config.downloads_dir) + self.downloads_path.mkdir(exist_ok=True) + + self.logger.info(f"音乐API服务初始化完成,下载目录: {self.downloads_path.absolute()}") + + def _setup_logger(self) -> logging.Logger: + """设置日志记录器""" + logger = logging.getLogger('music_api') + logger.setLevel(getattr(logging, self.config.log_level.upper())) + + if not logger.handlers: + # 控制台处理器 + console_handler = logging.StreamHandler() + console_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀) + try: + from logging.handlers import RotatingFileHandler + file_handler = RotatingFileHandler('music_api.log', maxBytes=5 * 1024 * 1024, + backupCount=3, encoding='utf-8') + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + ) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + except Exception as e: + logger.warning(f"无法创建日志文件: {e}") + + return logger + + def _get_cookies(self) -> dict[str, str]: + """直接从 cookie.txt 读取并解析为 dict""" + try: + cookies = load_cookies() + if not cookies: + self.logger.warning("cookie.txt 为空或不存在,部分接口(VIP 音质)将不可用") + return cookies + except Exception as e: + self.logger.error(f"读取 cookie.txt 异常: {e}") + return {} + + def _extract_music_id(self, id_or_url: str) -> str: + """提取音乐ID(F7/F74:正规 URL 解析替代子串/删除非数字启发式) + - 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF) + - music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关) + """ + raw = str(id_or_url or '').strip() + try: + # 短链展开:仅接受"整个输入就是 163cn.tv 链接"(F7:子串包含不算) + if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE): + raw = _expand_shortlink(raw) + + # 网易云链接:正规解析 query 取 id(F74:不再 find('id=') 误吃 userid= 尾部) + if 'music.163.com' in raw: + try: + q = parse_qs(urlsplit(raw).query) + for key in ('id', 'songid'): + if q.get(key): + return str(q[key][0]).strip() + except Exception: + pass + # 兜底:老格式 #/song?id=xxx 的片段部分 + m = re.search(r'[?&#]id=(\d+)', raw) + if m: + return m.group(1) + + # 纯数字/普通文本直接返回 + return raw + except Exception as e: + self.logger.error(f"提取音乐ID失败: {e}") + return str(id_or_url or '').strip() + + def _format_file_size(self, size_bytes) -> str: + """格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)""" + try: + size = float(size_bytes or 0) + except (TypeError, ValueError): + return "大小未知" + if size <= 0: + return "0B" + + units = ["B", "KB", "MB", "GB", "TB"] + unit_index = 0 + + while size >= 1024.0 and unit_index < len(units) - 1: + size /= 1024.0 + unit_index += 1 + + return f"{size:.2f}{units[unit_index]}" + + def _get_quality_display_name(self, quality: str) -> str: + """获取音质显示名称""" + quality_names = { + 'standard': "标准音质", + 'exhigh': "极高音质", + 'lossless': "无损音质", + 'hires': "Hi-Res音质", + 'sky': "沉浸环绕声", + 'jyeffect': "高清环绕声", + 'jymaster': "超清母带", + 'dolby': "杜比全景声" + } + return quality_names.get(quality, f"未知音质({quality})") + + +def _quiesce_tasks_for_restore() -> None: + """S5:恢复数据库前取消全部在途任务(worker 不再引用旧库用户结算)""" + with DOWNLOAD_TASKS_LOCK: + for task in DOWNLOAD_TASKS.values(): + if task.get('status') in ('queued', 'running', 'paused'): + task['status'] = 'canceled' + task['msg'] = '数据库恢复中,任务已取消(费用按实际交付结算)' + task['completed_at'] = time.time() + try: + task['cancel'].set() + except Exception: + pass + +# 数据库恢复前静默在途任务(与 Flask 版同口径) +auth_system.register_before_restore_hook(_quiesce_tasks_for_restore) + +# 服务实例(框架无关) +config = APIConfig() +api_service = MusicAPIService(config) + + + +def _rate_ok(key: str, limit: int, window: int) -> bool: + """滑动窗口限流:True=放行,False=超限""" + now = time.time() + with _RATE_LOCK: + dq = _RATE_BUCKETS.get(key) + if dq is None: + # 桶数量防膨胀:超限时清理不活跃桶 + if len(_RATE_BUCKETS) > 8000: + for k in [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > window]: + _RATE_BUCKETS.pop(k, None) + dq = collections.deque() + _RATE_BUCKETS[key] = dq + while dq and now - dq[0] > window: + dq.popleft() + if len(dq) >= limit: + return False + dq.append(now) + return True + +# 旧版管理员口令已废除(2026-09-15):管理员权限仅认站点管理员会话(admin 登录), +# 见 _verify_admin_password。管理后台(/admin/site/*)与旧版二维码接口(/admin/qr/*) +# 均通过该会话鉴权。 + +# 全局批量打包并发信号量:限制同时构建的批量 ZIP 数量。 +# 大量用户同时批量下载时,超出的请求排队等待,避免磁盘/内存/CPU 被同时占满。 +# (下载本身的并发已由下载器内的全局信号量控制) +# 使用带计数的信号量,供前端实时展示排队人数与预计等待时间。 +# 注:信号量依赖下载器的 CountingSemaphore,属服务层对象,随下载服务搬迁, +# 暂留 main(并发上限 3 见 core.config.BATCH_ZIP_CONCURRENCY)。 +BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY) + + +def _start_task_thread(task_id: str, target, args=()) -> str | None: + """启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。 + A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。 + R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死 + (无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。""" + try: + th = threading.Thread(target=target, args=args, daemon=True) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['worker_thread'] = th + th.start() + return None + except Exception as e: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.pop(task_id, None) + if t: + try: + auth_system.ledger_settle(task_id, 0, 'submit_failed') + except Exception: + pass + api_service.logger.error(f"任务线程启动失败: {task_id} {e}") + return f'任务启动失败:{e}' + + +def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None: + """任务进入终态的统一入口(F38:记录 completed_at,保留期从完成时刻起算, + 而不是从提交时刻起算)。 + R5-14/F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算), + 代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。 + 第 100 条(T1 不变量):终态先到先得——已终态(done/error/canceled)的任务 + 不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败 + 不得把"已取消"改写成"失败",completed_at 也不再刷新)。""" + need_settle = False + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t and t.get('status') in ('done', 'error', 'canceled'): + return + if t: + t['status'] = status + t['msg'] = msg or t.get('msg', '') + t['completed_at'] = time.time() + if status in ('canceled', 'error'): + th = t.get('worker_thread') + if th is not None and not th.is_alive(): + need_settle = True + # 第 97 条(P2-3):settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库 + # (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆; + # settle 幂等,锁外调用无双退 + if need_settle: + try: + auth_system.ledger_settle(task_id, 0, status) + except Exception: + pass + + +def _task_files(task: dict[str, Any]) -> list[str]: + """任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)""" + result = task.get('result') or {} + files = [] + for f in (result.get('zip_path'), result.get('file_path')): + if f: + files.append(f) + return files + + +def _sweep_download_cache(max_age_seconds: int = RESULT_RETENTION_SECONDS) -> None: + """下载缓存清扫(F27 配套:批量任务不再即时删除共享缓存,改由这里按年龄回收)。 + 只清扫 downloads 根目录的普通文件,不碰 tasks/ 子目录(那是任务专属交付物)。""" + try: + root = api_service.downloads_path + now = time.time() + for p in root.iterdir(): + try: + if p.is_file() and now - p.stat().st_mtime > max_age_seconds: + p.unlink() + except OSError: + pass + except Exception: + pass + + +_MONITOR_ERR_TS = {'ts': 0.0, 'n': 0} # 监控线程异常限频记录(持续异常不静默也不刷屏) +# 第 95 条(P1-1):监控线程周期兜底的三个节流字典——第 94 条补丁时定义丢失 +# (只剩使用),NameError 被外层 except 吞成 WARNING,孤儿对账/账本清理/tasks +# 补扫自那以后从未执行(music_api.log 实证 2064 条 WARNING)。红转绿:重启后 +# 日志零 NameError 且对账真实运行。 +_LEDGER_LAST_RECOVER = {'ts': 0.0} # 上次孤儿账本对账时间(每 60s 一次) +_LEDGER_LAST_CLEANUP = {'ts': 0.0} # 上次 settled 账本 90 天清理时间(每 24h 一次) +_TASKS_SWEEP_LAST = {'ts': 0.0} # 上次 tasks/ 孤儿目录补扫时间(每小时一次) + + +def _task_monitor_loop() -> None: + """后台监控: + - 心跳超时(40s)→ 暂停离线设备的批量任务,释放排队名额给下一位 + - 心跳超时 > 30 分钟 → 自动取消该设备的所有未完成任务并清理文件 + - 终态任务按 completed_at 保留 35 分钟(F38/P1-3:与前端「领取」入口对齐) + - 运行中/排队/暂停任务超过 4 小时仍未结束 → 置取消(F38:cancel Event 一并置位) + - 下载缓存按年龄清扫(F27 配套,同为 35 分钟口径) + - 顺带清理限流桶中的过期项(O-5:不再只依赖"新建桶时"的清理时机) + """ + while True: + time.sleep(HEARTBEAT_INTERVAL) + try: + now = time.time() + # ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)---- + # 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录 + # 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆 + finished_done: list[str] = [] + finished_retry: list[str] = [] + expired: list[tuple] = [] + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + if task.get('status') not in ('done', 'error', 'canceled'): + continue + done_at = task.get('completed_at') or 0 + # 兼容无 completed_at 的旧记录:回退到 start + if not done_at: + done_at = task.get('start', 0) + if now - done_at <= RESULT_RETENTION_SECONDS: + continue + expired.append((tid, _task_files(task), + api_service.downloads_path / 'tasks' / tid)) + cleanup_results: list[tuple] = [] + for tid, files, task_dir in expired: + all_removed = True + for f in files: + try: + os.remove(f) + except FileNotFoundError: + pass + except OSError: + all_removed = False # F106:文件暂时不可删(被占用等) + if task_dir.exists(): + shutil.rmtree(str(task_dir), ignore_errors=True) + if task_dir.exists(): + all_removed = False + cleanup_results.append((tid, all_removed)) + with DOWNLOAD_TASKS_LOCK: + for tid, all_removed in cleanup_results: + task = DOWNLOAD_TASKS.get(tid) + if task is None: + finished_done.append(tid) # 锁外期间已被其他路径移除 + continue + if all_removed: + finished_done.append(tid) + else: + task['_cleanup_tries'] = int(task.get('_cleanup_tries') or 0) + 1 + if task['_cleanup_tries'] >= 8: + # 重试多次仍失败:放弃记录但大声记录日志(文件留给缓存清扫兜底) + api_service.logger.warning( + f"任务 {tid} 结果文件多次清理失败,已放弃(文件可能被占用)") + finished_done.append(tid) + else: + finished_retry.append(tid) + for tid in finished_done: + DOWNLOAD_TASKS.pop(tid, None) + if finished_done: + api_service.logger.info(f"监控线程清理过期终态任务: {finished_done}") + if finished_retry: + api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}") + # ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)---- + timeout_settle: list[str] = [] + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + if (task.get('status') in ('queued', 'running', 'paused') + and now - task.get('start', 0) > 4 * 3600): + task['status'] = 'canceled' + task['msg'] = '任务超时未完成,已自动取消(费用按实际交付结算)' + task['completed_at'] = time.time() + try: + task['cancel'].set() + except Exception: + pass + # R5-14:worker 线程已死则无人再走终态结算——监控代为按账本 + # 结算退款(settle 幂等:worker 若已结算过,此处返回 0) + th = task.get('worker_thread') + if th is not None and not th.is_alive(): + timeout_settle.append(tid) + api_service.logger.warning(f"任务超时自动取消: {tid}") + # 第 97 条(P2-3):结算移到任务锁外(同 _mark_task_terminal 口径)—— + # 库忙 15s 不再随任务锁波及全站任务端点 + for tid in timeout_settle: + try: + auth_system.ledger_settle(tid, 0, 'timeout') + except Exception: + pass + # ---- 下载缓存清扫(保留期同交付口径 35 分钟)+ 限流桶过期项顺手清理(O-5)---- + _sweep_download_cache(RESULT_RETENTION_SECONDS) + _sweep_rate_buckets() + # ---- 第 91→93 条(P2-1/P2-2):账本周期兜底(原仅启动执行一次)---- + # 孤儿对账:长运行进程内 open 且账龄 > 5h(严格大于任务 4h 硬超时 + 监控 + # tick 抖动——账本行 created_at 整秒截断使账龄恒 ≥ 任务龄,旧 4h 相等口径 + # 存在抢退合法 4h+ 长任务的窗口,第 97 条 P2-4)的行每分钟周期检查退回; + # settled 行 90 天清理改为每小时低频执行,防长运行进程下表无界增长 + try: + # 第 94 条(P3-1):时间戳门槛替代 tick 取模——单轮工作段超过周期时 + # 取模落点会被整段跳过,时间戳门槛只延迟不漏跑 + if now - _LEDGER_LAST_RECOVER['ts'] > 60: + _LEDGER_LAST_RECOVER['ts'] = now + auth_system.ledger_recover_orphans(max_age_seconds=5 * 3600) + if now - _LEDGER_LAST_CLEANUP['ts'] > 24 * 3600: + _LEDGER_LAST_CLEANUP['ts'] = now + auth_system.ledger_cleanup_settled(90 * 86400) + except Exception as e: + api_service.logger.warning(f"账本周期兜底异常(下轮重试): {e}") + # ---- 第 91→94 条(P3-3):tasks/ 孤儿目录每小时低频补扫(>24h 且无记录)---- + try: + if now - _TASKS_SWEEP_LAST['ts'] > 3600: + _TASKS_SWEEP_LAST['ts'] = now + tasks_root = api_service.downloads_path / 'tasks' + if tasks_root.exists(): + for d in tasks_root.iterdir(): + try: + if (d.is_dir() and d.name not in DOWNLOAD_TASKS + and now - d.stat().st_mtime > 24 * 3600): + shutil.rmtree(str(d), ignore_errors=True) + api_service.logger.info(f"tasks 孤儿目录补扫移除: {d.name}") + except FileNotFoundError: + pass # 第 94 条(P3-3):单条竞态不中止整轮扫描 + except OSError: + pass + except Exception as e: + api_service.logger.warning(f"tasks 孤儿目录补扫异常(下轮重试): {e}") + # ---- 设备心跳离线处理 ---- + with DEVICE_LAST_SEEN_LOCK: + offline = {d: now - ts for d, ts in DEVICE_LAST_SEEN.items() if now - ts > PAUSE_AFTER_SECONDS} + if not offline: + continue + with DOWNLOAD_TASKS_LOCK: + for tid, task in DOWNLOAD_TASKS.items(): + dev = task.get('device_id') + gap = offline.get(dev) if dev else None + if not gap: + continue + st = task.get('status') + if gap > CANCEL_AFTER_SECONDS and st in ('queued', 'running', 'paused'): + # 离线超 30 分钟:请求取消(C3:文件删除与退款统一交给 worker 的 + # 取消收尾/终态清理;worker 已不在时由启动对账按账本兜底) + task['status'] = 'canceled' + task['msg'] = '设备离线超过 30 分钟,任务已自动取消' + task['completed_at'] = time.time() + try: + task['cancel'].set() + except Exception: + pass + api_service.logger.info(f"设备离线超 30 分钟自动请求取消: {tid}") + elif task.get('type') == 'batch' and st in ('queued', 'running'): + # 心跳超时:先暂停,让出排队名额给下一位 + task['status'] = 'paused' + task['msg'] = '设备离线,任务暂停(重新打开页面自动恢复)' + task['pause'].set() + except Exception: + # 持续异常不能完全静默(否则监控失能无人知晓):限频记录,每 10 分钟最多一条 + _now = time.time() + _MONITOR_ERR_TS['n'] += 1 + if _now - _MONITOR_ERR_TS['ts'] > 600: + api_service.logger.warning( + f"任务监控循环异常(近 10 分钟累计 {_MONITOR_ERR_TS['n']} 次," + f"最后异常见堆栈): {traceback.format_exc()[-600:]}") + _MONITOR_ERR_TS['ts'] = _now + _MONITOR_ERR_TS['n'] = 0 + + +def _sweep_rate_buckets() -> None: + """限流桶周期清扫(O-5):把超过 10 分钟无新请求的桶移出,防止低流量长期运行下 + 缓慢累积(原实现只在"新建第 8001 个桶"时才触发清理,时机太晚)""" + now = time.time() + with _RATE_LOCK: + stale = [k for k, v in _RATE_BUCKETS.items() if not v or now - v[-1] > 600] + for k in stale: + _RATE_BUCKETS.pop(k, None) + + +def _touch_device(device_id: str) -> None: + """记录设备心跳时间(P2-10:设备表容量上限——device_id 是客户端任意串, + 登录用户可提交海量随机 ID 使字典无界增长,超限清最旧; + 第 98 条:入口已统一钳 64,此处兜底再钳一次防新调用点绕过)""" + device_id = str(device_id or '')[:64] + if not device_id: + return + with DEVICE_LAST_SEEN_LOCK: + if len(DEVICE_LAST_SEEN) >= 5000: + # 保留最近心跳的设备:按最后心跳时间排序,砍掉最旧的一半 + for d in sorted(DEVICE_LAST_SEEN, key=lambda k: DEVICE_LAST_SEEN.get(k, 0.0))[:2500]: + DEVICE_LAST_SEEN.pop(d, None) + DEVICE_LAST_SEEN[device_id] = time.time() + + +# _safe_int / _safe_console_print 已迁出至 core.utils(此处 import 同名共享) + + +# ============ 接口请求统计 ============ +# REQUEST_STATS / _STATS_LOCK / REQ_LOG 已迁出至 core.state(此处 import 同名共享) + +# 只统计真实业务接口(排除首页/健康检查/统计/账号探测等内部调用) +# F09:兼容别名(/Search、/Song_V1、/Playlist、/Album、/Download)一并纳入统计与限流, +# 防止用大写别名绕过业务限流 +COUNTED_PATHS = { + '/song', '/search', '/playlist', '/album', '/toplist', '/artist_songs', + '/comments', '/download', '/download/batch_zip', + '/podcast/parse', '/podcast/programs', '/podcast/search', '/podcast/hot', + '/podcast/download', '/podcast/download/batch_zip', + '/user/playlists', '/user/playlist', '/user/status', '/user/logout', + '/user/qr/create', '/user/qr/check', + # P1-2/P2-17/P3-6:账号探测与个人操作端点此前漏在限流名单外—— + # /account/vip 每请求最多 9 次上游探测、/user/comment 与 /user/collect 携 + # 任意 cookie 触发上游 weapi、/auth/me 可匿名无限刷,全部纳入免费 30/分桶 + '/account/vip', '/user/collect', '/user/comment', '/auth/me', + '/admin/qr/create', '/admin/qr/check', '/admin/cookie/status', + '/admin/cookie/delete', '/admin/logs', +'/admin/site/play_key/get', '/admin/site/play_key/rotate', + '/global/playlists', '/global/playlist', '/api/playlist/play', + '/song_v1', +} +_COUNTED_LOWER = {p.lower() for p in COUNTED_PATHS} + +ENDPOINT_NAMES = { + '/song': '歌曲信息', '/search': '搜索', '/playlist': '歌单详情', + '/album': '专辑详情', '/toplist': '榜单列表', '/artist_songs': '歌手歌曲', + '/comments': '评论', '/download': '音乐下载', '/download/batch_zip': '批量ZIP', + '/health': '健康检查', '/api/info': '服务信息', '/api/topen': '请求统计', + '/account/vip': '账号VIP信息', + '/podcast/parse': '播客解析', '/podcast/programs': '播客单集列表', + '/podcast/search': '播客搜索', '/podcast/hot': '热门播客', + '/podcast/download': '播客单集下载', '/podcast/download/batch_zip': '播客整台打包', + '/user/playlists': '个人歌单', '/user/playlist': '个人歌单详情', + '/user/status': '个人登录状态', '/user/logout': '个人退出', + '/user/qr/create': '个人二维码', '/user/qr/check': '个人登录轮询', + '/admin/qr/create': '管理员二维码', '/admin/qr/check': '管理员登录轮询', + '/admin/cookie/status': 'Cookie状态', '/admin/cookie/delete': 'Cookie管理', + '/admin/logs': '操作记录', + '/global/playlists': '全局账号歌单', '/global/playlist': '全局歌单详情', + '/api/playlist/play': '公开歌单播放API', +} + +# 用户解析/下载操作记录(IP)——REQ_LOG 已迁出至 core.state(此处 import 同名共享) + + +def _record_log(action: str, ip: str, song: str) -> None: + """记录用户操作(解析/下载)。P3-4:歌名截断(来自上游/用户输入,不设上限会撑内存与后台页)""" + REQ_LOG.append({ + 'action': action, + 'ip': ip, + 'song': str(song or '')[:100], + 'time': time.strftime('%Y-%m-%d %H:%M:%S'), + }) + if len(REQ_LOG) > 2000: + del REQ_LOG[:len(REQ_LOG) - 2000] + + +def _parse_cookie_str(raw) -> dict[str, str]: + """将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典""" + cookies: dict[str, str] = {} + if not raw: + return cookies + for part in str(raw).split(';'): + if '=' in part: + k, v = part.strip().split('=', 1) + if k: + cookies[k] = v + return cookies + + +# _GLOBAL_MUSIC_U_CACHE 已迁出至 core.state(此处 import 同名共享) + + +def _global_music_u() -> str: + """全局账号 MUSIC_U(5 分钟缓存;后台重新扫码后自动跟随新值)""" + now = time.time() + if _GLOBAL_MUSIC_U_CACHE['v'] is None or now - _GLOBAL_MUSIC_U_CACHE['ts'] > 300: + try: + ck = api_service._get_cookies() + _GLOBAL_MUSIC_U_CACHE['v'] = (ck.get('MUSIC_U') or '').strip() + except Exception: + _GLOBAL_MUSIC_U_CACHE['v'] = '' + _GLOBAL_MUSIC_U_CACHE['ts'] = now + return _GLOBAL_MUSIC_U_CACHE['v'] + + +def _build_download_base(filename_format: str, music_info: dict[str, Any]) -> str: + """按文件名格式生成下载基础名(不含扩展名)""" + name = music_info.get('name', '未知歌曲') + artist = music_info.get('artist_string', '') or '未知歌手' + + def _safe(s: str) -> str: + # P2-14:交付文件名必须剔除控制字符——歌名含 \r\n 时交付头的 + # Content-Disposition 会抛 ValueError,任务完成已扣费但领取必 500 + # (F79 当年只修了下载器磁盘名这条同型路径,交付名漏网) + s = re.sub(r'[\x00-\x1f\x7f]', '', s) + return ''.join(c for c in s if c not in r'<>:"/\|?*').strip() or 'unknown' + + if filename_format == 'song': + return _safe(name) + if filename_format == 'song_artist': + return _safe(f"{name} - {artist}") + return _safe(f"{artist} - {name}") # artist_song(默认) + + +def _vip_name(vip_type: int) -> str: + """根据 vipType 返回会员类型名称(兼容新旧取值)""" + return { + 0: '普通用户', + 1: '音乐包', + 11: '黑胶VIP', + 100: '黑胶VIP', + 10: '黑胶SVIP', + 110: '黑胶SVIP' + }.get(vip_type, '普通用户') + + +def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: + """检测账号会员类型与可解析音质: + 优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。 + 第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行 + 8 级探测(最坏 16 分钟线程滞留),等锁超时者直接用账号接口给降级结果""" + if not _VIP_PROBE_LOCK.acquire(timeout=0.5): + info = account_info(cookies) + if info: + return info.get('vipType', 0), 'account', [] + return 0, 'probe', [] + try: + probe = detect_vip_by_probe(cookies) + levels_ok = probe.get('levels_ok', []) + info = account_info(cookies) + if info: + return info.get('vipType', 0), 'account', levels_ok + return probe.get('vip_type', 0), 'probe', levels_ok + finally: + _VIP_PROBE_LOCK.release() + + +# 高频轮询/流式端点:不写逐请求日志(3s 排队轮询、1.5s 进度轮询、15s 心跳、 +# Range 分段流、错误上报都会在忙碌站点把 5MB×3 的轮转日志几小时刷一圈,排障信息被冲掉)。 +# 用户主动动作(任务列表/取消)保留日志。 +_QUIET_LOG_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat', + '/audio_stream', '/api/fe_error'} + + +def _podcast_billing_count() -> int: + """播客打包计费:指定单集列表按数量扣;整台电台提交时先扣 1 次, + worker 解析完实际单集数后补扣差额(一首单集 = 1 次) + F22:与路由完全同口径——ids 模式先做与路由一致的 limit 截断,避免多扣""" + try: + data = api_service._safe_get_request_data() + if data.get('radio_id'): + return 1 + try: + limit = int(data.get('limit', 200) or 200) + except (TypeError, ValueError): + limit = 200 + limit = max(1, min(limit, 500)) + raw = data.get('ids') + if isinstance(raw, str): + raw_list = raw.split(',') + else: + raw_list = list(raw or []) + # 与路由完全同口径(提取→去重):URL 形式与纯数字形式的同一单集不再重复计费 + ids, seen = [], set() + for x in raw_list: + try: + mid = api_service._extract_music_id(x) + except Exception: + mid = str(x).strip() + if mid and mid not in seen: + seen.add(mid) + ids.append(mid) + return max(1, min(len(ids), limit)) + except Exception: + return 1 + + +def _podcast_safe_name(s: str, fallback: str = 'unknown') -> str: + """文件名安全化(去除非法字符)""" + s = ''.join(c for c in str(s or '') if c not in r'<>:"/\|?*' and ord(c) >= 32).strip() + return s or fallback + + + +class _SyncStreamResponse: + """同步流式响应包装(把 aiohttp 的异步流按需拉成同步迭代)。 + + 供流式下载使用:worker 是同步线程,这里经 core.async_bridge 在后台事件循环 + 逐块取数据,避免引入 requests 依赖(Flask 已删除、requests 已移出依赖)。 + """ + + def __init__(self, url: str, timeout: float): + from core import async_bridge + self._bridge = async_bridge + self._url = url + self._timeout = timeout + self._agent = None + self.status = 0 + self.headers: dict[str, str] = {} + self._resp = None + + def __enter__(self): + self._agent = self._bridge.run_sync(self._open()) + self.status = self._agent[0] + self.headers = self._agent[1] + return self + + async def _open(self): + from core.http_client import client + resp = await client.open('GET', self._url, headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Safari/537.36', + 'Referer': 'https://music.163.com/', + }, timeout=self._timeout) + return resp.status, dict(resp.headers), resp + + def iter_content(self, chunk_size: int = 256 * 1024): + assert self._agent is not None + resp = self._agent[2] + it = resp.content.iter_chunked(chunk_size) + while True: + try: + chunk = self._bridge.run_sync(it.__anext__()) + except StopAsyncIteration: + break + yield chunk + + def __exit__(self, *exc): + if self._agent is not None and self._agent[2] is not None: + try: + self._agent[2].close() + except Exception: + pass + return False + + +def _stream_open(url: str, timeout: float = 60) -> _SyncStreamResponse: + """打开同步可迭代的流式响应(见 _SyncStreamResponse)。""" + return _SyncStreamResponse(url, timeout) + +def _podcast_stream_download(url: str, target: Path, total_size: int, + cancel_ev: threading.Event, + task_id: str = '', progress_label: str = '') -> int: + """流式下载播客音频到 target(先写 .part 再改名),返回实际字节数 + + 自动加入全局下载信号量排队;每 0.5s 更新一次任务进度消息。 + """ + done = 0 + last_update = 0.0 + part = target.with_name(target.name + '.part') + # B2:等待全局下载名额期间可被取消(与音乐批量 BATCH_ZIP_SEM 同语义) + sem = api_service.downloader._download_semaphore + if not sem.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + with _stream_open(url, timeout=60) as r: + if r.status >= 400: + raise DownloadException(f"上游返回 {r.status}") + total = total_size or int(r.headers.get('Content-Length') or 0) + with open(part, 'wb') as f: + for chunk in r.iter_content(chunk_size=256 * 1024): + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + if chunk: + f.write(chunk) + done += len(chunk) + now = time.time() + if now - last_update > 0.5: + last_update = now + if total: + pct = min(99, int(done / total * 100)) + msg = f"{progress_label} {pct}% · {api_service._format_file_size(done)}/{api_service._format_file_size(total)}" + else: + msg = f"{progress_label} 已下载 {api_service._format_file_size(done)}" + if task_id: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t and t['status'] == 'running': + t['msg'] = msg + # 单集任务的字节级进度(批量任务保持按集数口径) + if t.get('type') == 'single' and total: + t['live_percent'] = min(99.0, float(pct)) + finally: + sem.release() + if part.exists(): + part.replace(target) + # 完整性校验:CDN 提前断流时 iter_content 会"正常"结束,文件静默截断 + # (F29 同口径:API 报告的期望大小时精确相等——95% 容差会交付截断的坏文件还扣次数; + # 期望未知时退化为非空校验,Content-Length 兜底值可能是压缩口径不做精确比对) + if total_size and done != total_size: + try: + os.remove(str(target)) + except OSError: + pass + raise DownloadException(f"下载不完整({done}/{total_size} 字节),请重试") + if not total_size and done <= 0: + try: + os.remove(str(target)) + except OSError: + pass + raise DownloadException("下载内容为空,请重试") + return done + + +def _podcast_resolve_audio(program: dict[str, Any], quality: str, + cookies: dict[str, str]) -> dict[str, Any]: + """解析单集音频直链,失败抛 DownloadException""" + song_id = program.get('main_song_id') + if not song_id: + raise DownloadException("该单集缺少音频信息(mainSong)") + url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) + if not url_info or not url_info.get('url'): + raise DownloadException("未获取到音频直链(单集可能已下架或受版权限制)") + return url_info + + +# ============ 管理员设置(二维码登录 / Cookie 管理) ============ + +def _admin_client() -> QRLoginClient: + """创建二维码登录客户端(基于 cookie.txt)""" + return QRLoginClient() + + +# ============ 个人歌单(游客扫码登录自己的账号) ============ + +def _user_client() -> QRLoginClient: + """创建个人用户二维码登录客户端(第 91 条后不再落盘 user_cookie.txt, + USER_COOKIE_FILE 仅作 QRLoginClient 构造参数遗留)""" + return QRLoginClient(USER_COOKIE_FILE) + + +def _billing_ids_count() -> int: + """按次计费:解析请求中的 ids 数量(一首歌 = 1 次;100 首歌单打包 = 100 次) + F23:与路由的清洗规则完全一致(提取→去重),预扣数=实际执行数,重复 ID 不多扣""" + try: + data = api_service._safe_get_request_data() + raw = data.get('ids') + if isinstance(raw, str): + raw_list = raw.split(',') + else: + raw_list = list(raw or []) + ids, seen = [], set() + for x in raw_list: + try: + mid = api_service._extract_music_id(x) + except Exception: + mid = str(x).strip() + if mid and mid not in seen: + seen.add(mid) + ids.append(mid) + # 与路由同口径:单任务上限 500(超出部分路由会 400 拒绝并退款, + # 预扣数先封顶可让"请分批提交"提示优先于"次数不足") + return max(1, min(len(ids), 500)) + except Exception: + return 1 + + +def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int: + """批量任务结算(F25/F26:走持久账本,按实际预扣-实际交付幂等退款; + 重复调用/重启后调用都只会退一次)""" + try: + refund = auth_system.ledger_settle(task_id, ok_count, final_status) + except Exception: + # 第 97 条(P3):本函数多在异常处理器内被调用——结算再抛错(库忙超时) + # 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等) + api_service.logger.error( + f"批量结算异常(留对账兜底): {task_id}\n{traceback.format_exc()}") + return 0 + if refund: + try: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) or {} + uid = int(t.get('billing_user_id') or 0) + u = auth_system.get_user_by_id(uid) if uid else None + uname = u['username'] if u else 'system' + remaining = u['duration_seconds'] if u else '?' + auth_system.add_log('success', uname, 'system', '批量退费', + f'失败部分 {refund} 次已退回,剩余 {remaining} 次') + except Exception: + pass + return refund + + +def _batch_zip_worker(task_id: str) -> None: + """后台批量打包:窗口化下载 → 写 ZIP(音频免压缩),支持取消与断线暂停 + + - 断线暂停:设备心跳超时后,当前窗口下载完即释放 BATCH_ZIP_SEM 名额给下一位, + 保存进度进入暂停等待;重新打开网站(心跳恢复)后自动回到队列继续下载 + - 音频入包即删,缓解磁盘占用 + """ + import tempfile as _tempfile + from concurrent.futures import ThreadPoolExecutor, as_completed + + zip_tmp_path: str | None = None + ok_count = 0 + failed: list[str] = [] + used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖) + info_pool = None + info_futures = {} + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '下载打包中...' + params = task['params'] + cancel_ev = task['cancel'] + pause_ev = task['pause'] + ids = list(params['ids']) + quality = params['quality'] + filename_format = params['filename_format'] + write_meta = params['write_meta'] + meta_fields = params['meta_fields'] + lyric_format = params['lyric_format'] + concurrency = params['concurrency'] + cookies = params['cookies'] + batch_start = time.time() + + # P2-7:预取按窗口进行——原实现一次性提交全部(最多 500 首 ×3 接口)预取任务, + # 大歌单瞬间以 10 并发连发上游约 1500 次请求打风控;改为每个下载窗口只预取本窗口 + # 的歌(与下载节奏对齐),_download_one 语义不变(等预取结果再下载) + from concurrent.futures import ThreadPoolExecutor as _InfoPool + info_pool = _InfoPool(max_workers=min(4, max(1, concurrency))) + + def _prefetch(window_ids): + for mid in window_ids: + if mid not in info_futures: + info_futures[mid] = info_pool.submit( + api_service.downloader.get_music_info, mid, quality, cookies + ) + + def _download_one(mid): + """并行下载单元:等待预取信息 → 下载 → 复用预取歌词与缓存封面(零重复请求)""" + info = info_futures[mid].result() + result = api_service.downloader.download_music_file( + mid, quality, + write_meta=write_meta, + meta_fields=meta_fields, + cookies=cookies, + cancel_event=cancel_ev, + music_info=info, + ) + extra = {'lyric': '', 'cover': b''} + if result.success: + # 歌词已随 get_music_info 预取返回,无需再调一次歌词接口 + extra['lyric'] = getattr(info, 'lyric', '') or '' + # 封面走共享缓存(同一专辑只拉一次网络);取消后不再发起新请求 + if not cancel_ev.is_set() and info.pic_url: + cover = fetch_cover_bytes(info.pic_url) + if cover: + extra['cover'] = cover + return result, extra + + def _update_progress() -> None: + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['done'] = ok_count + len(failed) + DOWNLOAD_TASKS[task_id]['failed'] = len(failed) + + def _wait_if_paused() -> None: + """暂停时等待恢复或取消(不持有 BATCH_ZIP_SEM)""" + if not (pause_ev.is_set() or task['status'] == 'paused'): + return + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'paused' + task['done'] = ok_count + len(failed) + task['msg'] = '已暂停(重新打开页面自动恢复)' + while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'): + time.sleep(1) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + pause_ev.clear() + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'running' + task['msg'] = '继续下载...' + + # P3-26:ZIP 构建放 downloads 卷(原实现落系统 %TEMP%——500 首无损可达 GB 级, + # 小系统盘一次大单即塞爆;单曲/播客早已指定 dir,唯批量漏了) + # F-A(第 76 条):临时包建在任务专属目录 tasks//——原放根目录会被 + # 缓存清扫线程按 mtime 回收(Linux 上已打开文件也能被 unlink,Docker 部署 + # 排队超 35 分钟即出现"扣费后领取 404"的悬空交付),根目录从此碰不到交付物 + _task_dir_for_zip = api_service.downloads_path / 'tasks' / task_id + _task_dir_for_zip.mkdir(parents=True, exist_ok=True) + zip_valid = False # F-B:ZIP 中央目录完整写出才置 True,否则一律按失败全额退费 + with _tempfile.NamedTemporaryFile(prefix='batch_zip_', suffix='.zip', + dir=str(_task_dir_for_zip), + delete=False) as zip_tmp: + zip_tmp_path = zip_tmp.name + # 音频本身已压缩,用 ZIP_STORED(免压缩)大幅提速;歌词等文本仍压缩 + with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf: + # P3-27:不用 with 管理下载池——取消时 with 退出会 wait=True 等在途 + # future(其内部阻塞在预取 HTTP 上最长 30s),改为不等待 + 取消排队任务 + pool = ThreadPoolExecutor(max_workers=concurrency) + try: + while ids: + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + # 断线暂停:释放名额等待恢复/取消,恢复后重新进入排队 + _wait_if_paused() + # 每个窗口持有 BATCH_ZIP_SEM(同一时刻全局最多 3 个批量任务在下载) + # F91:等待名额期间可被取消,不再无限阻塞线程与退款 + if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + if cancel_ev.is_set() or pause_ev.is_set() or task['status'] == 'paused': + continue # 立即释放名额,回到循环顶部处理暂停 + window = ids[:concurrency] + ids = ids[concurrency:] + _prefetch(window) # P2-7:只预取本窗口 + future_map = { + pool.submit(_download_one, mid): mid + for mid in window + } + for future in as_completed(future_map): + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + mid = future_map[future] + try: + result, extra = future.result() + except DownloadCanceled: + raise + except Exception as e: + failed.append(f"{mid}({e})") + _update_progress() + continue + if not result.success: + failed.append(str(mid)) + _update_progress() + continue + assert result.file_path is not None + fp = Path(result.file_path) + if not fp.exists(): + # 文件可能已被同名并发任务入包删除,按失败处理而非炸掉整个任务 + failed.append(str(mid)) + _update_progress() + continue + mi = result.music_info + base = _build_download_base(filename_format, {'name': mi.name, 'artist_string': mi.artists}) + # 同名去重:歌单里同一首歌出现两次/同名不同版本时, + # 追加序号避免 ZIP 内重名互相覆盖(用户被扣 2 次只拿到 1 首) + # F-C:音频/歌词/封面三个 arcname 都要查重——同名同歌手但 + # 音源扩展不同(flac vs mp3)时歌词/封面条目也会互相覆盖 + final_name = f"{base}.{mi.file_type}" + lrc_name = f"{base}.{lyric_format}" + jpg_name = f"{base}.jpg" + k = 2 + while (final_name in used_arcnames + or lrc_name in used_arcnames + or jpg_name in used_arcnames): + final_name = f"{base} ({k}).{mi.file_type}" + lrc_name = f"{base} ({k}).{lyric_format}" + jpg_name = f"{base} ({k}).jpg" + k += 1 + used_arcnames.update((final_name, lrc_name, jpg_name)) + # F-D:exists 与 zf.write 之间的缓存竞态(另一任务缓存 miss + # 删文件)按单曲失败计,不再让整批转 error + try: + zf.write(str(fp), arcname=final_name, compress_type=zipfile.ZIP_STORED) + except OSError as e: + failed.append(f"{mid}(缓存文件读取失败: {e})") + _update_progress() + continue + # F27:缓存文件是跨任务共享资源,任务不得删除; + # 磁盘回收交给监控线程的下载缓存清扫(按文件年龄) + # 歌词与封面已在并行下载线程中拉取,直接写入 ZIP(无网络等待) + lrc = extra.get('lyric', '') + if lrc: + try: + zf.writestr(lrc_name, _format_lyric(lrc, lyric_format), compress_type=zipfile.ZIP_DEFLATED) + except Exception: + pass + if extra.get('cover'): + try: + zf.writestr(jpg_name, extra['cover'], compress_type=zipfile.ZIP_STORED) + except Exception: + pass + ok_count += 1 + _update_progress() + finally: + BATCH_ZIP_SEM.release() + finally: + # P3-27:取消/异常时不等待在途下载线程(其可能阻塞在预取 HTTP 上), + # 排队任务一并取消;在途线程自行收尾(.part 残片由续传/清扫兜底) + try: + pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + pool.shutdown(wait=False) + # 第 93 条复审 P2-1 修正:zip_valid = True 必须在内层 with zipfile 退出之后 + # (中央目录在 close 时才写出)——原缩进 16 在 with 内部,守卫不可达 + zip_valid = True + + if ok_count > 0: + with BATCH_DURATIONS_LOCK: + BATCH_DURATIONS.append(time.time() - batch_start) + + # F-B:ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛 + if not zip_valid: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + zip_tmp_path = None + refunded = _settle_batch_billing(task_id, 0, 'error') + _mark_task_terminal(task_id, 'error', + f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded + else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['failed'] = t.get('failed', 0) + ok_count + t['refunded'] = refunded + api_service.logger.error(f"批量ZIP构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次") + return + + # F41:全部失败不发布空 ZIP、不标 done —— 按失败收敛并全额退费 + if ok_count == 0 and failed: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + zip_tmp_path = None + refunded = _settle_batch_billing(task_id, 0, 'error') + _mark_task_terminal(task_id, 'error', + f"全部失败({len(failed)} 首)" + (f",已退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['failed'] = len(failed) + DOWNLOAD_TASKS[task_id]['refunded'] = refunded + api_service.logger.info(f"批量ZIP全部失败: {task_id} 失败{len(failed)} 退回{refunded}次") + return + + refunded = _settle_batch_billing(task_id, ok_count) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + # S3:发布前锁内复查取消/失效状态——取消请求落在"检查后、拿锁前"的窗口也不得覆盖终态 + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = ok_count + len(failed) + t['failed'] = len(failed) + t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '') + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"批量下载_{ok_count}首.zip", + 'audio_files': [], # F27:缓存文件不归任务所有,不进入清理清单 + } + api_service.logger.info(f"批量ZIP完成: 成功{ok_count} 失败{len(failed)} 退回{refunded}次") + except DownloadCanceled: + # F24:部分成功后取消——已入包部分照常交付(成果守恒),未完成部分按账本退费 + # F-B:ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛 + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered, 'canceled') + if delivered > 0 and zip_tmp_path: + _mark_task_terminal(task_id, 'canceled', + f"已取消(已打包 {ok_count} 首可领取)" + + (f",退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"批量下载_{ok_count}首.zip", + 'audio_files': [], + } + else: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + _mark_task_terminal(task_id, 'canceled', + '任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + api_service.logger.info(f"批量ZIP已取消: {task_id}(成功{ok_count} 退回{refunded}次)") + except Exception as e: + # F24:通用异常与取消同一套交付/结算规则:有成果则交付,按账本退未交付部分 + # F-B:ZIP 未正常完成(写坏)时按全额退费,不发布坏包 + # 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化 + api_service.logger.error(f"批量打包异常: {task_id} {e}\n{traceback.format_exc()}") + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered, 'error') + if delivered > 0 and zip_tmp_path: + _mark_task_terminal(task_id, 'error', + "部分歌曲已打包完成,可领取" + + (f",退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"批量下载_{ok_count}首.zip", + 'audio_files': [], + } + else: + _mark_task_terminal(task_id, 'error', + '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.error(f"批量ZIP任务异常: {task_id} {e}(成功{ok_count} 退回{refunded}次)\n{traceback.format_exc()}") + finally: + # 回收信息预取线程池,避免线程泄漏(未启动的预取任务一并取消) + if info_pool is not None: + try: + info_pool.shutdown(wait=False, cancel_futures=True) + except TypeError: + info_pool.shutdown(wait=False) + + +def _podcast_download_worker(task_id: str) -> None: + """后台播客单集下载:解析单集 → 音质降级取直链 → 流式下载(可选 ZIP 打包) + + 任务 type 为 'single',完成后经 /download/result 交付,与单曲下载共用交付通道。 + F28:落盘文件进入任务专属目录且带节目 ID,跨任务永不重名;清理只动本任务目录。 + """ + import tempfile as _tempfile + + file_path_str: str | None = None + zip_tmp_path: str | None = None + task_dir: Path | None = None + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '解析单集信息...' + params = task['params'] + cancel_ev = task['cancel'] + program_id = params['program_id'] + quality = params['quality'] + need_zip = params['need_zip'] + cookies = params['cookies'] + remote_ip = params.get('remote_ip') or '-' + + program = podcast_api.get_program_detail(program_id, cookies) + url_info = _podcast_resolve_audio(program, quality, cookies) + + serial = int(program.get('serial_num') or 0) + name = program.get('name') or program.get('main_song_name') or '播客单集' + prefix = f"{serial:03d} - " if serial else '' + base = _podcast_safe_name(f"{prefix}{name}") + ext = (url_info.get('type') or 'mp3').lower().lstrip('.') + # F28:磁盘名附节目 ID + 任务目录隔离(两个任务同下载一集互不覆盖、互不删除) + task_dir = api_service.downloads_path / 'tasks' / task_id + task_dir.mkdir(parents=True, exist_ok=True) + disk_name = f"{base} [{program_id}].{ext}" + target = task_dir / disk_name + filename = f"{base}.{ext}" # 交付名保持干净(不带 ID) + + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS[task_id] + t['msg'] = '下载中...' + _podcast_stream_download(url_info['url'], target, url_info.get('size', 0), + cancel_ev, task_id, '下载中') + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + file_path_str = str(target) + + result: dict[str, Any] + if need_zip: + fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip', + dir=str(task_dir)) + os.close(fd) + with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(file_path_str, arcname=filename) + cover_url = program.get('cover_url') or '' + if cover_url: + try: + cover = fetch_cover_bytes(cover_url) + if cover: + zf.writestr(f"{base}.jpg", cover) + except Exception: + pass + # F17:info.txt 不再写直链(真实 URL 不应随交付文件外泄) + info_txt = ( + f"单集: {name}\n" + f"播客: {program.get('radio_name') or ''}\n" + f"主播: {program.get('dj_name') or ''}\n" + f"时长: {program.get('duration_text') or ''}\n" + f"音质: {api_service._get_quality_display_name(url_info.get('level', quality))}\n" + ) + zf.writestr("info.txt", info_txt) + # F42:打包阶段后同样以取消检查收口 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + result = { + 'file_path': file_path_str, + 'zip_path': zip_tmp_path, + 'filename': f"{base}.zip", + 'mimetype': 'application/zip', + 'audio_files': [file_path_str], + } + else: + result = { + 'file_path': file_path_str, + 'filename': filename, + 'mimetype': f"audio/{ext}", + 'audio_files': [file_path_str], + } + + # F42:最终发布前的取消检查(末段取消不得覆盖为 done) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = 1 + t['msg'] = '下载完成' + t['result'] = result + # C1:交付已落定;结算失败不再把任务改标 error(同单曲) + try: + auth_system.ledger_settle(task_id, 1, 'delivered') + except Exception as _e: + api_service.logger.error(f"播客单集交付结算失败(重启对账兜底): {task_id} {_e}") + try: + _record_log('下载', remote_ip, f"[播客] {name}") + except Exception: + pass + api_service.logger.info(f"播客单集下载完成: {filename} ({task_id})") + except DownloadCanceled: + _mark_task_terminal(task_id, 'canceled', '任务已取消') + try: + auth_system.ledger_settle(task_id, 0, 'canceled') + except Exception: + pass + if task_dir is not None: + shutil.rmtree(str(task_dir), ignore_errors=True) + api_service.logger.info(f"播客单集下载已取消: {task_id}") + except DownloadException as e: + _mark_task_terminal(task_id, 'error', str(e)) + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir is not None: + shutil.rmtree(str(task_dir), ignore_errors=True) + api_service.logger.error(f"播客单集下载失败: {task_id} {e}") + except Exception as e: + # 第 97 条(P3):非预期异常原文 logger 先行,用户可见文案泛化(第 84 条教训) + api_service.logger.error(f"播客单集下载异常: {task_id} {e}\n{traceback.format_exc()}") + _mark_task_terminal(task_id, 'error', '下载失败,请稍后重试') + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir is not None: + shutil.rmtree(str(task_dir), ignore_errors=True) + + +def _podcast_cleanup_files(paths: list[str | None]) -> None: + """清理播客任务产生的临时/残留文件""" + for p in paths: + if not p: + continue + try: + os.remove(p) + except OSError: + pass + # 连带清理可能残留的 .part 文件 + try: + part = Path(str(p) + '.part') + if part.exists(): + part.unlink() + except OSError: + pass + + +def _podcast_zip_worker(task_id: str) -> None: + """整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)""" + import tempfile as _tempfile + + zip_tmp_path: str | None = None + ok_count = 0 + failed: list[str] = [] + # 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等 + # 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程 + zip_valid = False + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '解析单集列表...' + params = task['params'] + cancel_ev = task['cancel'] + pause_ev = task['pause'] + radio_id = params['radio_id'] + preset_ids = params.get('ids') + limit = params['limit'] + quality = params['quality'] + cookies = params['cookies'] + remote_ip = params.get('remote_ip') or '-' + + # 组装单集清单:整台电台(正序全量)或指定单集ID列表 + if radio_id: + episodes = podcast_api.list_radio_all_program_ids(radio_id, cookies, max_count=limit) + if not episodes: + raise DownloadException("该电台暂无可下载的单集") + else: + episodes = [] + for pid in (preset_ids or []): + try: + prog = podcast_api.get_program_detail(pid, cookies) + episodes.append({ + 'id': prog['id'], + 'name': prog['name'], + 'main_song_id': prog['main_song_id'], + 'serial_num': prog['serial_num'], + 'cover_url': prog['cover_url'], + 'detail': prog, + }) + except APIException as ae: + failed.append(f"{pid}({ae})") + episodes = episodes[:limit] + if not episodes: + raise DownloadException("没有可下载的单集") + + radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客') + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['total'] = len(episodes) + + # 按次计费补扣(F21):整台电台提交时只预扣 1 次,此处按实际单集数补足差额。 + # 补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付 + try: + _buid = params.get('billing_uid') + _extra = len(episodes) - int(params.get('billed') or 1) + if _buid and _extra > 0: + if not auth_system.ledger_supplement(task_id, _extra): + raise DownloadException( + f"补扣下载次数失败(需补扣 {_extra} 次),任务已取消,预扣部分将退回") + if _buid: + with DOWNLOAD_TASKS_LOCK: + DOWNLOAD_TASKS[task_id]['billing_user_id'] = int(_buid) + DOWNLOAD_TASKS[task_id]['billing_cost'] = len(episodes) + except DownloadCanceled: + raise + except DownloadException: + raise + except Exception as e: + raise DownloadException(f"补扣下载次数异常: {e}") from e + + def _wait_if_paused() -> None: + """暂停时等待恢复或取消""" + if not (pause_ev.is_set() or task['status'] == 'paused'): + return + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'paused' + task['done'] = ok_count + len(failed) + task['msg'] = '已暂停(重新打开页面自动恢复)' + while not cancel_ev.is_set() and (pause_ev.is_set() or task['status'] == 'paused'): + time.sleep(1) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + pause_ev.clear() + with DOWNLOAD_TASKS_LOCK: + task['status'] = 'running' + task['msg'] = '继续下载...' + + def _update_progress(msg: str = '') -> None: + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS[task_id] + t['done'] = ok_count + len(failed) + t['failed'] = len(failed) + if msg: + t['msg'] = msg + + # 第 91 条(P1-1):对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常 + # 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头,第 97 条); + # 未 valid 一律不发布、不按 ok_count 结算 + # P3-26:同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP% + # R5-P3/W-A 同口径:建在任务专属目录 tasks//,根目录缓存清扫不再触及交付物 + _podcast_task_dir = api_service.downloads_path / 'tasks' / task_id + _podcast_task_dir.mkdir(parents=True, exist_ok=True) + with _tempfile.NamedTemporaryFile(prefix='podcast_batch_', suffix='.zip', + dir=str(_podcast_task_dir), + delete=False) as zip_tmp: + zip_tmp_path = zip_tmp.name + used_arcnames = set() # F73:包内条目名去重(同名单集/同号不同集不得互相覆盖) + with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf: + for idx, ep in enumerate(episodes, 1): + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + _wait_if_paused() + # 并发对齐音乐批量:播客打包同样占用全局批量名额(BATCH_ZIP_SEM,全局 3), + # 逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受 + # 全局下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发 + if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev): + raise DownloadCanceled("任务已取消") + try: + label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}" + _update_progress(f"{label} 解析中...") + try: + prog = ep.get('detail') or podcast_api.get_program_detail(ep['id'], cookies) + url_info = _podcast_resolve_audio(prog, quality, cookies) + _update_progress(f"{label} 下载中...") + ext = (url_info.get('type') or 'mp3').lower().lstrip('.') + serial = int(ep.get('serial_num') or 0) + prefix = f"{serial:03d} - " if serial else '' + base = _podcast_safe_name(f"{prefix}{ep['name']}") + # 第 91 条(P3-5):中间文件移入任务目录——落 downloads 根目录会被 + # 缓存清扫按 mtime 打断慢速单集(Linux 上 unlink 打开中的文件) + tmp_audio = _podcast_task_dir / f"podcast_zip_tmp_{task_id}_{idx}.{ext}" + try: + _podcast_stream_download(url_info['url'], tmp_audio, + url_info.get('size', 0), cancel_ev) + # F73:重名条目自动追加序号 + arcname = f"{base}.{ext}" + k = 2 + while arcname in used_arcnames: + arcname = f"{base} ({k}).{ext}" + k += 1 + used_arcnames.add(arcname) + zf.write(str(tmp_audio), arcname=arcname, compress_type=zipfile.ZIP_STORED) + finally: + # P3-5:finally 连带清理 .part 残片(原实现只删正文, + # 取消/失败时残片要等 1 小时缓存清扫兜底) + _podcast_cleanup_files([str(tmp_audio)]) + # 首集附带封面,便于识别 + if idx == 1 and ep.get('cover_url'): + try: + cover = fetch_cover_bytes(ep['cover_url']) + if cover: + zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED) + except Exception: + pass + ok_count += 1 + _update_progress(f"{label} 完成") + except DownloadCanceled: + raise + except Exception as e: + failed.append(f"{ep['name']}({e})") + _update_progress() + finally: + BATCH_ZIP_SEM.release() + + # 第 91 条(P1-1):走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布 + zip_valid = True + + if ok_count == 0: + raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}") + + # 第 91 条(P1-1):ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛 + if not zip_valid: + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + zip_tmp_path = None + refunded = _settle_batch_billing(task_id, 0, 'error') + _mark_task_terminal(task_id, 'error', + f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded + else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['failed'] = t.get('failed', 0) + ok_count + t['refunded'] = refunded + api_service.logger.error(f"播客打包构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次") + return + + # F42:最终发布前的取消检查(末段取消不得覆盖为 done) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + refunded = _settle_batch_billing(task_id, ok_count) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + # S3:发布前锁内复查取消/失效状态(同音乐批量) + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = ok_count + len(failed) + t['failed'] = len(failed) + t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '') + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"播客_{radio_name}_{ok_count}集.zip", + 'audio_files': [], + } + try: + first_name = episodes[0].get('name') if episodes else '' + _record_log('下载', remote_ip, f"[播客打包] {radio_name} · {first_name}") + except Exception: + pass + api_service.logger.info(f"播客打包完成: {radio_name} 成功{ok_count} 失败{len(failed)} ({task_id})") + except DownloadCanceled: + # F24:部分成功后取消——已入包部分照常交付,未完成部分按账本退费 + # 第 91 条(P1-1):ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛 + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered, 'canceled') + if delivered > 0 and zip_tmp_path: + _mark_task_terminal(task_id, 'canceled', + f"已取消(已打包 {ok_count} 集可领取)" + + (f",退回 {refunded} 次" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + t['result'] = { + 'zip_path': zip_tmp_path, + 'filename': f"播客_{radio_name or '打包'}_{ok_count}集.zip", + 'audio_files': [], + } + else: + _mark_task_terminal(task_id, 'canceled', + '任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')) + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is not None: + t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.info(f"播客打包已取消: {task_id}(成功{ok_count} 退回{refunded}次)") + except DownloadException as e: + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered) + with DOWNLOAD_TASKS_LOCK: + _t = DOWNLOAD_TASKS.get(task_id) + # 第 100 条(T1 不变量):终态先到先得——已终态(如用户取消/超时兜底) + # 不再被覆盖为 error(堵 canceled→error 非法迁移边) + if _t and _t.get('status') not in ('done', 'error', 'canceled'): + _t['status'] = 'error' + _t['completed_at'] = time.time() # F38:保留期从完成时刻起算 + _t['msg'] = str(e) + (f"(已退回 {refunded} 次下载次数)" if refunded else '') + _t['refunded'] = refunded + elif _t: + _t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.error(f"播客打包失败: {task_id} {e}(退回{refunded}次)") + except Exception as e: + # 第 97 条(对抗复审):非预期异常原文 logger 先行,用户可见文案泛化 + # (DownloadException 的文案是我们自己拼的用户可读文本,保留 str(e)) + api_service.logger.error(f"播客打包异常: {task_id} {e}\n{traceback.format_exc()}") + delivered = ok_count if zip_valid else 0 + refunded = _settle_batch_billing(task_id, delivered) + with DOWNLOAD_TASKS_LOCK: + _t = DOWNLOAD_TASKS.get(task_id) + # 第 100 条(T1 不变量):同上——已终态不再覆盖为 error + if _t and _t.get('status') not in ('done', 'error', 'canceled'): + _t['status'] = 'error' + _t['completed_at'] = time.time() # F38:保留期从完成时刻起算 + _t['msg'] = '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '') + _t['refunded'] = refunded + elif _t: + _t['refunded'] = refunded + if zip_tmp_path: + try: + os.remove(zip_tmp_path) + except OSError: + pass + api_service.logger.error(f"播客打包异常: {task_id} {e}(退回{refunded}次)\n{traceback.format_exc()}") + + +def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str: + """把共享缓存文件复制/硬链接为任务专属交付文件(F27:任务成果与共享缓存分离, + 任何任务的清理都不会再删掉别的任务的成果)""" + task_dir = api_service.downloads_path / 'tasks' / task_id + task_dir.mkdir(parents=True, exist_ok=True) + dst = task_dir / filename + if dst.exists(): + return str(dst) + try: + os.link(str(src), str(dst)) # 同卷硬链接:零拷贝 + except OSError: + shutil.copy2(str(src), str(dst)) + return str(dst) + + +# 第 91 条(P3):_cleanup_task_dir 死代码删除——worker 直接 rmtree、监控线程直接 +# rmtree,本函数全文件零调用(审计确认)。 + + +def _single_download_worker(task_id: str) -> None: + """后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理""" + import tempfile as _tempfile + + file_path_str: str | None = None + zip_tmp_path: str | None = None + task_dir_path: str | None = None + try: + with DOWNLOAD_TASKS_LOCK: + task = DOWNLOAD_TASKS[task_id] + task['status'] = 'running' + task['msg'] = '下载中...' + params = task['params'] + cancel_ev = task['cancel'] + music_id = params['music_id'] + quality = params['quality'] + filename_format = params['filename_format'] + write_meta = params['write_meta'] + meta_fields = params['meta_fields'] + lyric_format = params['lyric_format'] + need_zip = params['need_zip'] + cookies = params['cookies'] + remote_ip = params.get('remote_ip') or '-' + + # 一次预取歌曲完整信息(URL/详情/歌词三接口并行), + # 替代原先 name_v1 + url_v1 + 下载器内部 get_music_info 的重复串行调用 + mi = api_service.downloader.get_music_info(music_id, quality, cookies) + music_info = { + 'id': music_id, + 'name': mi.name, + 'artist_string': mi.artists, + 'album': mi.album, + 'pic_url': mi.pic_url, + 'file_type': mi.file_type, + 'file_size': mi.file_size, + 'duration': mi.duration * 1000, + 'download_url': mi.download_url, + } + base = _build_download_base(filename_format, music_info) + filename = f"{base}.{music_info['file_type']}" + + # 字节级进度回调(限频 0.4s):写进任务 live_percent,/api/batch_progress + # 对运行中的单曲任务优先返回它——原实现 done 按首计数,单曲进度条全程 0% + _cb_ts = {'t': 0.0} + + def _progress_cb(written, total_bytes): + try: + now = time.time() + if now - _cb_ts['t'] < 0.4 and written < (total_bytes or 0): + return + _cb_ts['t'] = now + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if not t or t.get('status') != 'running': + return + if total_bytes: + pct = min(99.0, written / total_bytes * 100) + t['live_percent'] = pct + t['msg'] = (f"下载中 {int(pct)}% · " + f"{api_service._format_file_size(written)}/{api_service._format_file_size(total_bytes)}") + else: + t['msg'] = f"下载中 · 已接收 {api_service._format_file_size(written)}" + except Exception: + pass # 进度上报故障绝不影响下载 + + download_result = api_service.downloader.download_music_file( + music_id, quality, + write_meta=write_meta, + meta_fields=meta_fields, + cookies=cookies, + cancel_event=cancel_ev, + music_info=mi, + progress_cb=_progress_cb, + ) + if not download_result.success: + raise DownloadException(download_result.error_message or "下载失败") + # F42:下载完成后的每一阶段都可能已被取消,发布前统一校验 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + assert download_result.file_path is not None + cache_path = Path(download_result.file_path) + # F27:交付文件用任务专属副本,共享缓存不进清理清单 + file_path_str = _task_delivery_copy(task_id, cache_path, filename) + task_dir_path = str(Path(file_path_str).parent) + + if need_zip: + fd, zip_tmp_path = _tempfile.mkstemp(prefix='single_zip_', suffix='.zip', + dir=str(Path(file_path_str).parent)) + os.close(fd) + with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(file_path_str, arcname=filename) + # 歌词已在预取时一并拿到,无需再请求一次 + if mi.lyric: + try: + zf.writestr(f"{base}.{lyric_format}", _format_lyric(mi.lyric, lyric_format)) + except Exception: + pass + # 封面走共享缓存(与元数据写入共用同一次网络请求) + cover = fetch_cover_bytes(mi.pic_url) + if cover: + zf.writestr(f"{base}.jpg", cover) + info_txt = ( + f"歌曲: {music_info['name']}\n" + f"歌手: {music_info['artist_string']}\n" + f"专辑: {music_info['album']}\n" + f"音质: {api_service._get_quality_display_name(quality)}\n" + ) + zf.writestr("info.txt", info_txt) + # F42:打包阶段同样以取消检查收口 + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + result = { + 'file_path': file_path_str, + 'zip_path': zip_tmp_path, + 'filename': f"{base}.zip", + 'mimetype': 'application/zip', + 'audio_files': [file_path_str], + } + else: + result = { + 'file_path': file_path_str, + 'filename': filename, + 'mimetype': f"audio/{music_info['file_type']}", + 'audio_files': [file_path_str], + } + + # F42:最终发布前的取消检查(末段取消不得覆盖为 done) + if cancel_ev.is_set(): + raise DownloadCanceled("任务已取消") + with DOWNLOAD_TASKS_LOCK: + t = DOWNLOAD_TASKS.get(task_id) + if t is None or t.get('status') in ('canceled', 'error'): + raise DownloadCanceled("任务已取消") + t['status'] = 'done' + t['completed_at'] = time.time() + t['done'] = 1 + t['msg'] = '下载完成' + t['result'] = result + # C1:交付已落定;结算失败不再把任务改标 error(钱由重启对账兜底,宁可商家小损不坑用户) + try: + auth_system.ledger_settle(task_id, 1, 'delivered') + except Exception as _e: + api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}") + try: + _record_log('下载', remote_ip, music_info['name']) + except Exception: + pass + api_service.logger.info(f"单曲下载完成: {filename} ({task_id})") + except DownloadCanceled: + _mark_task_terminal(task_id, 'canceled', '任务已取消') + try: + auth_system.ledger_settle(task_id, 0, 'canceled') + except Exception: + pass + if task_dir_path: + shutil.rmtree(task_dir_path, ignore_errors=True) + api_service.logger.info(f"单曲下载已取消: {task_id}") + except DownloadException as e: + _mark_task_terminal(task_id, 'error', str(e)) + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir_path: + shutil.rmtree(task_dir_path, ignore_errors=True) + api_service.logger.error(f"单曲下载失败: {task_id} {e}") + except Exception as e: + # 第 97 条(P3):非预期异常的 repr 可能含上游主机/连接细节——用户可见文案 + # 泛化为固定文案,原文 logger 先行留档(第 84 条教训:先记录再泛化) + api_service.logger.error(f"单曲下载异常: {task_id} {e}\n{traceback.format_exc()}") + _mark_task_terminal(task_id, 'error', '下载失败,请稍后重试') + try: + auth_system.ledger_settle(task_id, 0, 'error') + except Exception: + pass + if task_dir_path: + shutil.rmtree(task_dir_path, ignore_errors=True) + diff --git a/tests/golden/responses.json b/tests/golden/responses.json new file mode 100644 index 0000000..2f84aec --- /dev/null +++ b/tests/golden/responses.json @@ -0,0 +1,540 @@ +[ + { + "name": "health", + "auth": "none", + "method": "GET", + "path": "/health", + "payload": null, + "status": 200, + "body": { + "data": { + "cookie_count": 3, + "cookie_status": "valid", + "downloads_dir": "configured", + "service": "running", + "timestamp": 1790391125, + "version": "2.4.0" + }, + "message": "API服务运行正常", + "status": 200, + "success": true + } + }, + { + "name": "api_info", + "auth": "none", + "method": "GET", + "path": "/api/info", + "payload": null, + "status": 200, + "body": { + "data": { + "config": { + "downloads_dir": "configured", + "max_body_bytes": "2MB(备份上传 200MB)" + }, + "description": "云村小助手 · 网易云音乐解析/下载服务", + "endpoints": { + "/album": "GET/POST - 获取专辑详情", + "/api/info": "GET - API信息", + "/api/playlist/play": "GET/POST - 公开歌单播放API(id/level/limit)", + "/download": "POST - 下载音乐(付费动作仅接受 POST)", + "/global/playlist": "GET/POST - 全局账号歌单详情", + "/global/playlists": "GET/POST - 全局账号歌单列表", + "/health": "GET - 健康检查", + "/playlist": "GET/POST - 获取歌单详情", + "/podcast/download": "POST - 播客单集下载", + "/podcast/download/batch_zip": "POST - 整台播客打包ZIP", + "/podcast/hot": "GET/POST - 热门播客与分类", + "/podcast/parse": "GET/POST - 播客解析(电台/单集)", + "/podcast/programs": "GET/POST - 播客单集列表(分页)", + "/podcast/search": "GET/POST - 播客搜索", + "/search": "GET/POST - 搜索音乐", + "/song": "GET/POST - 获取歌曲信息" + }, + "name": "云村小助手API服务", + "supported_qualities": [ + "standard", + "exhigh", + "lossless", + "hires", + "sky", + "jyeffect", + "jymaster", + "dolby" + ], + "version": "2.4.0" + }, + "message": "API信息获取成功", + "status": 200, + "success": true + } + }, + { + "name": "toplist_unauth", + "auth": "none", + "method": "POST", + "path": "/toplist", + "payload": {}, + "status": 401, + "body": { + "error_code": "unauthorized", + "message": "请先登录后再使用解析功能", + "status": 401, + "success": false + } + }, + { + "name": "user_status_unauth", + "auth": "none", + "method": "POST", + "path": "/user/status", + "payload": {}, + "status": 200, + "body": { + "data": { + "cookie": { + "is_valid": false + }, + "user": null + }, + "message": "个人账号未登录", + "status": 200, + "success": true + } + }, + { + "name": "user_logout", + "auth": "none", + "method": "POST", + "path": "/user/logout", + "payload": {}, + "status": 200, + "body": { + "data": { + "is_valid": false + }, + "message": "已退出登录", + "status": 200, + "success": true + } + }, + { + "name": "auth_trial", + "auth": "none", + "method": "POST", + "path": "/auth/trial", + "payload": {}, + "status": 400, + "body": { + "message": "注册即送 3 次免费下载次数,请直接注册账号", + "status": 400, + "success": false + } + }, + { + "name": "auth_sendcode_badpurpose", + "auth": "none", + "method": "POST", + "path": "/auth/send_code", + "payload": { + "email": "x@y.com", + "purpose": "bad" + }, + "status": 400, + "body": { + "message": "验证码用途不正确", + "status": 400, + "success": false + } + }, + { + "name": "auth_register_shortuser", + "auth": "none", + "method": "POST", + "path": "/auth/register", + "payload": { + "username": "ab", + "password": "12345678", + "email": "a@b.com", + "code": "x", + "agree": "1" + }, + "status": 400, + "body": { + "message": "用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线", + "status": 400, + "success": false + } + }, + { + "name": "auth_register_noagree", + "auth": "none", + "method": "POST", + "path": "/auth/register", + "payload": { + "username": "abcdef", + "password": "12345678", + "email": "a@b.com", + "code": "x", + "agree": "0" + }, + "status": 400, + "body": { + "message": "请先阅读并同意《用户协议》与《隐私政策》", + "status": 400, + "success": false + } + }, + { + "name": "auth_register_badcode", + "auth": "none", + "method": "POST", + "path": "/auth/register", + "payload": { + "username": "abcdef", + "password": "12345678", + "email": "a@b.com", + "code": "zz", + "agree": "1" + }, + "status": 400, + "body": { + "message": "邮箱验证码错误或已过期", + "status": 400, + "success": false + } + }, + { + "name": "auth_redeem_unauth", + "auth": "none", + "method": "POST", + "path": "/auth/redeem", + "payload": { + "code": "X" + }, + "status": 401, + "body": { + "error_code": "unauthorized", + "message": "请先登录", + "status": 401, + "success": false + } + }, + { + "name": "auth_forgot_shortpwd", + "auth": "none", + "method": "POST", + "path": "/auth/forgot_password", + "payload": { + "email": "a@b.com", + "code": "x", + "password": "short" + }, + "status": 400, + "body": { + "message": "新密码长度需在 8-128 位之间", + "status": 400, + "success": false + } + }, + { + "name": "admin_logs_forbidden", + "auth": "user", + "method": "POST", + "path": "/admin/logs", + "payload": {}, + "status": 403, + "body": { + "message": "需要管理员权限,请使用管理员账号登录", + "status": 403, + "success": false + } + }, + { + "name": "admin_site_users_forbidden", + "auth": "user", + "method": "POST", + "path": "/admin/site/users", + "payload": {}, + "status": 403, + "body": { + "error_code": "forbidden", + "message": "需要管理员权限", + "status": 403, + "success": false + } + }, + { + "name": "admin_qr_forbidden", + "auth": "user", + "method": "POST", + "path": "/admin/qr/create", + "payload": {}, + "status": 403, + "body": { + "message": "需要管理员权限,请使用管理员账号登录", + "status": 403, + "success": false + } + }, + { + "name": "global_playlists_forbidden", + "auth": "user", + "method": "POST", + "path": "/global/playlists", + "payload": {}, + "status": 403, + "body": { + "error_code": "forbidden", + "message": "需要管理员权限", + "status": 403, + "success": false + } + }, + { + "name": "download_unauth", + "auth": "none", + "method": "POST", + "path": "/download", + "payload": { + "id": "1" + }, + "status": 401, + "body": { + "error_code": "unauthorized", + "message": "请先登录后再使用解析功能", + "status": 401, + "success": false + } + }, + { + "name": "download_badid", + "auth": "user", + "method": "POST", + "path": "/download", + "payload": { + "id": "abc" + }, + "status": 400, + "body": { + "message": "无效的歌曲 ID", + "status": 400, + "success": false + } + }, + { + "name": "download_badquality", + "auth": "user", + "method": "POST", + "path": "/download", + "payload": { + "id": "347230", + "quality": "nope" + }, + "status": 400, + "body": { + "message": "无效的音质参数,支持: standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby", + "status": 400, + "success": false + } + }, + { + "name": "task_list_unauth", + "auth": "none", + "method": "POST", + "path": "/api/task/list", + "payload": { + "device_id": "x" + }, + "status": 401, + "body": { + "error_code": "unauthorized", + "message": "请先登录后再使用解析功能", + "status": 401, + "success": false + } + }, + { + "name": "task_cancel_unauth", + "auth": "none", + "method": "POST", + "path": "/api/task/cancel", + "payload": { + "task_id": "x" + }, + "status": 401, + "body": { + "error_code": "unauthorized", + "message": "请先登录后再使用解析功能", + "status": 401, + "success": false + } + }, + { + "name": "queue", + "auth": "none", + "method": "POST", + "path": "/api/queue", + "payload": {}, + "status": 200, + "body": { + "data": { + "avg_batch_seconds": null, + "batch_active": 0, + "batch_capacity": 3, + "batch_waiting": 0, + "download_active": 0, + "download_capacity": 10, + "download_waiting": 0 + }, + "message": "获取排队状态成功", + "status": 200, + "success": true + } + }, + { + "name": "delivery_404", + "auth": "user", + "method": "POST", + "path": "/download/result", + "payload": { + "task_id": "nope" + }, + "status": 404, + "body": { + "message": "任务不存在或已过期", + "status": 404, + "success": false + } + }, + { + "name": "delivery_batch_404", + "auth": "user", + "method": "POST", + "path": "/download/batch_zip/result", + "payload": { + "task_id": "nope" + }, + "status": 404, + "body": { + "message": "任务不存在或已过期", + "status": 404, + "success": false + } + }, + { + "name": "fe_error_nomsg", + "auth": "none", + "method": "POST", + "path": "/api/fe_error", + "payload": {}, + "status": 400, + "body": { + "message": "缺少 msg 参数", + "status": 400, + "success": false + } + }, + { + "name": "play_badkey", + "auth": "none", + "method": "POST", + "path": "/api/playlist/play", + "payload": { + "id": 3778678 + }, + "status": 401, + "body": { + "message": "需要有效的 API 密钥(请联系站点管理员获取)", + "status": 401, + "success": false + } + }, + { + "name": "podcast_programs_badid", + "auth": "user", + "method": "POST", + "path": "/podcast/programs", + "payload": { + "radio_id": "abc" + }, + "status": 400, + "body": { + "message": "无效的电台 ID", + "status": 400, + "success": false + } + }, + { + "name": "song_badid", + "auth": "user", + "method": "POST", + "path": "/song", + "payload": { + "id": "abc" + }, + "status": 400, + "body": { + "message": "无效的歌曲ID,请输入数字ID或分享链接", + "status": 400, + "success": false + } + }, + { + "name": "comments_badid", + "auth": "user", + "method": "POST", + "path": "/comments", + "payload": { + "id": "abc" + }, + "status": 400, + "body": { + "message": "无效的歌曲ID,请输入数字ID或分享链接", + "status": 400, + "success": false + } + }, + { + "name": "playlist_badid", + "auth": "user", + "method": "POST", + "path": "/playlist", + "payload": { + "id": "" + }, + "status": 400, + "body": { + "message": "参数 'playlist_id' 不能为空", + "status": 400, + "success": false + } + }, + { + "name": "album_badid", + "auth": "user", + "method": "POST", + "path": "/album", + "payload": { + "id": "abc" + }, + "status": 400, + "body": { + "message": "无效的专辑ID,请输入数字ID或分享链接", + "status": 400, + "success": false + } + }, + { + "name": "index", + "auth": "none", + "method": "GET", + "path": "/", + "payload": null, + "status": 200, + "body": null, + "html_len": 466261, + "html_sha256": "5ba897c2f07b43b6c34e998505fea385cb6a6c8bfd5115d014f80044a38c6200" + } +] \ No newline at end of file diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py index c5d85c9..465308e 100644 --- a/tests/test_auth_routes.py +++ b/tests/test_auth_routes.py @@ -42,7 +42,7 @@ def app_env(tmp_path, monkeypatch): monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) # 清空登录失败锁定/2FA 的模块级内存态,避免测试间串扰 - import auth_routes + import web.auth_state as auth_routes auth_routes._LOGIN_FAILS.clear() auth_routes._LOGIN_FAIL_LOGGED.clear() auth_routes._ADMIN_2FA.clear() @@ -52,14 +52,14 @@ def app_env(tmp_path, monkeypatch): def test_wrong_password(app_env): - r = app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': 'nope'}) + r = app_env.post('/auth/login', json={'username': 'normaluser', 'password': 'nope'}) assert r.status_code == 400 assert r.json()['success'] is False assert 'session' not in r.cookies def test_user_login_issues_working_cookie(app_env): - r = app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': USER_PW}) + r = app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW}) assert r.status_code == 200 body = r.json() assert body['success'] is True @@ -67,13 +67,13 @@ def test_user_login_issues_working_cookie(app_env): assert 'session' in r.cookies # 用签发的 Cookie 访问受保护端点,应放行 - r2 = app_env.post('/api/v1/auth/heartbeat', json={}) + r2 = app_env.post('/auth/heartbeat', json={}) assert r2.status_code == 200 assert r2.json()['data']['duration_text'] == '97 次' def test_admin_login_returns_2fa_ticket(app_env): - r = app_env.post('/api/v1/auth/login', json={'username': 'bossuser', 'password': ADMIN_PW}) + r = app_env.post('/auth/login', json={'username': 'bossuser', 'password': ADMIN_PW}) assert r.status_code == 200 data = r.json()['data'] assert data['admin_2fa'] is True @@ -82,12 +82,12 @@ def test_admin_login_returns_2fa_ticket(app_env): def test_logout_bumps_login_seq_and_clears(app_env): - app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': USER_PW}) + app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW}) conn = auth.get_conn() seq_before = conn.execute( "SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0] conn.close() - r = app_env.post('/api/v1/auth/logout', json={}) + r = app_env.post('/auth/logout', json={}) assert r.status_code == 200 conn = auth.get_conn() seq_after = conn.execute( @@ -95,7 +95,7 @@ def test_logout_bumps_login_seq_and_clears(app_env): conn.close() assert seq_after == seq_before + 1 # 旧 Cookie 失效 - r2 = app_env.post('/api/v1/auth/heartbeat', json={}) + r2 = app_env.post('/auth/heartbeat', json={}) assert r2.status_code == 401 @@ -104,8 +104,8 @@ def test_heartbeat_expired_credits(app_env): conn.execute("UPDATE users SET duration_seconds=0 WHERE username='normaluser'") conn.commit() conn.close() - r = app_env.post('/api/v1/auth/login', json={'username': 'normaluser', 'password': USER_PW}) + r = app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW}) assert r.status_code == 200 - r2 = app_env.post('/api/v1/auth/heartbeat', json={}) + r2 = app_env.post('/auth/heartbeat', json={}) assert r2.status_code == 401 assert r2.json().get('error_code') == 'expired' diff --git a/tests/test_download_billing.py b/tests/test_download_billing.py index 6ce73fa..f4a4e9c 100644 --- a/tests/test_download_billing.py +++ b/tests/test_download_billing.py @@ -48,7 +48,7 @@ def _login(env, username): base64 padding('=') 的值加双引号,导致服务端签名解析失败(真实浏览器不会), 故测试避开 jar。 """ - r = env.post('/api/v1/auth/login', json={'username': username, 'password': PW}) + r = env.post('/auth/login', json={'username': username, 'password': PW}) assert r.status_code == 200, r.text raw = r.headers.get('set-cookie', '') cookie = '' @@ -73,7 +73,7 @@ def _open_ledger_rows(): def test_download_requires_login(env): - r = env.post('/api/v1/download', json={'id': '347230'}) + r = env.post('/download', json={'id': '347230'}) assert r.status_code == 401 @@ -81,7 +81,7 @@ def test_download_bad_id_refunds(env): ck, _ = _login(env, 'payer') H = {'Cookie': f'session={ck}'} before = _balance('payer') - r = env.post('/api/v1/download', json={'id': 'not-a-number'}, headers=H) + r = env.post('/download', json={'id': 'not-a-number'}, headers=H) assert r.status_code == 400 assert '无效' in r.json()['message'] # 预扣已退回,且无 open 账本行残留 @@ -93,7 +93,7 @@ def test_download_bad_quality_refunds(env): ck, _ = _login(env, 'payer') H = {'Cookie': f'session={ck}'} before = _balance('payer') - r = env.post('/api/v1/download', json={'id': '347230', 'quality': 'nope'}, headers=H) + r = env.post('/download', json={'id': '347230', 'quality': 'nope'}, headers=H) assert r.status_code == 400 assert _balance('payer') == before assert _open_ledger_rows() == [] @@ -103,7 +103,7 @@ def test_download_bad_format_refunds(env): ck, _ = _login(env, 'payer') H = {'Cookie': f'session={ck}'} before = _balance('payer') - r = env.post('/api/v1/download', json={'id': '347230', 'format': 'xml'}, headers=H) + r = env.post('/download', json={'id': '347230', 'format': 'xml'}, headers=H) assert r.status_code == 400 assert _balance('payer') == before @@ -111,7 +111,7 @@ def test_download_bad_format_refunds(env): def test_download_insufficient_credits(env): ck, _ = _login(env, 'broke') H = {'Cookie': f'session={ck}'} - r = env.post('/api/v1/download', json={'id': '347230'}, headers=H) + r = env.post('/download', json={'id': '347230'}, headers=H) assert r.status_code == 401 assert r.json().get('error_code') == 'expired' # 未开账 @@ -124,7 +124,7 @@ def test_batch_zip_too_many_refunds(env): H = {'Cookie': f'session={ck}'} before = _balance('rich') ids = ','.join(str(1000 + i) for i in range(501)) - r = env.post('/api/v1/download/batch_zip', json={'ids': ids}, headers=H) + r = env.post('/download/batch_zip', json={'ids': ids}, headers=H) assert r.status_code == 400 assert '500' in r.json()['message'] # 预扣(封顶 500)已全额退回,余额回到 600 @@ -136,6 +136,6 @@ def test_batch_zip_missing_ids_refunds(env): ck, _ = _login(env, 'payer') H = {'Cookie': f'session={ck}'} before = _balance('payer') - r = env.post('/api/v1/download/batch_zip', json={}, headers=H) + r = env.post('/download/batch_zip', json={}, headers=H) assert r.status_code == 400 assert _balance('payer') == before diff --git a/tests/test_golden.py b/tests/test_golden.py new file mode 100644 index 0000000..72b89a9 --- /dev/null +++ b/tests/test_golden.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""黄金契约回归:FastAPI 严格复现 Flask 时代的响应。 + +tests/golden/responses.json 是**删除 Flask 之前**从 Flask 应用捕获的快照 +(状态码 + 响应体)。本测试让 FastAPI(根路径模式)跑同一批请求, +逐字段断言一致——这样删除 Flask 巨石后,行为回归仍有硬证据。 + +说明: +- 会话 Cookie 在运行时用当前库的 login_seq 动态构造(快照不存 Cookie)。 +- 请求为只读/确定性调用,不含实时上游数据(那些由 parity/单测覆盖)。 +""" +import hashlib +import json +import sqlite3 +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +import auth_system +from core import session as cs + +_GOLDEN = Path(__file__).parent / 'golden' / 'responses.json' +SECRET = 'golden-secret' + + +def _load_cases(): + return json.loads(_GOLDEN.read_text(encoding='utf-8')) + + +def _session_for(role): + """按当前库中该角色用户的 login_seq 造一份合法会话 Cookie。""" + conn = sqlite3.connect('data/site.db') + conn.row_factory = sqlite3.Row + try: + where = "role = 'admin'" if role == 'admin' else "role != 'admin' AND status = 1" + row = conn.execute( + f'SELECT id, login_seq FROM users WHERE {where} AND login_seq IS NOT NULL LIMIT 1' + ).fetchone() + finally: + conn.close() + if row is None: + return None + return cs.dumps(SECRET, { + 'site_uid': int(row['id']), 'site_uid_seq': int(row['login_seq']), + 'site_uid_ts': int(time.time()), + }) + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + # 站点库仍用生产 data/site.db(只读),密钥 patch 成固定值以便造 Cookie + monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + monkeypatch.setattr(auth_system, 'get_secret_key', lambda: SECRET) + from web.app import create_app + return TestClient(create_app(api_prefix='')) + + +# 响应对比前剔除的易变字段(时钟) +_VOLATILE = {'timestamp'} + + +def _strip(obj): + if isinstance(obj, dict): + return {k: _strip(v) for k, v in obj.items() if k not in _VOLATILE} + if isinstance(obj, list): + return [_strip(v) for v in obj] + return obj + + +def _request(client, method, path, payload, cookie): + headers = {'Cookie': f'session={cookie}'} if cookie else {} + if method == 'GET': + return client.get(path, headers=headers) + return client.post(path, json=payload or {}, headers=headers) + + +def test_golden_contract(client): + """逐例复现 Flask 快照。""" + cases = _load_cases() + failures = [] + for c in cases: + if c.get('html_len'): + r = client.get(c['path']) + if r.status_code != c['status'] or len(r.content) != c['html_len']: + failures.append(f"{c['name']}: html {r.status_code}/{len(r.content)} " + f"!= {c['status']}/{c['html_len']}") + continue + cookie = None + if c['auth'] == 'admin': + cookie = _session_for('admin') + elif c['auth'] == 'user': + cookie = _session_for('user') + r = _request(client, c['method'], c['path'], c['payload'], cookie) + body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None + if r.status_code != c['status']: + failures.append(f"{c['name']}: status {r.status_code} != {c['status']}") + elif _strip(body) != _strip(c['body']): + failures.append(f"{c['name']}: body\n fastapi={body}\n golden ={c['body']}") + assert not failures, '黄金契约回归失败:\n' + '\n'.join(failures) + + +def test_index_html_stable(client): + """首页渲染逐字节稳定(模板未改时)。""" + cases = {c['name']: c for c in _load_cases()} + c = cases['index'] + r = client.get('/') + assert hashlib.sha256(r.content).hexdigest() == c['html_sha256'] diff --git a/tests/test_register.py b/tests/test_register.py index 05f245a..98753de 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -30,7 +30,7 @@ def env(tmp_path, monkeypatch): monkeypatch.setattr(db, 'DB_PATH', db_path) auth.init_db() monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) - import auth_routes + import web.auth_state as auth_routes auth_routes._SEND_CODE_IP.clear() from web.app import create_app return TestClient(create_app()) @@ -47,7 +47,7 @@ def _seed_code(email: str, code: str, purpose: str = 'register'): def test_register_requires_agreement(env): - r = env.post('/api/v1/auth/register', json={ + r = env.post('/auth/register', json={ 'username': 'newuser', 'password': 'Passw0rd12', 'email': 'n@example.com', 'code': '000000', 'agree': '0'}) assert r.json()['message'].startswith('请先阅读并同意') @@ -55,7 +55,7 @@ def test_register_requires_agreement(env): def test_register_success(env): _seed_code('new@example.com', '123456') - r = env.post('/api/v1/auth/register', json={ + r = env.post('/auth/register', json={ 'username': 'newuser', 'password': 'Passw0rd12', 'email': 'new@example.com', 'code': '123456', 'agree': '1', 'device_id': 'dev-1'}) assert r.status_code == 200 @@ -65,18 +65,18 @@ def test_register_success(env): assert 'session' in r.cookies # 会话可用 - assert env.post('/api/v1/auth/heartbeat', json={}).status_code == 200 + assert env.post('/auth/heartbeat', json={}).status_code == 200 # 用户名/邮箱已被占用 _seed_code('new@example.com', '222222') - r2 = env.post('/api/v1/auth/register', json={ + r2 = env.post('/auth/register', json={ 'username': 'newuser', 'password': 'Passw0rd12', 'email': 'new@example.com', 'code': '222222', 'agree': '1'}) assert '已被注册' in r2.json()['message'] def test_register_bad_code(env): - r = env.post('/api/v1/auth/register', json={ + r = env.post('/auth/register', json={ 'username': 'another', 'password': 'Passw0rd12', 'email': 'a@example.com', 'code': '999999', 'agree': '1'}) assert r.json()['message'] == '邮箱验证码错误或已过期' @@ -86,12 +86,12 @@ def test_forgot_password_resets(env): # 建一个用户 auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5) _seed_code('f@example.com', '654321', purpose='reset') - r = env.post('/api/v1/auth/forgot_password', json={ + r = env.post('/auth/forgot_password', json={ 'email': 'f@example.com', 'code': '654321', 'password': 'NewPass456'}) assert r.status_code == 200 assert r.json()['success'] is True # 新密码可登录 - r2 = env.post('/api/v1/auth/login', json={'username': 'forgetme', 'password': 'NewPass456'}) + r2 = env.post('/auth/login', json={'username': 'forgetme', 'password': 'NewPass456'}) assert r2.status_code == 200 and r2.json()['success'] is True @@ -101,7 +101,7 @@ def test_forgot_password_admin_blocked(env): conn.commit() conn.close() _seed_code('boss@example.com', '111111', purpose='reset') - r = env.post('/api/v1/auth/forgot_password', json={ + r = env.post('/auth/forgot_password', json={ 'email': 'boss@example.com', 'code': '111111', 'password': 'NewPass456'}) # 管理员不走自助找回:统一防枚举文案 assert r.json()['message'] == '邮箱验证码错误或已过期' @@ -111,8 +111,8 @@ def test_redeem_card(env): # 造一张卡密并让用户登录 auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0) code = auth.generate_cards(1, 10, note='test')[0] - env.post('/api/v1/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'}) - r = env.post('/api/v1/auth/redeem', json={'code': code}) + env.post('/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'}) + r = env.post('/auth/redeem', json={'code': code}) assert r.status_code == 200 assert r.json()['success'] is True assert r.json()['data']['credits'] == 10 diff --git a/tests/test_root_mode.py b/tests/test_root_mode.py index 3da261f..29e010a 100644 --- a/tests/test_root_mode.py +++ b/tests/test_root_mode.py @@ -62,6 +62,6 @@ def test_root_page_served(root_client): def test_no_api_v1_prefix_in_root_mode(root_client): - """root 模式下不再暴露 /api/v1 前缀(避免与 Flask 时代语义混淆)。""" + """根路径模式下不再有 /api/v1 前缀(Flask 时代的比对前缀已废弃)。""" r = root_client.post('/api/v1/song', json={}) assert r.status_code == 404 diff --git a/tests/test_session.py b/tests/test_session.py index 307f790..a56bda1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,54 +1,57 @@ # -*- coding: utf-8 -*- -"""Flask 兼容会话编解码的单元测试(不依赖网络)。""" +"""Flask 兼容会话编解码的单元测试。 + +验证 core.session 与 itsdangerous(Flask 会话所用库)**双向互通**: +Flask 时代签发的会话 Cookie 必须仍能被本站解析(用户不必重新登录)。 + +Flask 本身已从依赖中移除,这里直接用 itsdangerous 构造/校验 +(Flask 会话即 itsdangerous URLSafeTimedSerializer:盐 cookie-session、HMAC-SHA1)。 +""" +import hashlib import time import pytest -from flask import Flask -from flask.sessions import SecureCookieSessionInterface +from itsdangerous import URLSafeTimedSerializer from core import session as cs SECRET = 'unit-test-secret-key' -@pytest.fixture() -def flask_serializer(): - app = Flask(__name__) - app.secret_key = SECRET - return SecureCookieSessionInterface().get_signing_serializer(app) +def _flask_serializer(): + """等价于 Flask 的会话序列化器(同盐、同摘要算法、同 key 派生)。""" + return URLSafeTimedSerializer( + SECRET, salt='cookie-session', + signer_kwargs={'key_derivation': 'hmac', 'digest_method': hashlib.sha1}) -def _flask_cookie(ser, data): - return ser.dumps(data) - - -def test_flask_to_core(flask_serializer): - """Flask 签发的会话 Cookie 能被 core.session 解出。""" - cookie = _flask_cookie(flask_serializer, {'site_uid': 26, 'site_uid_seq': 5}) +def test_itsdangerous_to_core(): + """itsdangerous(Flask)签发的会话 Cookie 能被 core.session 解出。""" + cookie = _flask_serializer().dumps({'site_uid': 26, 'site_uid_seq': 5}) data = cs.loads(SECRET, cookie) assert data == {'site_uid': 26, 'site_uid_seq': 5} assert cs.get_uid(data) == 26 -def test_core_to_flask(flask_serializer): - """core.session 签发(含时间戳)的 Cookie 能被 Flask 解出。""" +def test_core_to_itsdangerous(): + """core.session 签发的 Cookie 能被 itsdangerous 解出。""" cookie = cs.dumps(SECRET, {'site_uid': 74, 'site_uid_seq': 8}) - assert flask_serializer.loads(cookie) == {'site_uid': 74, 'site_uid_seq': 8} + assert _flask_serializer().loads(cookie) == {'site_uid': 74, 'site_uid_seq': 8} -def test_core_to_flask_expiry(flask_serializer): - """时间戳格式正确:Flask 能按 max_age 校验(未过期可解)。""" +def test_core_timestamp_valid_for_timed_check(): + """时间戳编码与 itsdangerous 一致:max_age 校验可用(未过期)。""" cookie = cs.dumps(SECRET, {'site_uid': 1}) - assert flask_serializer.loads(cookie, max_age=3600) == {'site_uid': 1} + assert _flask_serializer().loads(cookie, max_age=3600) == {'site_uid': 1} -def test_tampered_returns_none(flask_serializer): - cookie = _flask_cookie(flask_serializer, {'site_uid': 26}) +def test_tampered_returns_none(): + cookie = _flask_serializer().dumps({'site_uid': 26}) assert cs.loads(SECRET, cookie[:-3] + 'xyz') is None -def test_wrong_key_returns_none(flask_serializer): - cookie = _flask_cookie(flask_serializer, {'site_uid': 26}) +def test_wrong_key_returns_none(): + cookie = _flask_serializer().dumps({'site_uid': 26}) assert cs.loads('wrong-secret', cookie) is None @@ -65,3 +68,8 @@ def test_roundtrip_selfconsistency(): def test_get_uid_none(): assert cs.get_uid(None) is None assert cs.get_uid({}) is None + + +@pytest.mark.parametrize('uid', [1, 26, 99999]) +def test_various_uids(uid): + assert cs.get_uid(cs.loads(SECRET, cs.dumps(SECRET, {'site_uid': uid}))) == uid diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6968d8d --- /dev/null +++ b/uv.lock @@ -0,0 +1,1574 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "multidict" +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, + { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] + +[[package]] +name = "yunmusic-assistant" +version = "3.0.0" +source = { virtual = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiosqlite" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "qrcode" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=24.1" }, + { name = "aiohttp", specifier = ">=3.10,<4.0" }, + { name = "aiosqlite", specifier = ">=0.20,<1.0" }, + { name = "cryptography", specifier = ">=50.0,<51.0" }, + { name = "fastapi", specifier = ">=0.115,<1.0" }, + { name = "itsdangerous", specifier = ">=2.2,<3.0" }, + { name = "jinja2", specifier = ">=3.1,<4.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "qrcode", specifier = ">=7.0,<8.3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pyright", specifier = ">=1.1.390" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.6" }, +] diff --git a/web/app.py b/web/app.py index c4fe781..750ae3e 100644 --- a/web/app.py +++ b/web/app.py @@ -4,13 +4,8 @@ 设计(strangler 迁移): - 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存, 便于逐端点比对响应契约(.zcode/refactor/parity.py)。 -- `serve_flask_fallback=True` 时,把未迁移的遗留 Flask 应用整体挂到根路径兜底, - 实现「新端点走 FastAPI、其余走 Flask」的单进程双框架运行;迁移完成后删掉该开关。 - -注意(默认关闭的原因):Flask fallback 会在 FastAPI 进程内 import main, -进而初始化 MusicAPIService / 打开 SQLite —— 与生产既有的 waitress 进程是 -两套独立进程语义。生产切换前需按计划把状态与任务模型统一(见迁移计划阶段 C), -当前仅用于开发期联调。 +迁移已完成:Flask 巨石(main.py/auth_routes.py)已删除,本应用即唯一服务端。 +`api_prefix` 为空即直接服务前端使用的根路径;非空则挂在给定前缀下(用于契约比对)。 """ from contextlib import asynccontextmanager @@ -64,13 +59,12 @@ def _init_audio_secret() -> None: pass -def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1') -> FastAPI: +def create_app(api_prefix: str = '') -> FastAPI: """创建 FastAPI 应用。 - api_prefix:已迁移路由的挂载前缀。 - - '/api/v1'(默认):与 Flask 根路径并存,便于逐端点契约比对(迁移期) - - ''(切换完成):FastAPI 直接服务前端使用的根路径,不再需要 Flask 兜底 - serve_flask_fallback:把 Flask 应用挂到根路径兜底(仅 api_prefix 非空时可用)。 + api_prefix:路由挂载前缀。 + - ''(默认):直接服务前端使用的根路径(生产形态) + - '/api/v1':挂在子前缀下(契约比对脚本 .zcode/refactor/parity.py 用) """ _init_audio_secret() @@ -112,16 +106,4 @@ def create_app(serve_flask_fallback: bool = False, api_prefix: str = '/api/v1') install_error_handlers(app) - if serve_flask_fallback and api_prefix: - _mount_flask_fallback(app) - return app - - -def _mount_flask_fallback(app: FastAPI) -> None: - """把遗留 Flask WSGI 应用挂到根路径,兜底未迁移的路由。""" - from starlette.middleware.wsgi import WSGIMiddleware - - import main as legacy_flask - - app.mount('/', WSGIMiddleware(legacy_flask.app)) diff --git a/web/auth_state.py b/web/auth_state.py new file mode 100644 index 0000000..bf65e7d --- /dev/null +++ b/web/auth_state.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +"""认证的内存态与校验辅助(原 auth_routes 的模块级状态,框架无关)。 + +包含:登录失败锁定、管理员 2FA 票据、发码 IP 限流、用户名/保留名校验。 +这些状态**进程内共享**——登录/注册/2FA 各端点都读写同一份,避免「换个端点绕过锁定」。 +""" +import secrets +import time + +import auth_system as auth + +# ---- 登录失败锁定(按用户名维度,10 分钟窗口内 >=5 次临时锁定)---- +_LOGIN_FAILS: dict = {} +_LOGIN_FAIL_SWEEP_TS = 0.0 +# 本锁定周期已落过审计日志的用户名(解锁或成功登录即重置) +_LOGIN_FAIL_LOGGED: set = set() + +# ---- 管理员两步验证票据(内存态)---- +_ADMIN_2FA: dict = {} +_ADMIN2FA_LAST_SEND: dict = {} + + +_LOGIN_FAIL_LIMIT = 5 + + +_LOGIN_FAIL_WINDOW = 600 + + +_LOGIN_FAIL_SWEEP_TS = 0.0 + + +_ADMIN_2FA_TTL = 300 # 票据 5 分钟有效 + + +_ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限 + + +_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧) + + +_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环 + + +def _admin2fa_purge() -> None: + now = time.time() + for t in [t for t, v in _ADMIN_2FA.items() if now - v['ts'] > _ADMIN_2FA_TTL]: + _ADMIN_2FA.pop(t, None) + + +def _admin2fa_mask(email: str) -> str: + """邮箱掩码(审计日志与前端提示用):3*****@qq.com 样式""" + try: + local, dom = str(email).split('@', 1) + return (local[0] if local else '*') + '*****@' + dom + except Exception: + return '*****' + + +def _admin2fa_issue(user) -> str: + _admin2fa_purge() + # 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票 + for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]: + _ADMIN_2FA.pop(t, None) + if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS: + items = list(_ADMIN_2FA.items()) # 快照后再 min,防并发 pop 使迭代失效 + if items: + oldest = min(items, key=lambda kv: kv[1]['ts'])[0] + _ADMIN_2FA.pop(oldest, None) + ticket = secrets.token_urlsafe(16) + _ADMIN_2FA[ticket] = {'user_id': user['id'], 'username': user['username'], + 'email': user['email'], 'ts': int(time.time()), 'attempts': 0} + return ticket + + +def _admin2fa_take(ticket: str): + """取活票(不销毁):无票/过期返回 (None, None);命中返回 (ticket, 数据)。""" + _admin2fa_purge() + v = _ADMIN_2FA.get(ticket or '') + if not v: + return None, None + return ticket, v + + +def _login_fail_check(username: str) -> bool: + """返回 True 表示允许尝试登录;锁定窗口内返回 False""" + global _LOGIN_FAIL_SWEEP_TS + now = time.time() + fails = [t for t in _LOGIN_FAILS.get(username, []) if now - t < _LOGIN_FAIL_WINDOW] + _LOGIN_FAILS[username] = fails + if len(fails) < _LOGIN_FAIL_LIMIT: + _LOGIN_FAIL_LOGGED.discard(username) + if len(_LOGIN_FAILS) > 10000 and now - _LOGIN_FAIL_SWEEP_TS > 60: # 容量上限(用户名为键,防无界增长) + _LOGIN_FAIL_SWEEP_TS = now + stale_users = [u for u, ts in _LOGIN_FAILS.items() + if not ts or now - max(ts) > _LOGIN_FAIL_WINDOW] + for u in stale_users[:5000]: + _LOGIN_FAILS.pop(u, None) + if len(_LOGIN_FAILS) > 20000: + for u in list(_LOGIN_FAILS.keys())[:len(_LOGIN_FAILS) // 2]: + _LOGIN_FAILS.pop(u, None) + return len(fails) < _LOGIN_FAIL_LIMIT + + +def _login_fail_record(username: str) -> None: + _LOGIN_FAILS.setdefault(username, []).append(time.time()) + + +def _login_fail_clear(username: str) -> None: + _LOGIN_FAILS.pop(username, None) + _LOGIN_FAIL_LOGGED.discard(username) + + +def _login_fail_locked_log_once(username: str) -> bool: + """R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表; + 解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。""" + if username in _LOGIN_FAIL_LOGGED: + return False + _LOGIN_FAIL_LOGGED.add(username) + return True + + +def _safe_register_credits() -> int: + """注册赠送次数(settings.register_credits,默认 3)。 + R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。""" + try: + credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0) + except (TypeError, ValueError): + return auth.DEFAULT_REGISTER_CREDITS + return max(0, credits) + + +_SEND_CODE_IP = {} + + +_SEND_CODE_IP_LIMIT = 10 + + +_SEND_CODE_IP_WINDOW = 3600 + + +def _send_code_ip_check(ip: str) -> bool: + """返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)""" + now = time.time() + if len(_SEND_CODE_IP) > 10000: + stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW] + for k in stale: + _SEND_CODE_IP.pop(k, None) + cnt, win = _SEND_CODE_IP.get(ip, (0, now)) + if now - win > _SEND_CODE_IP_WINDOW: + cnt, win = 0, now + if cnt >= _SEND_CODE_IP_LIMIT: + return False + _SEND_CODE_IP[ip] = (cnt + 1, win) + return True + + +_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'} + + +def re_username_ok(username: str) -> bool: + import re as _re + # P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险) + # R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符 + #("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口 + return bool(_re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \ + not _re.match(r'^_+$', username) and not username.startswith('_') and not username.endswith('_') \ + and username.lower() not in _RESERVED_USERNAMES + + +def secrets_rand() -> int: + import secrets as _s + return _s.randbelow(900000) + 100000 diff --git a/web/main.py b/web/main.py index 6c26ff5..246238e 100644 --- a/web/main.py +++ b/web/main.py @@ -22,15 +22,11 @@ from web.app import create_app HOST = os.environ.get('HOST', '0.0.0.0') PORT = int(os.environ.get('PORT', '5000')) -# 运行模式(默认保持迁移期形态,切换为显式操作): -# WEB_MODE=dual(默认)已迁移路由挂 /api/v1 + Flask 兜底其余路由(前端仍走根路径→Flask) -# WEB_MODE=fastapi 已迁移路由直接挂根路径,完全由 FastAPI 服务(不再需要 Flask) -WEB_MODE = os.environ.get('WEB_MODE', 'dual').strip().lower() +# Flask 巨石已删除:本应用即唯一服务端,直接服务前端使用的根路径。 +# API_PREFIX 非空时挂在子前缀下(契约比对脚本使用)。 +API_PREFIX = os.environ.get('API_PREFIX', '').strip() -if WEB_MODE == 'fastapi': - app = create_app(serve_flask_fallback=False, api_prefix='') -else: - app = create_app(serve_flask_fallback=True, api_prefix='/api/v1') +app = create_app(api_prefix=API_PREFIX) if __name__ == '__main__': uvicorn.run( diff --git a/web/ratelimit.py b/web/ratelimit.py index 344a24a..20fd66c 100644 --- a/web/ratelimit.py +++ b/web/ratelimit.py @@ -19,18 +19,22 @@ _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'} def client_ip(request: Request) -> str: - """客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP,与 Flask 同策略。""" - import auth_system + """客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP。 + + 规则与 Flask 时代完全一致(core.net 与原实现同源):XFF 最右段优先, + 最右段为回环时回落 X-Real-IP,全部无效回落直连地址。 + """ + from core.net import is_loopback_ip, trusted_proxy_ip remote = request.client.host if request.client else '-' - if remote and remote != '-' and auth_system._is_loopback_ip(remote): + if remote and remote != '-' and is_loopback_ip(remote): xff = request.headers.get('x-forwarded-for') if xff: - ip = auth_system._trusted_proxy_ip(xff) - if ip and not auth_system._is_loopback_ip(ip): + ip = trusted_proxy_ip(xff) + if ip and not is_loopback_ip(ip): return ip xreal = request.headers.get('x-real-ip') if xreal: - ip = auth_system._trusted_proxy_ip(xreal) + ip = trusted_proxy_ip(xreal) if ip: return ip return remote or '-' diff --git a/web/routers/auth.py b/web/routers/auth.py index 587e9ab..a3a497f 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -8,7 +8,7 @@ - POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据) 设计: -- 登录失败锁定与管理员 2FA 票据表**复用 auth_routes 的模块级状态**,两栈共享同一份 +- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份 计数/票据,避免「换个框架打就绕过锁定」。 - 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行, 不阻塞事件循环。 @@ -104,7 +104,7 @@ async def ann_seen(user: Annotated[SiteUser | None, Depends(current_user)]): @router.post('/auth/login') async def login(request: Request): """登录:密码校验(含邮箱登录)、失败锁定、管理员两步验证转票据。""" - from auth_routes import ( + from web.auth_state import ( _admin2fa_issue, _admin2fa_mask, _login_fail_check, @@ -201,7 +201,7 @@ async def admin_login_email(request: Request): """管理员 2FA Step2:比对绑定邮箱 → 发送 admin2fa 验证码。""" import hmac as _hmac - from auth_routes import ( + from web.auth_state import ( _ADMIN2FA_LAST_SEND, _ADMIN2FA_SEND_GAP, _ADMIN_2FA, @@ -245,7 +245,7 @@ async def admin_login_email(request: Request): @router.post('/auth/admin_login/verify') async def admin_login_verify(request: Request): """管理员 2FA Step3:验证码原子消费 → 建立管理员会话。""" - from auth_routes import ( + from web.auth_state import ( _ADMIN_2FA, _ADMIN_2FA_MAX_ATTEMPTS, _admin2fa_take, diff --git a/web/routers/download.py b/web/routers/download.py index 05ce7b8..ae968e2 100644 --- a/web/routers/download.py +++ b/web/routers/download.py @@ -115,8 +115,8 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ lyric_format = 'lrc' need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') - import main as legacy - downloader = legacy.api_service.downloader + import services.runtime as services + downloader = services.api_service.downloader if return_format == 'json': try: @@ -127,7 +127,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ await billing.refund_open('rejected') return error('获取失败:歌曲不存在或暂无版权', 404) base = await asyncio.to_thread( - legacy._build_download_base, filename_format, + services._build_download_base, filename_format, {'name': info.name, 'artist_string': info.artists}) filename = f'{base}.{info.file_type}' try: @@ -165,7 +165,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ # file 模式:注册后台任务 device_id = str(data.get('device_id') or '').strip()[:64] - await asyncio.to_thread(legacy._touch_device, device_id) + await asyncio.to_thread(services._touch_device, device_id) task = _single_task(data, music_id, quality, filename_format, write_meta, meta_fields, lyric_format, need_zip, cookies, ip, device_id) reg_err = await register_download_task(billing, task, str(data.get('task_id') or '').strip()) @@ -173,7 +173,7 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ await billing.refund_open('rejected') return error(reg_err, 429) task_id = task['task_id'] - start_err = await start_task(task_id, legacy._single_download_worker, (task_id,)) + start_err = await start_task(task_id, services._single_download_worker, (task_id,)) if start_err: return error(start_err, 500) return success({'task_id': task_id, 'status': 'queued'}, '下载任务已提交') @@ -248,8 +248,8 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us cookies = _cookies() device_id = str(data.get('device_id') or '').strip()[:64] - import main as legacy - await asyncio.to_thread(legacy._touch_device, device_id) + import services.runtime as services + await asyncio.to_thread(services._touch_device, device_id) task = { 'type': 'batch', 'total': len(ids), 'done': 0, 'failed': 0, @@ -269,7 +269,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us await billing.refund_open('rejected') return error(reg_err, 429) task_id = task['task_id'] - start_err = await start_task(task_id, legacy._batch_zip_worker, (task_id,)) + start_err = await start_task(task_id, services._batch_zip_worker, (task_id,)) if start_err: return error(start_err, 500) return success({'task_id': task_id, 'status': 'queued', 'total': len(ids)}, @@ -330,8 +330,8 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user cookies = _cookies() device_id = str(data.get('device_id') or '').strip()[:64] - import main as legacy - await asyncio.to_thread(legacy._touch_device, device_id) + import services.runtime as services + await asyncio.to_thread(services._touch_device, device_id) task = { 'type': 'single', 'total': 1, 'done': 0, 'failed': 0, @@ -346,7 +346,7 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user await billing.refund_open('rejected') return error(reg_err, 429) task_id = task['task_id'] - start_err = await start_task(task_id, legacy._podcast_download_worker, (task_id,)) + start_err = await start_task(task_id, services._podcast_download_worker, (task_id,)) if start_err: return error(start_err, 500) return success({'task_id': task_id, 'status': 'queued'}, '播客单集下载任务已提交') @@ -402,8 +402,8 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use billed = _podcast_billing_count(data) device_id = str(data.get('device_id') or '').strip()[:64] - import main as legacy - await asyncio.to_thread(legacy._touch_device, device_id) + import services.runtime as services + await asyncio.to_thread(services._touch_device, device_id) task = { 'type': 'batch', 'total': 0, 'done': 0, 'failed': 0, @@ -425,7 +425,7 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use await billing.refund_open('rejected') return error(reg_err, 429) task_id = task['task_id'] - start_err = await start_task(task_id, legacy._podcast_zip_worker, (task_id,)) + start_err = await start_task(task_id, services._podcast_zip_worker, (task_id,)) if start_err: return error(start_err, 500) return success({'task_id': task_id, 'status': 'queued'}, '播客打包任务已提交') diff --git a/web/routers/register.py b/web/routers/register.py index 0df2741..aaa463c 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -11,7 +11,7 @@ 说明: - 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行, 不阻塞事件循环;行为与 Flask 完全一致。 -- 发码 IP 限流复用 auth_routes 的模块级状态 `_SEND_CODE_IP`(跨栈共享计数)。 +- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。 - 失败锁定/验证码消费等安全口径逐字保留。 """ import asyncio @@ -64,7 +64,7 @@ async def _safe_register_credits() -> int: @router.post('/auth/send_code') async def send_code(request: Request): """发送邮箱验证码(注册/找回用途隔离)。""" - from auth_routes import _send_code_ip_check + from web.auth_state import _send_code_ip_check ip = ratelimit.client_ip(request) if not _send_code_ip_check(ip): return error('验证码请求过于频繁,请稍后再试') @@ -141,7 +141,7 @@ async def forgot_password(request: Request): @router.post('/auth/register') async def register(request: Request): """注册:验证码校验 → 用户名/邮箱查重 → 防刷双闸 → 建号赠送 → 建立会话。""" - from auth_routes import re_username_ok + from web.auth_state import re_username_ok data = await _request_data(request) username = str(data.get('username') or '').strip() diff --git a/web/routers/tasks.py b/web/routers/tasks.py index e761fcb..a39b58f 100644 --- a/web/routers/tasks.py +++ b/web/routers/tasks.py @@ -58,14 +58,14 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool: def _semaphores(): """批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。""" - import main as legacy - return legacy.BATCH_ZIP_SEM, legacy.api_service.downloader._download_semaphore + import services.runtime as services + return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore @router.api_route('/api/topen', methods=['GET', 'POST']) async def topen(request: Request): """接口请求统计(实时)——锁内快照,避免 total 与明细不一致。""" - import main as legacy + import services.runtime as services with STATS_LOCK: snapshot = {p: dict(v) for p, v in REQUEST_STATS.items()} endpoints = [] @@ -73,7 +73,7 @@ async def topen(request: Request): for path, info in snapshot.items(): endpoints.append({ 'path': path, - 'name': legacy.ENDPOINT_NAMES.get(path, path), + 'name': services.ENDPOINT_NAMES.get(path, path), 'count': info['count'], 'percent': round(info['count'] / total * 100, 1) if total else 0, 'last': info['last'], diff --git a/web/tasks_registry.py b/web/tasks_registry.py index 1d53e53..1fb61dc 100644 --- a/web/tasks_registry.py +++ b/web/tasks_registry.py @@ -72,8 +72,8 @@ async def register_download_task(billing: Billing, task: dict[str, Any], async def start_task(task_id: str, worker: Callable, args: tuple = ()) -> str | None: - """启动 worker 线程(复用 main._start_task_thread 的实现,失败收敛终态并退费)。""" + """启动 worker 线程(复用 services 的 _start_task_thread,失败收敛终态并退费)。""" import asyncio - import main as legacy - return await asyncio.to_thread(legacy._start_task_thread, task_id, worker, args) + import services.runtime as services + return await asyncio.to_thread(services._start_task_thread, task_id, worker, args) diff --git a/功能介绍.md b/功能介绍.md index 27d4ac0..8785858 100644 --- a/功能介绍.md +++ b/功能介绍.md @@ -1,6 +1,6 @@ # 云村小助手 · 功能介绍 -> 一个基于 Flask + 原生前端的云村小助手。 +> 一个基于 FastAPI + 原生前端的云村小助手。 > 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、ZIP 打包、 > 歌词逐字高亮、评论区、接口统计、隐私协议 Cookie 管理等完整功能。 diff --git a/部署文档.md b/部署文档.md index b20017c..c98b086 100644 --- a/部署文档.md +++ b/部署文档.md @@ -22,18 +22,39 @@ ## 二、文件说明 ``` -Netease_url/ -├── main.py # Flask 主程序(路由、计费、音频代理防护) +NeteaseUrl/ +├── web/ # FastAPI 应用(唯一服务端) +│ ├── main.py # uvicorn 入口(python web/main.py) +│ ├── app.py # create_app 工厂(挂路由 / 静态资源 / 中间件) +│ ├── deps.py # 会话解析、当前用户、登录/管理员依赖 +│ ├── billing.py # 计费依赖(预扣+开账同事务、只退 open 行) +│ ├── tasks_registry.py # 任务登记/启动(容量闸、账本承接) +│ ├── middleware.py # 安全头 + 音频直链签名保护 +│ ├── ratelimit.py # 按 IP 滑动窗限流(与 core.state 共享桶) +│ ├── auth_state.py # 登录失败锁定 / 管理员 2FA 票据 / 发码限流 +│ └── routers/ # 各领域路由(music/podcast/download/auth/admin…) +├── core/ # 框架无关核心层 +│ ├── config.py # 常量与配置 +│ ├── state.py # 进程内运行时状态(任务表/限流桶/缓存) +│ ├── db.py / repo.py # 异步 SQLite(WAL:读并发、写串行) +│ ├── http_client.py # aiohttp 连接池客户端 +│ ├── aiohttp_compat.py # requests 同形异步垫片 +│ ├── async_bridge.py # 同步↔异步桥(遗留同步调用点) +│ ├── session.py # itsdangerous 兼容会话编解码 +│ ├── security.py # 音频 HMAC 签名 +│ ├── net.py / media.py / utils.py / serializers… # 纯工具 +├── services/runtime.py # 服务层(MusicAPIService、下载/打包 worker、任务监控) ├── auth_system.py # 站点账号系统(SQLite:用户/卡密/公告/日志/封禁/顶号) -├── auth_routes.py # 认证与管理后台路由 -├── music_api.py # 网易云音乐 API 封装 +├── music_api.py # 网易云音乐 API 封装(全异步) ├── music_downloader.py # 下载器(ffmpeg 元数据 / ZIP 打包) ├── podcast_api.py # 播客解析 API ├── qr_login.py # 网易云扫码登录 ├── templates/index.html # 前端页面(原生 HTML/CSS/JS) ├── static/site_auth.js # 前端认证 / 个人中心 / 管理后台交互 -├── requirements.txt # Python 依赖(Python 3.12) -├── Dockerfile # 镜像构建文件 +├── pyproject.toml # 依赖与工具配置(uv 管理) +├── uv.lock # 精确锁定依赖版本 +├── tests/ # 测试(含 golden 契约回归) +├── Dockerfile # 镜像构建文件(uv sync) ├── docker-compose.yml # 编排配置(端口 / 持久卷) ├── entrypoint.sh # 容器启动脚本 ├── cookie.txt # 全局网易云账号 Cookie(部署后扫码生成,勿提交)