Archived
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:
1 parent
183f5744d8
commit
ad8c30d932
12 files changed
+1364
-366
No files matched your search
+6
-4
@@ -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
|
FROM python:3.12-alpine3.21
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# 使用阿里云 apk 镜像源加速安装 ffmpeg
|
# 使用阿里云 apk 镜像源加速安装
|
||||||
# 补 tzdata——alpine 无 zoneinfo,ENV TZ=Asia/Shanghai 不装它不生效
|
# tzdata:alpine 无 zoneinfo,ENV TZ=Asia/Shanghai 不装它不生效
|
||||||
# (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响)
|
# (容器内日志/展示本地时间差 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 && \
|
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 二进制仅存在于构建阶段
|
# 依赖由 uv 从 uv.lock 精确安装(可复现构建);uv 二进制仅存在于构建阶段
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
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
|
ENV UV_PROJECT_ENVIRONMENT=/usr/local UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""网易云音乐下载器(适配 ffmpeg 写入元数据)
|
"""网易云音乐下载器(标签写入基于 mutagen,纯 Python)
|
||||||
|
|
||||||
提供功能:
|
提供功能:
|
||||||
- 同步 / 异步下载(本地落盘 / 内存返回)
|
- 同步 / 异步下载(本地落盘 / 内存返回)
|
||||||
- 批量并发下载
|
- 批量并发下载
|
||||||
- 进度查询
|
- 进度查询
|
||||||
- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
|
- 基于 mutagen 的标签 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
|
||||||
|
|
||||||
设计要点:
|
设计要点:
|
||||||
1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装
|
1. 内容嗅探交给 filetype(按魔数识别容器),不再自维护魔数表
|
||||||
2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险
|
2. 传输通道二选一:aria2c 常驻 RPC(多连接)优先,不可用时回落内置 aiohttp 流式
|
||||||
3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart
|
3. 标签写入按容器分派(ID3v2.3 / Vorbis comment / iTunes ilst),全部原子替换
|
||||||
4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底
|
4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -27,7 +27,6 @@ from ._concurrency import (
|
|||||||
get_file_lock,
|
get_file_lock,
|
||||||
)
|
)
|
||||||
from ._http import (
|
from ._http import (
|
||||||
_AUDIO_MAGIC,
|
|
||||||
_SESSION_LOCAL,
|
_SESSION_LOCAL,
|
||||||
DEFAULT_UA,
|
DEFAULT_UA,
|
||||||
DL_CHUNK_SIZE,
|
DL_CHUNK_SIZE,
|
||||||
@@ -49,7 +48,6 @@ from .downloader import (
|
|||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
API_TYPE_TO_EXT,
|
API_TYPE_TO_EXT,
|
||||||
EXT_TO_FFMPEG_FORMAT,
|
|
||||||
METADATA_FORMATS,
|
METADATA_FORMATS,
|
||||||
DownloadCanceled,
|
DownloadCanceled,
|
||||||
DownloadException,
|
DownloadException,
|
||||||
@@ -63,9 +61,7 @@ __all__ = [
|
|||||||
'API_TYPE_TO_EXT',
|
'API_TYPE_TO_EXT',
|
||||||
'DEFAULT_UA',
|
'DEFAULT_UA',
|
||||||
'DL_CHUNK_SIZE',
|
'DL_CHUNK_SIZE',
|
||||||
'EXT_TO_FFMPEG_FORMAT',
|
|
||||||
'METADATA_FORMATS',
|
'METADATA_FORMATS',
|
||||||
'_AUDIO_MAGIC',
|
|
||||||
'_COVER_CACHE',
|
'_COVER_CACHE',
|
||||||
'_COVER_CACHE_LOCK',
|
'_COVER_CACHE_LOCK',
|
||||||
'_COVER_CACHE_MAX',
|
'_COVER_CACHE_MAX',
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ if __name__ == "__main__":
|
|||||||
# 无参数:打印模块信息
|
# 无参数:打印模块信息
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("网易云音乐下载器")
|
print("网易云音乐下载器")
|
||||||
print(f"ffmpeg 路径: {downloader.ffmpeg_path or '未找到'}")
|
|
||||||
print("支持的音质: standard / exhigh / lossless / hires / sky / jyeffect / jymaster / dolby")
|
print("支持的音质: standard / exhigh / lossless / hires / sky / jyeffect / jymaster / dolby")
|
||||||
print("支持的功能: 同步/批量下载、ffmpeg 元数据写入")
|
print("支持的功能: 同步/批量下载、标签与封面写入(mutagen 纯 Python)")
|
||||||
print()
|
print()
|
||||||
print("用法:")
|
print("用法:")
|
||||||
print(" python music_downloader.py <music_id> [quality] # 仅打印信息")
|
print(" python music_downloader.py <music_id> [quality] # 仅打印信息")
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
import filetype
|
||||||
|
|
||||||
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
|
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
|
||||||
|
|
||||||
# ============ HTTP 连接复用(提速核心) ============
|
# ============ HTTP 连接复用(提速核心) ============
|
||||||
@@ -27,26 +29,28 @@ DL_CHUNK_SIZE = 256 * 1024
|
|||||||
_SESSION_LOCAL = threading.local()
|
_SESSION_LOCAL = threading.local()
|
||||||
|
|
||||||
|
|
||||||
# 下载内容嗅探的音频魔数(未知期望尺寸时唯一的内容防线)
|
# M4A/MP4 音频在 filetype 里被判为 video/mp4——mp4 是复合容器,仅凭魔数无法
|
||||||
_AUDIO_MAGIC = (b'ID3', b'fLaC', b'OggS', b'RIFF')
|
# 区分音频轨与视频轨,故显式放行该 mime。
|
||||||
|
_CONTAINER_MIMES = frozenset({'video/mp4'})
|
||||||
|
|
||||||
|
|
||||||
def _sniff_audio_header(path) -> bool:
|
def _sniff_audio_header(path) -> bool:
|
||||||
"""读取文件头做基础音频魔数校验:MP3(ID3/裸帧)、FLAC、OGG/Opus、WAV(RIFF)、
|
"""按魔数判定文件是否为音频容器(MP3/FLAC/OGG/Opus/WAV/M4A/MP4)。
|
||||||
M4A/MP4(ftyp)。未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容
|
|
||||||
防线——CDN 错误页/风控 JSON 不再能被存成音频交付。"""
|
未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容防线——
|
||||||
|
CDN 错误页/风控 JSON 不再能被存成音频交付。
|
||||||
|
|
||||||
|
识别交给 filetype(内置完整类型表,覆盖各格式的多种帧头变体),
|
||||||
|
不再自行维护魔数前缀与 ftyp 偏移;对空文件/未知格式返回 False。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open(path, 'rb') as f:
|
kind = filetype.guess(str(path))
|
||||||
head = f.read(16)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
if not head:
|
if kind is None:
|
||||||
return False
|
return False
|
||||||
if any(head.startswith(m) for m in _AUDIO_MAGIC):
|
mime = (kind.mime or '').lower()
|
||||||
return True
|
return mime.startswith('audio/') or mime in _CONTAINER_MIMES
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def get_http_session() -> requests.Session:
|
def get_http_session() -> requests.Session:
|
||||||
|
|||||||
+163
-325
@@ -8,9 +8,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,6 +16,7 @@ from core import sync_http as requests # 同步门面(底层 aiohttp,替代
|
|||||||
from core.async_bridge import run_sync
|
from core.async_bridge import run_sync
|
||||||
from music_api import APIException, NeteaseAPI, load_cookies
|
from music_api import APIException, NeteaseAPI, load_cookies
|
||||||
|
|
||||||
|
from ._aria2 import Aria2Backend, get_backend
|
||||||
from ._concurrency import (
|
from ._concurrency import (
|
||||||
CountingSemaphore,
|
CountingSemaphore,
|
||||||
get_file_lock,
|
get_file_lock,
|
||||||
@@ -36,14 +34,16 @@ from .cover import (
|
|||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
API_TYPE_TO_EXT,
|
API_TYPE_TO_EXT,
|
||||||
EXT_TO_FFMPEG_FORMAT,
|
|
||||||
METADATA_FORMATS,
|
|
||||||
DownloadCanceled,
|
DownloadCanceled,
|
||||||
DownloadException,
|
DownloadException,
|
||||||
DownloadResult,
|
DownloadResult,
|
||||||
MusicInfo,
|
MusicInfo,
|
||||||
_RetryFromScratch,
|
_RetryFromScratch,
|
||||||
)
|
)
|
||||||
|
from .tagging import build_tags, write_tags
|
||||||
|
|
||||||
|
# 单文件下载体积硬上限:超限即弃,防止单文件拖垮磁盘
|
||||||
|
_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024
|
||||||
|
|
||||||
# ============ 核心实现 ============
|
# ============ 核心实现 ============
|
||||||
|
|
||||||
@@ -59,15 +59,13 @@ class MusicDownloader:
|
|||||||
self,
|
self,
|
||||||
download_dir: str = "downloads",
|
download_dir: str = "downloads",
|
||||||
max_concurrent: int = 3,
|
max_concurrent: int = 3,
|
||||||
ffmpeg_path: str | None = None,
|
|
||||||
global_max_concurrent: int = 10,
|
global_max_concurrent: int = 10,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
download_dir: 下载目录
|
download_dir: 下载目录
|
||||||
max_concurrent: 单个批量任务内的最大并发下载数
|
max_concurrent: 单个批量任务内的最大并发下载数
|
||||||
ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次
|
global_max_concurrent: 整个服务同时下载的文件数(含标签写入)。
|
||||||
global_max_concurrent: 整个服务同时下载的文件数(含 ffmpeg 元数据写入)。
|
|
||||||
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
|
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
|
||||||
"""
|
"""
|
||||||
self.download_dir = Path(download_dir)
|
self.download_dir = Path(download_dir)
|
||||||
@@ -80,15 +78,10 @@ class MusicDownloader:
|
|||||||
# (带计数,供前端实时展示排队情况)
|
# (带计数,供前端实时展示排队情况)
|
||||||
self._download_semaphore = CountingSemaphore(global_max_concurrent)
|
self._download_semaphore = CountingSemaphore(global_max_concurrent)
|
||||||
|
|
||||||
# ffmpeg 元数据写入专用信号量(CPU 密集操作):
|
# 标签写入专用信号量:写标签会重写整个文件(IO 密集),
|
||||||
# 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输
|
# 与下载分离限流 —— 写满时只阻塞后来者,不占下载名额、不拖慢其它文件的传输
|
||||||
cpu_count = os.cpu_count() or 2
|
cpu_count = os.cpu_count() or 2
|
||||||
self._meta_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
|
self._tag_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,写入元数据功能将不可用")
|
|
||||||
|
|
||||||
# ----------- 工具方法 -----------
|
# ----------- 工具方法 -----------
|
||||||
|
|
||||||
@@ -142,18 +135,6 @@ class MusicDownloader:
|
|||||||
|
|
||||||
return ".mp3"
|
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
|
@staticmethod
|
||||||
def _unlink_quiet(path: Path) -> None:
|
def _unlink_quiet(path: Path) -> None:
|
||||||
"""安静地删除文件,忽略不存在 / 权限错误"""
|
"""安静地删除文件,忽略不存在 / 权限错误"""
|
||||||
@@ -328,6 +309,11 @@ class MusicDownloader:
|
|||||||
self._unlink_quiet(part_path0)
|
self._unlink_quiet(part_path0)
|
||||||
raise DownloadCanceled("下载已取消")
|
raise DownloadCanceled("下载已取消")
|
||||||
|
|
||||||
|
# part_path 是 file_path 的 .part 别名。必须在进入下载 try 之前绑定:
|
||||||
|
# except 分支(取消/网络错误)要清理 .part,若此处未赋值,异常处理器
|
||||||
|
# 会以 NameError 二次崩溃、掩盖原始异常
|
||||||
|
part_path = part_path0
|
||||||
|
|
||||||
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
|
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
|
||||||
with get_file_lock(file_path):
|
with get_file_lock(file_path):
|
||||||
try:
|
try:
|
||||||
@@ -345,8 +331,8 @@ class MusicDownloader:
|
|||||||
else:
|
else:
|
||||||
# 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
|
# 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
|
||||||
if write_meta:
|
if write_meta:
|
||||||
with self._meta_semaphore:
|
with self._tag_semaphore:
|
||||||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
self._write_metadata(file_path, music_info, meta_fields)
|
||||||
return DownloadResult(
|
return DownloadResult(
|
||||||
success=True,
|
success=True,
|
||||||
file_path=str(file_path),
|
file_path=str(file_path),
|
||||||
@@ -354,7 +340,7 @@ class MusicDownloader:
|
|||||||
music_info=music_info,
|
music_info=music_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
part_path = file_path.with_name(file_path.name + ".part")
|
# part_path 已在文件锁外绑定为 part_path0(同一路径),此处不再重复计算
|
||||||
resume_from = 0
|
resume_from = 0
|
||||||
if part_path.exists():
|
if part_path.exists():
|
||||||
existing = part_path.stat().st_size
|
existing = part_path.stat().st_size
|
||||||
@@ -364,8 +350,8 @@ class MusicDownloader:
|
|||||||
# 改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
|
# 改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
|
||||||
os.replace(part_path, file_path)
|
os.replace(part_path, file_path)
|
||||||
if write_meta:
|
if write_meta:
|
||||||
with self._meta_semaphore:
|
with self._tag_semaphore:
|
||||||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
self._write_metadata(file_path, music_info, meta_fields)
|
||||||
return DownloadResult(
|
return DownloadResult(
|
||||||
success=True,
|
success=True,
|
||||||
file_path=str(file_path),
|
file_path=str(file_path),
|
||||||
@@ -387,85 +373,15 @@ class MusicDownloader:
|
|||||||
if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5):
|
if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5):
|
||||||
raise DownloadCanceled("下载已取消")
|
raise DownloadCanceled("下载已取消")
|
||||||
try:
|
try:
|
||||||
headers = {}
|
aria2_backend = get_backend()
|
||||||
mode = "wb"
|
if aria2_backend is not None:
|
||||||
if resume_from > 0:
|
# aria2c 通道:多连接 + 原生断点续传
|
||||||
headers["Range"] = f"bytes={resume_from}-"
|
self._fetch_via_aria2(aria2_backend, music_info, part_path,
|
||||||
mode = "ab"
|
cancel_event, progress_cb)
|
||||||
|
else:
|
||||||
if progress_cb is not None:
|
# 内置通道:aiohttp 流式落盘 + Range 起点校验
|
||||||
try:
|
self._fetch_via_http(music_info, part_path, resume_from,
|
||||||
progress_cb(resume_from, music_info.file_size or 0)
|
cancel_event, progress_cb)
|
||||||
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()
|
|
||||||
finally:
|
finally:
|
||||||
# 与 acquire_cancellable 配对释放(原 with 语义)
|
# 与 acquire_cancellable 配对释放(原 with 语义)
|
||||||
self._download_semaphore.release()
|
self._download_semaphore.release()
|
||||||
@@ -501,8 +417,8 @@ class MusicDownloader:
|
|||||||
# 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟
|
# 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟
|
||||||
if cancel_event is not None and cancel_event.is_set():
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
raise DownloadCanceled("下载已取消")
|
raise DownloadCanceled("下载已取消")
|
||||||
with self._meta_semaphore:
|
with self._tag_semaphore:
|
||||||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
self._write_metadata(file_path, music_info, meta_fields)
|
||||||
|
|
||||||
return DownloadResult(
|
return DownloadResult(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -548,229 +464,151 @@ class MusicDownloader:
|
|||||||
return DownloadResult(success=False, error_message=last_error or "下载失败")
|
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
|
# download_music_to_memory / download_batch_async / get_download_progress
|
||||||
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
|
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
|
||||||
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
|
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
|
||||||
|
|
||||||
def _write_metadata_with_ffmpeg(
|
def _write_metadata(
|
||||||
self,
|
self,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
music_info: MusicInfo,
|
music_info: MusicInfo,
|
||||||
fields: list[str] | None = None,
|
fields: list[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""使用 ffmpeg 写入音频元数据
|
"""写入音频标签与封面(mutagen);失败不影响交付,返回是否成功。
|
||||||
|
|
||||||
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
|
Args:
|
||||||
fields: 可选字段列表 ['title','artist','album','cover','lyrics','date'];
|
file_path: 已转正的音频文件
|
||||||
None 表示全部写入。
|
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
|
include_cover = fields is None or "cover" in fields
|
||||||
|
cover: bytes | None = None
|
||||||
if include_cover and music_info.pic_url:
|
if include_cover and music_info.pic_url:
|
||||||
cover_data = fetch_cover_bytes(music_info.pic_url)
|
cover = fetch_cover_bytes(music_info.pic_url)
|
||||||
if cover_data:
|
if not cover:
|
||||||
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("封面下载失败,跳过封面写入")
|
logger.warning("封面下载失败,跳过封面写入")
|
||||||
|
return write_tags(file_path, build_tags(music_info, fields), cover)
|
||||||
# 构造 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"]
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""数据模型、异常与 ffmpeg 格式常量。
|
"""数据模型、异常与格式常量。
|
||||||
|
|
||||||
(自 music_downloader.py 拆出,行为与原实现一致。)
|
(自 music_downloader.py 拆出,行为与原实现一致。)
|
||||||
"""
|
"""
|
||||||
@@ -82,16 +82,5 @@ API_TYPE_TO_EXT = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# 文件扩展名 -> ffmpeg 输出 muxer 名称
|
# 支持元数据写入的扩展名(tagging.py 按此表分派到各容器的标签写入器)
|
||||||
EXT_TO_FFMPEG_FORMAT = {
|
|
||||||
".mp3": "mp3",
|
|
||||||
".flac": "flac",
|
|
||||||
".m4a": "mp4",
|
|
||||||
".mp4": "mp4",
|
|
||||||
".ogg": "ogg",
|
|
||||||
".opus": "opus",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 支持元数据写入的扩展名
|
|
||||||
METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"}
|
METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"}
|
||||||
@@ -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 不一致'
|
||||||
@@ -15,6 +15,9 @@ dependencies = [
|
|||||||
"qrcode>=7.0,<8.3",
|
"qrcode>=7.0,<8.3",
|
||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
"jinja2>=3.1,<4.0",
|
"jinja2>=3.1,<4.0",
|
||||||
|
"filetype>=1.2.0",
|
||||||
|
"aria2p>=0.12.1",
|
||||||
|
"mutagen>=1.48.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -24,6 +27,7 @@ dev = [
|
|||||||
"ruff>=0.6",
|
"ruff>=0.6",
|
||||||
"pyright>=1.1.390",
|
"pyright>=1.1.390",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"httpx2>=2.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 应用工程(非库):不把自身装进环境,避免 uv 扫描顶层模块
|
# 应用工程(非库):不把自身装进环境,避免 uv 扫描顶层模块
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "attrs"
|
name = "attrs"
|
||||||
version = "26.1.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.20"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.10.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.16.9"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.54.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "17.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "yarl"
|
name = "yarl"
|
||||||
version = "1.25.1"
|
version = "1.25.1"
|
||||||
@@ -1532,10 +1808,13 @@ dependencies = [
|
|||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "aria2p" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "filetype" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
|
{ name = "mutagen" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "qrcode" },
|
{ name = "qrcode" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -1544,6 +1823,7 @@ dependencies = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "httpx2" },
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
@@ -1555,10 +1835,13 @@ requires-dist = [
|
|||||||
{ name = "aiofiles", specifier = ">=24.1" },
|
{ name = "aiofiles", specifier = ">=24.1" },
|
||||||
{ name = "aiohttp", specifier = ">=3.10,<4.0" },
|
{ name = "aiohttp", specifier = ">=3.10,<4.0" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.20,<1.0" },
|
{ name = "aiosqlite", specifier = ">=0.20,<1.0" },
|
||||||
|
{ name = "aria2p", specifier = ">=0.12.1" },
|
||||||
{ name = "cryptography", specifier = ">=50.0,<51.0" },
|
{ name = "cryptography", specifier = ">=50.0,<51.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115,<1.0" },
|
{ name = "fastapi", specifier = ">=0.115,<1.0" },
|
||||||
|
{ name = "filetype", specifier = ">=1.2.0" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.2,<3.0" },
|
{ name = "itsdangerous", specifier = ">=2.2,<3.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1,<4.0" },
|
{ name = "jinja2", specifier = ">=3.1,<4.0" },
|
||||||
|
{ name = "mutagen", specifier = ">=1.48.1" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
{ name = "qrcode", specifier = ">=7.0,<8.3" },
|
{ name = "qrcode", specifier = ">=7.0,<8.3" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" },
|
||||||
@@ -1567,6 +1850,7 @@ requires-dist = [
|
|||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "httpx2", specifier = ">=2.13.1" },
|
||||||
{ name = "pyright", specifier = ">=1.1.390" },
|
{ name = "pyright", specifier = ">=1.1.390" },
|
||||||
{ name = "pytest", specifier = ">=8.0" },
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||||
|
|||||||
Reference in new issue
Block a user