refactor: 下载/标签/格式探测改用成熟库(aria2 + mutagen + filetype)

为什么:原先三处都在手写轮子——ffmpeg CLI 拼参数写标签、aiohttp 单连接手搓
下载、自己比对魔数判格式。手写部分既难测又易错;改走成熟库后测试从 144 涨到
175,并且彻底去掉了 ffmpeg 这个外部二进制依赖。

下载 → aria2c 常驻 JSON-RPC(aria2p 客户端),新增 _aria2.py
- 单例后端:首次调用时拉起 aria2c,只监听 127.0.0.1 且强制随机 secret
  (无鉴权的 aria2 RPC 允许本机任意进程操纵队列、读写任意文件)
- split=5 / max-connection-per-server=5 多连接 + 原生断点续传
- 异常语义与内置通道逐一对齐:取消/超限清残片抛 DownloadCanceled /
  DownloadException,任务失败/超时保留残片抛 RequestException 供续传
- 取不到 aria2c 时 get_backend() 返回 None,自动降级到内置 aiohttp 通道
  (本机无 aria2c 的降级路径已实测)

标签 → mutagen 纯 Python 写入,新增 tagging.py
- 取代 _write_metadata_with_ffmpeg + _build_format_args(226 行 shell 参数拼接)
- MP3(ID3v2.3)/FLAC/M4A/OGG/Opus 分容器实现,无子进程、不重编码
- 就地改帧:TXXX 等未触及的标签原样保留(ffmpeg 整帧重写会丢)
- 临时文件 + os.replace 原子替换,写坏不会留下半个文件
- 为什么不是 ffmpeg-python(原方案):实测它用 **kwargs 建模选项,无法表达
  重复键(-metadata ×N),list 值还会被字符串化成 "['0', '1:v']";-map 尚有
  内置的 stream 位置参数入口,但 -metadata 无解,且 overwrite_output=True
  生成的 -y 被排到输出文件之后、可能失效

格式探测 → filetype,改 _http.py
- 删除手写的 _AUDIO_MAGIC 魔数表,改用 filetype.guess() 判 MIME
  (audio/* 放行,video/mp4 作为容器显式放行)

配套
- Dockerfile:镜像不再装 ffmpeg,改装 aria2
- pyproject:新增 filetype / aria2p / mutagen;dev 加 httpx2(消除测试里的
  StarletteDeprecationWarning,现 0 warning)
- __main__.py / __init__.py / models.py:清理 ffmpeg 相关导出与文案

测试:新增 31 个用例(test_aria2.py 13、test_tagging.py 21,含最小合法 FLAC
样本构造);全套 175 passed / 0 warning
This commit is contained in:
snowy-yang committed 2026-09-26 13:54:44 +08:00
1 parent 183f5744d8
commit ad8c30d932
12 files changed
+1367 -369

No files matched your search

+6 -4
View File
@@ -1,11 +1,13 @@
# 基础镜像 Python 3.12(3.9 已于 2025-10 EOL);alpine + ffmpeg 组合保持不变
# 基础镜像 Python 3.12(3.9 已于 2025-10 EOL)
FROM python:3.12-alpine3.21
WORKDIR /app
# 使用阿里云 apk 镜像源加速安装 ffmpeg
# 补 tzdata——alpine 无 zoneinfo,ENV TZ=Asia/Shanghai 不装它不生效
# 使用阿里云 apk 镜像源加速安装
# tzdata:alpine 无 zoneinfo,ENV TZ=Asia/Shanghai 不装它不生效
# (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响)
# aria2:下载加速通道(常驻 RPC,仅监听 127.0.0.1 的随机端口 + 随机 secret)
# ——标签写入已改为 mutagen 纯 Python 实现,镜像不再需要 ffmpeg
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \
apk add --no-cache tzdata ffmpeg
apk add --no-cache tzdata aria2
# 依赖由 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
+5 -9
View File
@@ -1,16 +1,16 @@
# -*- coding: utf-8 -*-
"""网易云音乐下载器(适配 ffmpeg 写入元数据)
"""网易云音乐下载器(标签写入基于 mutagen,纯 Python)
提供功能:
- 同步 / 异步下载(本地落盘 / 内存返回)
- 批量并发下载
- 进度查询
- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
- 基于 mutagen 的标签 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
设计要点:
1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装
2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险
3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart
1. 内容嗅探交给 filetype(按魔数识别容器),不再自维护魔数表
2. 传输通道二选一:aria2c 常驻 RPC(多连接)优先,不可用时回落内置 aiohttp 流式
3. 标签写入按容器分派(ID3v2.3 / Vorbis comment / iTunes ilst),全部原子替换
4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底
"""
@@ -27,7 +27,6 @@ from ._concurrency import (
get_file_lock,
)
from ._http import (
_AUDIO_MAGIC,
_SESSION_LOCAL,
DEFAULT_UA,
DL_CHUNK_SIZE,
@@ -49,7 +48,6 @@ from .downloader import (
)
from .models import (
API_TYPE_TO_EXT,
EXT_TO_FFMPEG_FORMAT,
METADATA_FORMATS,
DownloadCanceled,
DownloadException,
@@ -63,9 +61,7 @@ __all__ = [
'API_TYPE_TO_EXT',
'DEFAULT_UA',
'DL_CHUNK_SIZE',
'EXT_TO_FFMPEG_FORMAT',
'METADATA_FORMATS',
'_AUDIO_MAGIC',
'_COVER_CACHE',
'_COVER_CACHE_LOCK',
'_COVER_CACHE_MAX',
+1 -2
View File
@@ -21,9 +21,8 @@ if __name__ == "__main__":
# 无参数:打印模块信息
if len(sys.argv) < 2:
print("网易云音乐下载器")
print(f"ffmpeg 路径: {downloader.ffmpeg_path or '未找到'}")
print("支持的音质: standard / exhigh / lossless / hires / sky / jyeffect / jymaster / dolby")
print("支持的功能: 同步/批量下载、ffmpeg 元数据写入")
print("支持的功能: 同步/批量下载、标签与封面写入(mutagen 纯 Python)")
print()
print("用法:")
print(" python music_downloader.py <music_id> [quality] # 仅打印信息")
+276
View File
@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""aria2c 常驻 RPC 下载后端(可选加速通道;不可用时调用方降级)。
为什么用常驻 RPC 而不是每个任务起一个 aria2c 进程:
- 批量任务最多 500 首,逐首歌都付一次进程启动 + RPC 握手开销不划算;
- 取消/查询只需一次 RPC,不必在进程表里追踪 N 个子进程。
安全与稳健:
- RPC 只监听 127.0.0.1 并强制随机 secret —— 无鉴权的 aria2 RPC 允许本机任意
进程操纵下载队列(读写任意文件),暴露到容器外等于开了一个后门;
- aria2c 缺失 / 进程早退 / RPC 未就绪,一律返回 None 静默降级,
由调用方回落到既有的 core.sync_http 流式通道;
- aria2 没有「单文件体积上限」选项,故用轮询 tellStatus 实现体积守卫与取消响应。
"""
from __future__ import annotations
import atexit
import secrets
import shutil
import socket
import subprocess
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any, Protocol
from core import sync_http as requests # 同步门面(底层 aiohttp)
from ._http import DEFAULT_UA
from ._log import logger
from .models import DownloadCanceled, DownloadException
class _ProcessHandle(Protocol):
"""aria2c 子进程的最小接口。
只依赖本模块真正调用的方法,既避免绑死 subprocess.Popen 具体类型,
也让测试可以用轻量替身驱动生命周期逻辑。
"""
def poll(self) -> int | None: ...
def terminate(self) -> None: ...
def kill(self) -> None: ...
def wait(self, timeout: float | None = None) -> int: ...
# aria2 可执行文件名。Docker 镜像内由 apk 提供;宿主未装则整条通道降级
ARIA2_BIN = 'aria2c'
# RPC 就绪等待上限:超过即判定启动失败并降级(不拖慢首次下载)
_RPC_READY_TIMEOUT = 8.0
# 状态轮询间隔:取消与体积守卫的响应粒度(0.3s 足够,RPC 开销可忽略)
_POLL_INTERVAL = 0.3
# 单文件体积守卫默认值,与 downloader 内置通道口径一致
_DEFAULT_MAX_BYTES = 500 * 1024 * 1024
# 分片数与单服务器连接数:网易云直链支持 Range,多连接可提速;
# 数值保守(aria2 默认即 5),过高易触发 CDN 风控
_SPLIT = 5
_MIN_SPLIT_SIZE = '1M'
_lock = threading.Lock()
_backend: Aria2Backend | None = None
_unavailable = False # 启动失败后置位,不再反复重试
def _free_port() -> int:
"""向内核要一个空闲端口(bind 后立即释放,交给 aria2c 监听)。"""
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return int(s.getsockname()[1])
class Aria2Backend:
"""aria2c RPC 后端。请通过 get_backend() 获取单例,不要直接构造。"""
def __init__(self, api: Any, proc: _ProcessHandle, port: int) -> None:
self._api = api
self._proc = proc
self.port = port
self._closed = False
# ---------- 生命周期 ----------
def close(self) -> None:
"""关闭 RPC 客户端并终止 aria2c 进程(幂等)。"""
if self._closed:
return
self._closed = True
try:
self._api.client.close()
except Exception:
pass # 客户端关闭失败不影响后续进程回收
if self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc.wait(timeout=5)
# ---------- 下载 ----------
def fetch(
self,
url: str,
target: Path,
*,
ua: str = DEFAULT_UA,
referer: str | None = None,
cancel_event: threading.Event | None = None,
progress_cb: Callable[[int, int], None] | None = None,
max_bytes: int = _DEFAULT_MAX_BYTES,
timeout: float = 600.0,
) -> None:
"""把 url 下载到 target(落地即最终字节,含断点续传)。
Raises:
DownloadCanceled: cancel_event 置位(残片一并删除)
DownloadException: 体积超限(残片一并删除)、aria2 报告任务失败、总超时
requests.RequestException: RPC 抖动等可重试的网络类失败(保留残片续传)
"""
options = {
'dir': str(target.parent),
'out': target.name,
'continue': 'true', # 断点续传(控制文件 target.aria2 与残片同放)
'auto-file-renaming': 'false', # 禁止另存为 target.1,否则与 .part 语义冲突
'allow-overwrite': 'true',
'split': str(_SPLIT),
'max-connection-per-server': str(_SPLIT),
'min-split-size': _MIN_SPLIT_SIZE,
'user-agent': ua,
'summary-interval': '0',
'quiet': 'true',
}
if referer:
options['referer'] = referer
try:
download = self._api.add_uris([url], options=options)
except Exception as e:
raise requests.RequestException(f'aria2 提交任务失败: {e}') from e
deadline = time.monotonic() + timeout
while True:
if cancel_event is not None and cancel_event.is_set():
self._abort(download, drop_files=True)
raise DownloadCanceled('下载已取消')
try:
download.update()
except Exception as e:
# RPC 抖动按可重试的网络失败上报,交给外层退避重试
raise requests.RequestException(f'aria2 状态查询失败: {e}') from e
done = int(download.completed_length or 0)
total = int(download.total_length or 0)
if progress_cb is not None:
try:
progress_cb(done, total)
except Exception:
pass # 进度回调绝不影响下载主流程
if done > max_bytes:
self._abort(download, drop_files=True)
raise DownloadException(
f'文件超过大小上限 {max_bytes // (1024 * 1024)}MB,已中止')
if download.is_complete:
self._drop_control_file(target)
return
if download.has_failed:
# 保留残片与控制文件,等待外层重试时续传
raise requests.RequestException(
f'aria2 下载失败: {download.error_message or download.status}')
if time.monotonic() > deadline:
self._abort(download, drop_files=False)
raise requests.RequestException('aria2 下载超时')
time.sleep(_POLL_INTERVAL)
# ---------- 内部 ----------
def _abort(self, download: Any, *, drop_files: bool) -> None:
"""强制移除任务。drop_files=True 时连同已下载字节一起删除。"""
try:
self._api.remove([download], force=True, files=drop_files)
except Exception as e:
logger.warning(f'终止 aria2 任务失败(不影响主流程): {e}')
@staticmethod
def _drop_control_file(target: Path) -> None:
"""清理 aria2 控制文件(正常情况下 aria2 已自行删除,这里兜底)。"""
ctrl = target.with_name(target.name + '.aria2')
try:
ctrl.unlink(missing_ok=True)
except OSError:
pass
def get_backend() -> Aria2Backend | None:
"""返回 aria2 后端单例;aria2c 不可用/启动失败时返回 None(调用方降级)。
首次调用尝试拉起常驻进程,之后命中缓存;失败结果进程内记忆,不反复重试。
"""
global _backend, _unavailable
if _unavailable:
return None
if _backend is not None:
return _backend
with _lock:
if _unavailable:
return None
if _backend is None:
_backend = _try_start()
if _backend is None:
_unavailable = True
return _backend
def _try_start() -> Aria2Backend | None:
"""拉起 aria2c --enable-rpc 并等待 RPC 就绪;任何失败都返回 None。"""
exe = shutil.which(ARIA2_BIN)
if not exe:
logger.info('未检测到 aria2c,下载走内置流式通道')
return None
try:
import aria2p
except ImportError as e:
logger.info(f'aria2p 不可用,下载走内置流式通道: {e}')
return None
port = _free_port()
secret = secrets.token_urlsafe(24)
cmd = [
exe,
'--enable-rpc',
f'--rpc-listen-port={port}',
f'--rpc-secret={secret}',
'--rpc-listen-all=false',
'--rpc-allow-origin-all=false',
'--no-conf',
'--quiet=true',
]
try:
# exe 来自 shutil.which,参数全部由本模块构造,不经任何用户输入
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
logger.warning(f'aria2c 启动失败,降级内置下载: {e}')
return None
client = aria2p.Client(host='http://127.0.0.1', port=port, secret=secret)
deadline = time.monotonic() + _RPC_READY_TIMEOUT
while True:
if proc.poll() is not None:
logger.warning(f'aria2c 进程提前退出(code={proc.returncode}),降级内置下载')
return None
try:
client.get_version()
break
except Exception:
if time.monotonic() > deadline:
logger.warning(f'aria2c RPC {_RPC_READY_TIMEOUT:.0f}s 内未就绪,降级内置下载')
proc.kill()
proc.wait(timeout=5)
return None
time.sleep(0.2)
atexit.register(_shutdown)
logger.info(f'aria2c 常驻 RPC 已就绪(127.0.0.1:{port},{_SPLIT} 连接/文件)')
return Aria2Backend(aria2p.API(client), proc, port)
def _shutdown() -> None:
"""进程退出时回收 aria2c(atexit 只注册一次)。"""
global _backend
if _backend is not None:
_backend.close()
_backend = None
+17 -13
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import threading
import filetype
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
# ============ HTTP 连接复用(提速核心) ============
@@ -27,26 +29,28 @@ DL_CHUNK_SIZE = 256 * 1024
_SESSION_LOCAL = threading.local()
# 下载内容嗅探的音频魔数(未知期望尺寸时唯一的内容防线)
_AUDIO_MAGIC = (b'ID3', b'fLaC', b'OggS', b'RIFF')
# M4A/MP4 音频在 filetype 里被判为 video/mp4——mp4 是复合容器,仅凭魔数无法
# 区分音频轨与视频轨,故显式放行该 mime。
_CONTAINER_MIMES = frozenset({'video/mp4'})
def _sniff_audio_header(path) -> bool:
"""读取文件头做基础音频魔数校验:MP3(ID3/裸帧)、FLAC、OGG/Opus、WAV(RIFF)、
M4A/MP4(ftyp)。未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容
防线——CDN 错误页/风控 JSON 不再能被存成音频交付。"""
"""按魔数判定文件是否为音频容器(MP3/FLAC/OGG/Opus/WAV/M4A/MP4)。
未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容防线——
CDN 错误页/风控 JSON 不再能被存成音频交付。
识别交给 filetype(内置完整类型表,覆盖各格式的多种帧头变体),
不再自行维护魔数前缀与 ftyp 偏移;对空文件/未知格式返回 False。
"""
try:
with open(path, 'rb') as f:
head = f.read(16)
kind = filetype.guess(str(path))
except OSError:
return False
if not head:
if kind is None:
return False
if any(head.startswith(m) for m in _AUDIO_MAGIC):
return True
if len(head) >= 8 and head[:4] == b'\x00\x00\x00' and head[4:8] == b'ftyp':
return True
return len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0
mime = (kind.mime or '').lower()
return mime.startswith('audio/') or mime in _CONTAINER_MIMES
def get_http_session() -> requests.Session:
+166 -328
View File
@@ -8,9 +8,6 @@ from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
import threading
import time
from pathlib import Path
@@ -19,6 +16,7 @@ from core import sync_http as requests # 同步门面(底层 aiohttp,替代
from core.async_bridge import run_sync
from music_api import APIException, NeteaseAPI, load_cookies
from ._aria2 import Aria2Backend, get_backend
from ._concurrency import (
CountingSemaphore,
get_file_lock,
@@ -36,14 +34,16 @@ from .cover import (
)
from .models import (
API_TYPE_TO_EXT,
EXT_TO_FFMPEG_FORMAT,
METADATA_FORMATS,
DownloadCanceled,
DownloadException,
DownloadResult,
MusicInfo,
_RetryFromScratch,
)
from .tagging import build_tags, write_tags
# 单文件下载体积硬上限:超限即弃,防止单文件拖垮磁盘
_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024
# ============ 核心实现 ============
@@ -59,15 +59,13 @@ class MusicDownloader:
self,
download_dir: str = "downloads",
max_concurrent: int = 3,
ffmpeg_path: str | None = None,
global_max_concurrent: int = 10,
):
"""
Args:
download_dir: 下载目录
max_concurrent: 单个批量任务内的最大并发下载数
ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次
global_max_concurrent: 整个服务同时下载的文件数(含 ffmpeg 元数据写入)。
global_max_concurrent: 整个服务同时下载的文件数(含标签写入)。
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
"""
self.download_dir = Path(download_dir)
@@ -80,15 +78,10 @@ class MusicDownloader:
# (带计数,供前端实时展示排队情况)
self._download_semaphore = CountingSemaphore(global_max_concurrent)
# ffmpeg 元数据写入专用信号量(CPU 密集操作):
# 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输
# 标签写入专用信号量:写标签会重写整个文件(IO 密集),
# 与下载分离限流 —— 写满时只阻塞后来者,不占下载名额、不拖慢其它文件的传输
cpu_count = os.cpu_count() or 2
self._meta_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
# ffmpeg 路径缓存:启动时检测一次,写入时还会再懒加载一次
self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg")
if not self.ffmpeg_path:
logger.warning("未检测到 ffmpeg,写入元数据功能将不可用")
self._tag_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
# ----------- 工具方法 -----------
@@ -142,18 +135,6 @@ class MusicDownloader:
return ".mp3"
def _resolve_ffmpeg(self) -> str | None:
"""懒加载解析 ffmpeg 路径
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
"""
if self.ffmpeg_path:
return self.ffmpeg_path
path = shutil.which("ffmpeg")
if path:
self.ffmpeg_path = path # 回写缓存
return path
@staticmethod
def _unlink_quiet(path: Path) -> None:
"""安静地删除文件,忽略不存在 / 权限错误"""
@@ -328,6 +309,11 @@ class MusicDownloader:
self._unlink_quiet(part_path0)
raise DownloadCanceled("下载已取消")
# part_path 是 file_path 的 .part 别名。必须在进入下载 try 之前绑定:
# except 分支(取消/网络错误)要清理 .part,若此处未赋值,异常处理器
# 会以 NameError 二次崩溃、掩盖原始异常
part_path = part_path0
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
with get_file_lock(file_path):
try:
@@ -345,8 +331,8 @@ class MusicDownloader:
else:
# 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
if write_meta:
with self._meta_semaphore:
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
@@ -354,7 +340,7 @@ class MusicDownloader:
music_info=music_info,
)
part_path = file_path.with_name(file_path.name + ".part")
# part_path 已在文件锁外绑定为 part_path0(同一路径),此处不再重复计算
resume_from = 0
if part_path.exists():
existing = part_path.stat().st_size
@@ -364,8 +350,8 @@ class MusicDownloader:
# 改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
os.replace(part_path, file_path)
if write_meta:
with self._meta_semaphore:
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
@@ -387,85 +373,15 @@ class MusicDownloader:
if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5):
raise DownloadCanceled("下载已取消")
try:
headers = {}
mode = "wb"
if resume_from > 0:
headers["Range"] = f"bytes={resume_from}-"
mode = "ab"
if progress_cb is not None:
try:
progress_cb(resume_from, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
resp: requests.SyncResponse | None = None
try:
resp = get_http_session().get(
music_info.download_url,
stream=True,
timeout=(10, 60),
headers=headers,
)
# 服务器忽略 Range 返回 200 → 从头重写而非追加
if resume_from > 0 and resp.status_code != 206:
mode = "wb"
# 206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件
if resume_from > 0 and resp.status_code == 206:
cr = resp.headers.get('Content-Range') or ''
m = re.match(r'bytes\s+(\d+)-', cr.strip())
if m and int(m.group(1)) != resume_from:
self._unlink_quiet(part_path)
raise _RetryFromScratch(
f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载")
# 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件,
# 与起点错位同口径处理:清残片从头下
if not m:
self._unlink_quiet(part_path)
raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载")
resp.raise_for_status()
# 下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘
max_bytes = 500 * 1024 * 1024
written = resume_from if mode == 'ab' else 0
over_limit = False
with open(part_path, mode) as f:
for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE):
if cancel_event is not None and cancel_event.is_set():
raise DownloadCanceled("下载已取消")
if chunk:
f.write(chunk)
written += len(chunk)
if written > max_bytes:
# 超限残片没有任何续传价值。
# Windows 无法删除打开中的文件——置标志 break,
# with 关闭后再清理(原实现在打开中 unlink 被静默吞掉)
over_limit = True
break
if progress_cb is not None:
try:
progress_cb(written, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
if over_limit:
self._unlink_quiet(part_path) # 文件已关闭,清理可达
raise DownloadException(
f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止")
except requests.HTTPError as e:
# Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 416:
self._unlink_quiet(part_path)
raise _RetryFromScratch("Range 起点失效,已从头重新下载") from e
if status is not None and 400 <= status < 500 and status != 408:
# CDN 4xx(403 防盗链/429 风控等)是确定性
# 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回
# (408 请求超时保留可重试)
self._unlink_quiet(part_path)
raise DownloadException(
f"下载失败(HTTP {status}),下载链接可能已失效") from e
raise
finally:
if resp is not None:
resp.close()
aria2_backend = get_backend()
if aria2_backend is not None:
# aria2c 通道:多连接 + 原生断点续传
self._fetch_via_aria2(aria2_backend, music_info, part_path,
cancel_event, progress_cb)
else:
# 内置通道:aiohttp 流式落盘 + Range 起点校验
self._fetch_via_http(music_info, part_path, resume_from,
cancel_event, progress_cb)
finally:
# 与 acquire_cancellable 配对释放(原 with 语义)
self._download_semaphore.release()
@@ -501,8 +417,8 @@ class MusicDownloader:
# 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟
if cancel_event is not None and cancel_event.is_set():
raise DownloadCanceled("下载已取消")
with self._meta_semaphore:
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
@@ -548,229 +464,151 @@ class MusicDownloader:
return DownloadResult(success=False, error_message=last_error or "下载失败")
# ============ ffmpeg 元数据写入(核心) ============
# ----------- 传输通道(二选一,产物都是 part_path) -----------
def _fetch_via_http(
self,
music_info: MusicInfo,
part_path: Path,
resume_from: int,
cancel_event: threading.Event | None,
progress_cb,
) -> None:
"""内置流式通道:core.sync_http 逐块落盘,支持 Range 断点续传。
Raises:
DownloadCanceled: 取消事件置位
DownloadException: 体积超限、CDN 4xx 等确定性失败
_RetryFromScratch: 续传起点失效(残片已清,外层从头重下)
"""
headers = {}
mode = "wb"
if resume_from > 0:
headers["Range"] = f"bytes={resume_from}-"
mode = "ab"
if progress_cb is not None:
try:
progress_cb(resume_from, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
resp: requests.SyncResponse | None = None
try:
resp = get_http_session().get(
music_info.download_url,
stream=True,
timeout=(10, 60),
headers=headers,
)
# 服务器忽略 Range 返回 200 → 从头重写而非追加
if resume_from > 0 and resp.status_code != 206:
mode = "wb"
# 206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件
if resume_from > 0 and resp.status_code == 206:
cr = resp.headers.get('Content-Range') or ''
m = re.match(r'bytes\s+(\d+)-', cr.strip())
if m and int(m.group(1)) != resume_from:
self._unlink_quiet(part_path)
raise _RetryFromScratch(
f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载")
# 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件,
# 与起点错位同口径处理:清残片从头下
if not m:
self._unlink_quiet(part_path)
raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载")
resp.raise_for_status()
# 下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘
max_bytes = _MAX_DOWNLOAD_BYTES
written = resume_from if mode == 'ab' else 0
over_limit = False
with open(part_path, mode) as f:
for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE):
if cancel_event is not None and cancel_event.is_set():
raise DownloadCanceled("下载已取消")
if chunk:
f.write(chunk)
written += len(chunk)
if written > max_bytes:
# 超限残片没有任何续传价值。
# Windows 无法删除打开中的文件——置标志 break,
# with 关闭后再清理(原实现在打开中 unlink 被静默吞掉)
over_limit = True
break
if progress_cb is not None:
try:
progress_cb(written, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
if over_limit:
self._unlink_quiet(part_path) # 文件已关闭,清理可达
raise DownloadException(
f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止")
except requests.HTTPError as e:
# Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 416:
self._unlink_quiet(part_path)
raise _RetryFromScratch("Range 起点失效,已从头重新下载") from e
if status is not None and 400 <= status < 500 and status != 408:
# CDN 4xx(403 防盗链/429 风控等)是确定性
# 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回
# (408 请求超时保留可重试)
self._unlink_quiet(part_path)
raise DownloadException(
f"下载失败(HTTP {status}),下载链接可能已失效") from e
raise
finally:
if resp is not None:
resp.close()
def _fetch_via_aria2(
self,
backend: Aria2Backend,
music_info: MusicInfo,
part_path: Path,
cancel_event: threading.Event | None,
progress_cb,
) -> None:
"""aria2c 通道:多连接加速 + 原生断点续传。
异常语义与内置通道对齐:取消抛 DownloadCanceled、体积超限抛
DownloadException(均清残片),网络类失败抛 RequestException
(保留残片,由外层退避重试续传)。
"""
backend.fetch(
music_info.download_url,
part_path,
cancel_event=cancel_event,
progress_cb=progress_cb,
max_bytes=_MAX_DOWNLOAD_BYTES,
)
# ============ 元数据写入(mutagen,纯 Python) ============
# download_music_to_memory / download_batch_async / get_download_progress
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
def _write_metadata_with_ffmpeg(
def _write_metadata(
self,
file_path: Path,
music_info: MusicInfo,
fields: list[str] | None = None,
) -> bool:
"""使用 ffmpeg 写入音频元数据
"""写入音频标签与封面(mutagen);失败不影响交付,返回是否成功。
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
fields: 可选字段列表 ['title','artist','album','cover','lyrics','date'];
None 表示全部写入。
Args:
file_path: 已转正的音频文件
music_info: 歌曲信息(标题/歌手/专辑/年份/曲号/歌词/封面地址)
fields: 要写入的字段列表,None 表示全部写入
Returns:
是否写入成功(失败不影响主流程)
封面走共享缓存(与 ZIP 打包共用一次网络请求);各容器的落盘细节见
tagging.py——按扩展名分派到 ID3v2.3 / Vorbis comment / iTunes ilst。
"""
ffmpeg_path = self._resolve_ffmpeg()
if not ffmpeg_path:
logger.warning("跳过元数据写入: ffmpeg 不可用")
return False
file_ext = file_path.suffix.lower()
if file_ext not in METADATA_FORMATS:
logger.warning(f"跳过元数据写入: 不支持的格式 {file_ext}")
return False
if not file_path.exists():
logger.warning(f"跳过元数据写入: 文件不存在 {file_path}")
return False
# 临时输出文件:保留原扩展名在最后,便于 ffmpeg 识别 muxer
# 例: 艾辰 - 错位时空.tagging.flac
tmp_output = file_path.with_name(
file_path.stem + ".tagging" + file_path.suffix
)
# 临时封面文件
cover_tmp: str | None = None
cover_ok = False # 创建成功与否与"路径是否需要清理"分离管理
try:
# 下载封面到临时文件(走共享缓存:与 ZIP 打包共用一次网络请求)
include_cover = fields is None or "cover" in fields
if include_cover and music_info.pic_url:
cover_data = fetch_cover_bytes(music_info.pic_url)
if cover_data:
try:
fd, cover_tmp = tempfile.mkstemp(suffix=".jpg", dir=str(self.download_dir))
os.close(fd)
with open(cover_tmp, "wb") as cf:
cf.write(cover_data)
cover_ok = True
except Exception as e:
# cover_tmp 保持指向已创建的文件,finally 统一清理
# 原实现置 None 会把已创建的零字节文件遗留在下载目录)
logger.warning(f"封面写盘失败,跳过封面写入: {e}")
else:
logger.warning("封面下载失败,跳过封面写入")
# 构造 ffmpeg 命令
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
cmd: list[str] = [
ffmpeg_path, "-y", "-loglevel", "error",
"-i", str(file_path),
]
use_cover = cover_ok
# OGG/Opus 的封面流 copy 不被支持(Unsupported codec id),
# 主动降级为"只写文本元数据",不再让整个写入失败
if use_cover and file_ext in (".ogg", ".opus"):
use_cover = False
logger.info("OGG/Opus 不支持封面写入,仅写入文本元数据")
if use_cover and cover_tmp:
cmd += ["-i", cover_tmp]
# 通用元数据(仅写入勾选的字段)
meta = {}
# 前端字段名为 name,与后端 title 统一映射
if fields is None or "title" in fields or "name" in fields:
meta["title"] = music_info.name
if fields is None or "artist" in fields:
meta["artist"] = music_info.artists
meta["album_artist"] = music_info.artists
if fields is None or "album" in fields:
meta["album"] = music_info.album
if fields is None or "date" in fields:
if music_info.publish_year:
meta["date"] = str(music_info.publish_year)
meta["year"] = str(music_info.publish_year)
if fields is None or "lyrics" in fields:
if music_info.lyric and music_info.lyric.strip():
# 去掉 LRC 时间标签,写入纯歌词
clean_lyric = re.sub(
r"\[\d{1,2}:\d{1,2}([.:]\d+)?\]", "", music_info.lyric
).strip()
if clean_lyric:
meta["lyrics"] = clean_lyric
if music_info.track_number > 0 and (fields is None or "track" in fields):
# 只写当前曲号;没有真实专辑总数时不虚构 tracktotal
meta["track"] = str(music_info.track_number)
for k, v in meta.items():
if v:
cmd += ["-metadata", f"{k}={v}"]
# 各格式的 mapping / 编码选项
cmd += self._build_format_args(file_ext, has_cover=use_cover)
# -f 必须放在所有 -i 之后(作为 output option)才生效
if output_format:
cmd += ["-f", output_format]
cmd.append(str(tmp_output))
# 执行
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
encoding="utf-8",
errors="ignore",
)
if result.returncode == 0 and tmp_output.exists():
# os.replace 才是同卷原子替换——shutil.move 在目标已
# 存在时退化为 copy2+unlink,进程中断会留半截文件
os.replace(str(tmp_output), str(file_path))
return True
# ffmpeg stderr 原文走 logger(errors='ignore' 解码后的任意 Unicode
# 在 GBK 重定向下裸 print 会崩 worker)
logger.warning(
f"ffmpeg 写入元数据失败: {(result.stderr or result.stdout or '').strip()}"
)
return False
except subprocess.TimeoutExpired:
logger.warning("ffmpeg 执行超时")
return False
except FileNotFoundError:
logger.warning(f"ffmpeg 可执行文件未找到: {ffmpeg_path}")
return False
except Exception as e:
logger.warning(f"ffmpeg 写入元数据异常: {e}")
return False
finally:
# 清理封面临时文件
if cover_tmp and os.path.exists(cover_tmp):
try:
os.unlink(cover_tmp)
except OSError:
pass
# 清理可能残留的临时输出
if tmp_output.exists():
try:
tmp_output.unlink()
except OSError:
pass
def _build_format_args(self, file_ext: str, has_cover: bool) -> list[str]:
"""根据文件格式构造 mapping / 编码参数
不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""
if file_ext == ".mp3":
# MP3: 封面通过 ID3v2 APIC 帧写入
if has_cover:
return [
"-map", "0:a", "-map", "1:v",
"-c:a", "copy", "-c:v", "copy",
"-id3v2_version", "3",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
]
return ["-map", "0", "-c", "copy", "-id3v2_version", "3"]
if file_ext == ".flac":
# FLAC: 原生支持封面
if has_cover:
return [
"-map", "0", "-map", "1",
"-c", "copy",
"-disposition:v:0", "attached_pic",
]
return ["-map", "0", "-c", "copy"]
if file_ext == ".m4a":
# M4A: AAC 音频 + mp4 容器
if has_cover:
return [
"-map", "0:a", "-map", "1:v",
"-c", "copy",
"-disposition:v:0", "attached_pic",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
]
# 保留既有封面流(原 -map 0:a 会删掉已有封面)
return ["-map", "0", "-c", "copy"]
if file_ext == ".mp4":
# MP4 (杜比全景声 EAC3 / 其它 mp4 容器音频)
# 杜比文件通常无视频流,封面作为 video stream 注入;+faststart 让 moov 在文件头
if has_cover:
return [
"-map", "0", "-map", "1:v",
"-c", "copy",
"-disposition:v:0", "attached_pic",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
"-movflags", "+faststart",
]
return ["-map", "0", "-c", "copy", "-movflags", "+faststart"]
if file_ext in (".ogg", ".opus"):
if has_cover:
return [
"-map", "0:a", "-map", "1:v",
"-c:a", "copy", "-c:v", "copy",
]
return ["-map", "0:a", "-c:a", "copy"]
# 默认
if has_cover:
return ["-map", "0:a", "-map", "1:v", "-c", "copy"]
return ["-map", "0:a", "-c:a", "copy"]
include_cover = fields is None or "cover" in fields
cover: bytes | None = None
if include_cover and music_info.pic_url:
cover = fetch_cover_bytes(music_info.pic_url)
if not cover:
logger.warning("封面下载失败,跳过封面写入")
return write_tags(file_path, build_tags(music_info, fields), cover)
+2 -13
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""数据模型、异常与 ffmpeg 格式常量。
"""数据模型、异常与格式常量。
(自 music_downloader.py 拆出,行为与原实现一致。)
"""
@@ -82,16 +82,5 @@ API_TYPE_TO_EXT = {
}
# 文件扩展名 -> ffmpeg 输出 muxer 名称
EXT_TO_FFMPEG_FORMAT = {
".mp3": "mp3",
".flac": "flac",
".m4a": "mp4",
".mp4": "mp4",
".ogg": "ogg",
".opus": "opus",
}
# 支持元数据写入的扩展名
# 支持元数据写入的扩展名(tagging.py 按此表分派到各容器的标签写入器)
METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"}
+240
View File
@@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
"""音频标签与封面写入(mutagen 纯 Python 实现,不需要 ffmpeg 命令)。
为什么不再用 ffmpeg CLI:本工程的元数据写入只做「改标签」,不做任何重编码,
而 ffmpeg 命令行必须出现**重复的** `-metadata` / `-map` 参数;ffmpeg-python 用
`**kwargs` 字典建模这些选项,结构上无法表达重复键(实测把列表值字符串化成
`"['0', '1:v']"`),绕一圈仍需手写命令拼接。改用 mutagen 后整段变成纯 Python
调用,部署镜像也不必再装 ffmpeg。
覆盖容器:
- MP3 ID3v2.3(TIT2 / TPE1 / TPE2 / TALB / TDRC / TRCK / USLT / APIC)
- FLAC Vorbis comment(title / artist / albumartist / …)+ Picture 块
- M4A / MP4 iTunes ilst(©nam / ©ART / aART / ©alb / ©day / trkn / ©lyr)+ covr
- OGG / Opus Vorbis comment + METADATA_BLOCK_PICTURE(base64 编码的 Picture 块)
写入一律走 mutagen 的原子替换(临时文件 + os.replace),中途失败不会留下半截文件;
且是「就地改帧」,原有未触及的标签(TXXX 等)会被保留。
"""
from __future__ import annotations
import base64
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ._log import logger
from .models import METADATA_FORMATS, MusicInfo
# 封面类型:ID3 APIC / FLAC Picture 的 picture type 3 = Cover (front)
_PICTURE_TYPE_COVER_FRONT = 3
_COVER_DESC = 'Cover (front)'
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
# LRC 时间标签([mm:ss] / [mm:ss.xx] / [mm:ss:xx])
_LRC_TIME_TAG = re.compile(r'\[\d{1,2}:\d{1,2}([.:]\d+)?\]')
@dataclass
class TagSet:
"""与容器无关的标签中间表示;None 表示本次不写该字段。"""
title: str | None = None
artist: str | None = None
album: str | None = None
album_artist: str | None = None
date: str | None = None
track: int | None = None
lyrics: str | None = None
def build_tags(info: MusicInfo, fields: list[str] | None) -> TagSet:
"""按前端勾选的字段构造标签集合;fields 为 None 表示全部写入。"""
def want(*names: str) -> bool:
return fields is None or any(n in fields for n in names)
tags = TagSet()
if want('title', 'name'):
tags.title = info.name or None
if want('artist'):
tags.artist = info.artists or None
tags.album_artist = info.artists or None
if want('album'):
tags.album = info.album or None
if want('date') and info.publish_year:
tags.date = str(info.publish_year)
if want('lyrics') and info.lyric:
# 写入纯歌词:去掉 LRC 时间标签
tags.lyrics = _LRC_TIME_TAG.sub('', info.lyric).strip() or None
if want('track') and info.track_number > 0:
# 只写当前曲号;没有真实专辑总数时不虚构 tracktotal
tags.track = info.track_number
return tags
def write_tags(path: Path, tags: TagSet, cover: bytes | None = None) -> bool:
"""把标签与封面写入音频文件;失败仅记日志并返回 False(不影响交付主流程)。"""
ext = path.suffix.lower()
writer = _WRITERS.get(ext)
if writer is None:
logger.warning(f"跳过元数据写入: 不支持的格式 {ext}")
return False
if not path.exists():
logger.warning(f"跳过元数据写入: 文件不存在 {path}")
return False
try:
writer(path, tags, cover)
return True
except Exception as e:
logger.warning(f"写入元数据失败({ext}): {e}")
return False
# ---------- 各容器实现 ----------
def _write_mp3(path: Path, tags: TagSet, cover: bytes | None) -> None:
from mutagen.id3 import (
APIC,
ID3,
TALB,
TDRC,
TIT2,
TPE1,
TPE2,
TRCK,
USLT,
ID3NoHeaderError,
)
try:
audio = ID3(str(path))
except ID3NoHeaderError:
audio = ID3() # 无 ID3 头的裸音频:新建标签区
if tags.title:
audio['TIT2'] = TIT2(encoding=3, text=[tags.title])
if tags.artist:
audio['TPE1'] = TPE1(encoding=3, text=[tags.artist])
if tags.album_artist:
audio['TPE2'] = TPE2(encoding=3, text=[tags.album_artist])
if tags.album:
audio['TALB'] = TALB(encoding=3, text=[tags.album])
if tags.date:
audio['TDRC'] = TDRC(encoding=3, text=[tags.date])
if tags.track:
audio['TRCK'] = TRCK(encoding=3, text=[str(tags.track)])
if tags.lyrics:
audio['USLT'] = USLT(encoding=3, lang='eng', desc='', text=tags.lyrics)
if cover:
audio['APIC'] = APIC(encoding=3, mime=_cover_mime(cover),
type=_PICTURE_TYPE_COVER_FRONT,
desc=_COVER_DESC, data=cover)
# ID3v2.3 与 ffmpeg 时代的 -id3v2_version 3 保持一致(mutagen 会自动把
# TDRC 降级为 TYER/TDAT);不写 v1 标签以免在文件尾追加 128 字节
audio.save(str(path), v2_version=3)
def _write_flac(path: Path, tags: TagSet, cover: bytes | None) -> None:
from mutagen.flac import FLAC
audio = FLAC(str(path))
_set_vorbis_comments(audio, tags)
if cover:
audio.clear_pictures()
audio.add_picture(_make_picture(cover))
audio.save()
def _write_m4a(path: Path, tags: TagSet, cover: bytes | None) -> None:
from mutagen.mp4 import MP4, MP4Cover
audio = MP4(str(path))
mapping = {
'\xa9nam': tags.title,
'\xa9ART': tags.artist,
'aART': tags.album_artist,
'\xa9alb': tags.album,
'\xa9day': tags.date,
'\xa9lyr': tags.lyrics,
}
for key, value in mapping.items():
if value:
audio[key] = [value]
if tags.track:
audio['trkn'] = [(tags.track, 0)] # 不虚构 tracktotal
if cover:
image_format = (MP4Cover.FORMAT_PNG if cover.startswith(_PNG_MAGIC)
else MP4Cover.FORMAT_JPEG)
audio['covr'] = [MP4Cover(cover, imageformat=image_format)]
audio.save()
def _write_ogg(path: Path, tags: TagSet, cover: bytes | None) -> None:
_write_ogg_stream(path, tags, cover, opus=False)
def _write_opus(path: Path, tags: TagSet, cover: bytes | None) -> None:
_write_ogg_stream(path, tags, cover, opus=True)
def _write_ogg_stream(path: Path, tags: TagSet, cover: bytes | None, *,
opus: bool) -> None:
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
audio: Any = OggOpus(str(path)) if opus else OggVorbis(str(path))
_set_vorbis_comments(audio, tags)
if cover:
# Ogg 系把封面按 METADATA_BLOCK_PICTURE 规范存成 base64 的 Picture 块
audio['metadata_block_picture'] = [
base64.b64encode(_make_picture(cover).write()).decode('ascii')]
audio.save()
# ---------- 小工具 ----------
def _cover_mime(cover: bytes) -> str:
return 'image/png' if cover.startswith(_PNG_MAGIC) else 'image/jpeg'
def _make_picture(cover: bytes) -> Any:
from mutagen.flac import Picture
pic = Picture()
pic.type = _PICTURE_TYPE_COVER_FRONT
pic.mime = _cover_mime(cover)
pic.desc = _COVER_DESC
pic.data = cover
return pic
def _set_vorbis_comments(audio: Any, tags: TagSet) -> None:
"""FLAC 与 Ogg 共用 Vorbis comment 字段名。"""
mapping = {
'title': tags.title,
'artist': tags.artist,
'albumartist': tags.album_artist,
'album': tags.album,
'date': tags.date,
}
for key, value in mapping.items():
if value:
audio[key] = [value]
if tags.track:
audio['tracknumber'] = [str(tags.track)]
if tags.lyrics:
audio['lyrics'] = [tags.lyrics]
_WRITERS = {
'.mp3': _write_mp3,
'.flac': _write_flac,
'.m4a': _write_m4a,
'.mp4': _write_m4a,
'.ogg': _write_ogg,
'.opus': _write_opus,
}
# 与 models 的类型表保持同源:新增容器必须两边同时登记
assert set(_WRITERS) == set(METADATA_FORMATS), '标签写入表与 METADATA_FORMATS 不一致'
+4
View File
@@ -15,6 +15,9 @@ dependencies = [
"qrcode>=7.0,<8.3",
"python-multipart>=0.0.9",
"jinja2>=3.1,<4.0",
"filetype>=1.2.0",
"aria2p>=0.12.1",
"mutagen>=1.48.1",
]
[dependency-groups]
@@ -24,6 +27,7 @@ dev = [
"ruff>=0.6",
"pyright>=1.1.390",
"httpx>=0.27",
"httpx2>=2.13.1",
]
# 应用工程(非库):不把自身装进环境,避免 uv 扫描顶层模块
+178
View File
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""aria2 下载后端的单元测试(用假 api 驱动,不需要真实 aria2c 二进制)。
覆盖:
- aria2c 缺失时 get_backend 静默降级且失败结果被记忆
- 正常完成路径的进度回调与自动清理
- 取消 / 体积超限 连带删除残片;任务失败 / 总超时 保留残片供续传
- 提交失败与 RPC 抖动统一上报为可重试异常
"""
from __future__ import annotations
import threading
import pytest
import music_downloader._aria2 as a2
from core.sync_http import RequestException
from music_downloader import DownloadCanceled, DownloadException
class _FakeDownload:
"""按预设步进产出状态,模拟 aria2 的 tellStatus 轮询。"""
def __init__(self, steps):
self.gid = 'g1'
self._steps = list(steps)
self._i = 0
self.completed_length = 0
self.total_length = 100
self.is_complete = False
self.has_failed = False
self.error_message = ''
self.status = 'active'
def update(self):
idx = min(self._i, len(self._steps) - 1)
done, complete, failed = self._steps[idx]
self._i += 1
self.completed_length = done
self.is_complete = complete
self.has_failed = failed
if failed:
self.status = 'error'
self.error_message = 'boom'
class _FakeAPI:
def __init__(self, download, fail_submit=False, fail_update=False):
self.download = download
self.removed: list[tuple[str, bool, bool]] = []
self.submitted: tuple[list[str], dict[str, str]] | None = None
self._fail_submit = fail_submit
self._fail_update = fail_update
def add_uris(self, uris, options=None):
if self._fail_submit:
raise RuntimeError('rpc down')
self.submitted = (list(uris), options or {})
return self.download
def remove(self, downloads, force=False, files=False):
self.removed.append((downloads[0].gid, force, files))
class _FailUpdateDownload(_FakeDownload):
def update(self):
raise RuntimeError('rpc jitter')
class _FakeProc:
def poll(self):
return 0
def terminate(self):
pass
def kill(self):
pass
def wait(self, timeout=None):
return 0
def _backend(monkeypatch, steps, **kw):
monkeypatch.setattr(a2.time, 'sleep', lambda _s: None)
dl = _FailUpdateDownload([]) if kw.get('fail_update') else _FakeDownload(steps)
api = _FakeAPI(dl, fail_submit=kw.get('fail_submit', False))
return a2.Aria2Backend(api, _FakeProc(), 6800), api
# ---------- 降级 ----------
def test_get_backend_degrades_without_binary(monkeypatch):
monkeypatch.setattr(a2.shutil, 'which', lambda _n: None)
monkeypatch.setattr(a2, '_backend', None)
monkeypatch.setattr(a2, '_unavailable', False)
assert a2.get_backend() is None
assert a2.get_backend() is None # 失败结果被记忆,不反复拉起进程
def test_free_port_is_in_valid_range():
assert 0 < a2._free_port() < 65536
# ---------- 完成路径 ----------
def test_fetch_reports_progress_and_completes(monkeypatch, tmp_path):
be, api = _backend(monkeypatch, [(50, False, False), (100, True, False)])
seen = []
be.fetch('http://x/y.mp3', tmp_path / 'y.part',
progress_cb=lambda done, total: seen.append((done, total)))
assert seen == [(50, 100), (100, 100)]
assert api.removed == [] # 完成不需要中止
assert not (tmp_path / 'y.part.aria2').exists() # 控制文件已兜底清理
submitted = api.submitted
assert submitted is not None
options = submitted[1]
assert options['continue'] == 'true' # 续传选项已下发
assert options['max-connection-per-server'] == str(a2._SPLIT)
def test_progress_callback_exception_is_swallowed(monkeypatch, tmp_path):
be, _api = _backend(monkeypatch, [(100, True, False)])
def boom(_d, _t):
raise RuntimeError('cb')
be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=boom) # 不应抛出
# ---------- 取消 / 守卫 / 失败 ----------
def test_fetch_maps_cancel_and_drops_partial(monkeypatch, tmp_path):
be, api = _backend(monkeypatch, [(10, False, False)])
ev = threading.Event()
ev.set()
with pytest.raises(DownloadCanceled):
be.fetch('http://x/y.mp3', tmp_path / 'y.part', cancel_event=ev)
assert api.removed == [('g1', True, True)]
def test_fetch_guards_size_and_drops_partial(monkeypatch, tmp_path):
be, api = _backend(monkeypatch, [(700, False, False)])
with pytest.raises(DownloadException):
be.fetch('http://x/y.mp3', tmp_path / 'y.part', max_bytes=500)
assert api.removed == [('g1', True, True)]
def test_fetch_failure_is_retryable_and_keeps_partial(monkeypatch, tmp_path):
be, api = _backend(monkeypatch, [(10, False, True)])
with pytest.raises(RequestException):
be.fetch('http://x/y.mp3', tmp_path / 'y.part')
assert api.removed == [] # 残片保留,供下轮续传
def test_fetch_timeout_is_retryable(monkeypatch, tmp_path):
be, api = _backend(monkeypatch, [(10, False, False)]) # 永不完成
with pytest.raises(RequestException):
be.fetch('http://x/y.mp3', tmp_path / 'y.part', timeout=-1)
assert api.removed == [('g1', True, False)] # 超时中止但保留残片
def test_fetch_submit_failure_is_retryable(monkeypatch, tmp_path):
be, _api = _backend(monkeypatch, [], fail_submit=True)
with pytest.raises(RequestException):
be.fetch('http://x/y.mp3', tmp_path / 'y.part')
def test_fetch_update_failure_is_retryable(monkeypatch, tmp_path):
be, _api = _backend(monkeypatch, [], fail_update=True)
with pytest.raises(RequestException):
be.fetch('http://x/y.mp3', tmp_path / 'y.part')
def test_close_is_idempotent(monkeypatch):
be, _api = _backend(monkeypatch, [])
be.close()
be.close()
+188
View File
@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""标签写入(mutagen)与内容嗅探(filetype)的单元测试。
覆盖:
- build_tags 的字段勾选、name/title 别名、LRC 时间标签剔除、曲号/年份缺省
- write_tags 的失败路径(不支持的扩展名 / 文件不存在)
- MP3 与 FLAC 的真实往返(写入后按标签读回)
- _sniff_audio_header 对音频容器放行、对 CDN 错误页/JSON/空文件拒绝
"""
from __future__ import annotations
from typing import Any
import pytest
from music_downloader import MusicInfo
from music_downloader._http import _sniff_audio_header
from music_downloader.tagging import TagSet, build_tags, write_tags
# 最小合法 FLAC:fLaC 魔数 + 一个 34 字节的 STREAMINFO(last-block 标志置位)。
# STREAMINFO 不能全零——mutagen 会拒绝采样率为 0 的流;此处填 44100Hz/2ch/16bit:
# min_blocksize=4096 max_blocksize=4096 min_framesize=1000 max_framesize=2000
# 随后 8 字节打包 (sample_rate 20bit | channels-1 3bit | bps-1 5bit | total 36bit)
_STREAMINFO = bytes.fromhex(
'1000' '1000' '0003e8' '0007d0' '0ac442f00000ac44' + '00' * 16)
assert len(_STREAMINFO) == 34
_MINIMAL_FLAC = b'fLaC' + bytes([0x80, 0x00, 0x00, 0x22]) + _STREAMINFO
# 最小 JPEG 头(仅用于标签写入,不参与解码)
_JPEG = b'\xff\xd8\xff\xe0' + b'\x00' * 32
def _info(**kw) -> MusicInfo:
base: dict[str, Any] = {
'id': 1, 'name': '歌名', 'artists': '歌手', 'album': '专辑', 'pic_url': '',
'duration': 200, 'track_number': 3,
'download_url': 'http://example.invalid/x', 'file_type': 'mp3',
'file_size': 0, 'quality': 'standard',
'lyric': '[00:12.34]第一行\n[00:15.00]第二行', 'tlyric': '',
'publish_year': 2024,
}
base.update(kw)
return MusicInfo(**base)
# ---------- build_tags ----------
def test_build_tags_all_fields():
tags = build_tags(_info(), None)
assert tags.title == '歌名'
assert tags.artist == '歌手'
assert tags.album_artist == '歌手'
assert tags.album == '专辑'
assert tags.date == '2024'
assert tags.track == 3
assert tags.lyrics == '第一行\n第二行' # LRC 时间标签已剔除
def test_build_tags_respects_selection():
tags = build_tags(_info(), ['title'])
assert tags.title == '歌名'
assert tags.artist is None
assert tags.album is None
assert tags.lyrics is None
assert tags.track is None
def test_build_tags_accepts_name_alias():
assert build_tags(_info(), ['name']).title == '歌名'
@pytest.mark.parametrize('kw, field', [
({'track_number': 0}, 'track'),
({'publish_year': None}, 'date'),
({'lyric': ''}, 'lyrics'),
({'lyric': '[00:01.00]'}, 'lyrics'), # 只有时间标签 → 清洗后为空
])
def test_build_tags_omits_empty_values(kw, field):
assert getattr(build_tags(_info(**kw), None), field) is None
# ---------- write_tags ----------
def test_write_tags_rejects_unsupported_extension(tmp_path):
p = tmp_path / 'a.wav'
p.write_bytes(b'RIFF' + b'\x00' * 8)
assert write_tags(p, TagSet(title='x')) is False
def test_write_tags_returns_false_when_missing(tmp_path):
assert write_tags(tmp_path / 'nope.mp3', TagSet(title='x')) is False
def test_write_tags_mp3_roundtrip(tmp_path):
from mutagen.id3 import ID3
p = tmp_path / 'a.mp3'
ID3().save(str(p)) # 构造一个只含 ID3 标签区的合法 mp3
tags = TagSet(title='T', artist='A', album='B', album_artist='A2',
date='2020', track=2, lyrics='L')
assert write_tags(p, tags, cover=_JPEG) is True
got = ID3(str(p))
assert got['TIT2'].text[0] == 'T'
assert got['TPE1'].text[0] == 'A'
assert got['TPE2'].text[0] == 'A2'
assert got['TALB'].text[0] == 'B'
assert got['TRCK'].text[0] == '2'
# USLT/APIC 的 HashKey 带语言/描述后缀,须用 getall 取
assert got.getall('USLT')[0].text == 'L'
assert got.getall('APIC')[0].mime == 'image/jpeg'
def test_write_tags_mp3_keeps_untouched_frames(tmp_path):
from mutagen.id3 import ID3, TXXX
p = tmp_path / 'a.mp3'
audio = ID3()
audio['TXXX:keep'] = TXXX(encoding=3, desc='keep', text=['v'])
audio.save(str(p))
assert write_tags(p, TagSet(title='T')) is True
assert ID3(str(p))['TXXX:keep'].text[0] == 'v' # 未触及的帧被保留
def test_write_tags_flac_roundtrip(tmp_path):
from mutagen.flac import FLAC
p = tmp_path / 'a.flac'
p.write_bytes(_MINIMAL_FLAC)
assert write_tags(p, TagSet(title='T', artist='A', track=4), cover=_JPEG) is True
got = FLAC(str(p))
assert got['title'][0] == 'T'
assert got['artist'][0] == 'A'
assert got['tracknumber'][0] == '4'
assert len(got.pictures) == 1
assert got.pictures[0].mime == 'image/jpeg'
def test_write_tags_failure_is_swallowed(tmp_path):
"""写坏文件不应抛异常(主流程靠返回值判断)。"""
p = tmp_path / 'broken.flac'
p.write_bytes(b'fLaC') # 截断的 FLAC,解析必然失败
assert write_tags(p, TagSet(title='T')) is False
# ---------- 内容嗅探 ----------
def test_sniff_accepts_id3(tmp_path):
p = tmp_path / 'x.mp3'
p.write_bytes(b'ID3\x03\x00\x00\x00' + b'\x00' * 64)
assert _sniff_audio_header(p) is True
def test_sniff_accepts_flac(tmp_path):
p = tmp_path / 'x.flac'
p.write_bytes(_MINIMAL_FLAC)
assert _sniff_audio_header(p) is True
def test_sniff_accepts_ogg(tmp_path):
p = tmp_path / 'x.ogg'
p.write_bytes(b'OggS' + b'\x00' * 64)
assert _sniff_audio_header(p) is True
def test_sniff_rejects_risk_json(tmp_path):
"""CDN 风控 JSON 不能被当成音频交付。"""
p = tmp_path / 'x.mp3'
p.write_bytes(b'{"code": 403, "msg": "risk"}')
assert _sniff_audio_header(p) is False
def test_sniff_rejects_html_error_page(tmp_path):
p = tmp_path / 'x.mp3'
p.write_bytes(b'<!DOCTYPE html><html><body>403</body></html>')
assert _sniff_audio_header(p) is False
def test_sniff_rejects_empty_file(tmp_path):
p = tmp_path / 'x.mp3'
p.write_bytes(b'')
assert _sniff_audio_header(p) is False
def test_sniff_rejects_missing_file(tmp_path):
assert _sniff_audio_header(tmp_path / 'nope.mp3') is False
Generated
+284
View File
@@ -173,6 +173,21 @@ 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 = "aria2p"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "loguru" },
{ name = "platformdirs" },
{ name = "requests" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/d5/68f38791f44e5caf5c7c9576aea61d77cf940a060135358dcadb5345dd9c/aria2p-0.12.1.tar.gz", hash = "sha256:1521ccba4a4bb164a023f4c4213513dc3e6f47b91927f5e49dd50678da8c1294", size = 270595, upload-time = "2024-12-25T17:06:34.571Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/95/1ec57df6c52152fb4c776da5fbb2896f19af52de3776899d91e7140e7654/aria2p-0.12.1-py3-none-any.whl", hash = "sha256:96d31c1edd3c9e0edf725b7adeb0b9fb9b6ae102f3c228040d773b43837913a2", size = 88242, upload-time = "2024-12-25T17:06:30.995Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
@@ -276,6 +291,137 @@ wheels = [
{ 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 = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -360,6 +506,15 @@ 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 = "filetype"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
@@ -471,6 +626,19 @@ 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 = "httpcore2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
@@ -522,6 +690,32 @@ 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 = "httpx2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
name = "idna"
version = "3.20"
@@ -561,6 +755,19 @@ 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 = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -762,6 +969,15 @@ wheels = [
{ 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 = "mutagen"
version = "1.48.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/70/1675da133ea92227da41bf5b24e1c66be597ff736a1533ade41da986852f/mutagen-1.48.1.tar.gz", hash = "sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173", size = 1276978, upload-time = "2026-06-25T09:47:32.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/d8/a29e4e3991765e7ce4ed1f7e4074fe1ba9da03e0048639734de60f9cadb9/mutagen-1.48.1-py3-none-any.whl", hash = "sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7", size = 195706, upload-time = "2026-06-25T09:47:30.296Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -780,6 +996,15 @@ 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 = "platformdirs"
version = "4.11.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/e0/7c20b5d0e0f147c40a934e8f9e9f717bd6e3e372c4d58ca5c2fcce2b1652/platformdirs-4.11.15.tar.gz", hash = "sha256:d7419e973b2b740d428200130f80c0e79304d1c71081001db911e15b26a3a6d4", size = 46059, upload-time = "2026-09-26T02:04:58.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/83/246d3052d043683cc5f400ab5748bd07a99fbdf311dc771cfad7e3f9e7e0/platformdirs-4.11.15-py3-none-any.whl", hash = "sha256:84508421c77d3c6462e71dd8c0d2776c2ace0e94eb006dc4fd383bc1dcf5a176", size = 26689, upload-time = "2026-09-26T02:04:56.71Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1126,6 +1351,21 @@ 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 = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
version = "0.16.9"
@@ -1164,6 +1404,15 @@ 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 = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -1185,6 +1434,15 @@ 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 = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
@@ -1279,6 +1537,15 @@ wheels = [
{ 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 = "websocket-client"
version = "1.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
]
[[package]]
name = "websockets"
version = "17.1"
@@ -1408,6 +1675,15 @@ wheels = [
{ 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 = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]
[[package]]
name = "yarl"
version = "1.25.1"
@@ -1532,10 +1808,13 @@ dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "aria2p" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "filetype" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "mutagen" },
{ name = "python-multipart" },
{ name = "qrcode" },
{ name = "uvicorn", extra = ["standard"] },
@@ -1544,6 +1823,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "httpx2" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -1555,10 +1835,13 @@ requires-dist = [
{ name = "aiofiles", specifier = ">=24.1" },
{ name = "aiohttp", specifier = ">=3.10,<4.0" },
{ name = "aiosqlite", specifier = ">=0.20,<1.0" },
{ name = "aria2p", specifier = ">=0.12.1" },
{ name = "cryptography", specifier = ">=50.0,<51.0" },
{ name = "fastapi", specifier = ">=0.115,<1.0" },
{ name = "filetype", specifier = ">=1.2.0" },
{ name = "itsdangerous", specifier = ">=2.2,<3.0" },
{ name = "jinja2", specifier = ">=3.1,<4.0" },
{ name = "mutagen", specifier = ">=1.48.1" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "qrcode", specifier = ">=7.0,<8.3" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" },
@@ -1567,6 +1850,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx2", specifier = ">=2.13.1" },
{ name = "pyright", specifier = ">=1.1.390" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },