refactor(async): music_api 异步化 + 全项目 lint 收敛

music_api.py:
- 22 个方法改 async(HTTPClient.post_request*/NeteaseAPI.*/QRLoginManager.*),
  33 处 requests.* → await core.aiohttp_compat.*
- 16 个模块级同步包装改走 run_sync 桥(main.py 等 99 个同步调用点零改动)
- async 内 time.sleep → asyncio.sleep(修 7 处阻塞事件循环)

musica_downloader/qr_login:
- 单曲三请求 ThreadPoolExecutor → asyncio.gather(语义等价,失败处理保留)
- interactive_login 改 async,CLI 入口用 asyncio.run

真实缺陷修复(lint 发现):
- podcast_api._to_int 未定义(2 处调用会 NameError)→ 补安全转换 helper
- main.py VALID_QUALITY_LEVELS 重复定义(覆盖 core.config 导入值)→ 移除
- 移除死代码变量(salt×2/device_id×3/file_path/zipped_files)

lint 收敛:524→0(ruff All checks passed);pyright 0 errors。
pyproject 记录「有意保留」项(B904/RUF012/S305 上游协议等)及理由。

验证:冒烟 10/10;pytest 16/16;真实上游 get_toplist 异步直调与桥接均得 63 条。
This commit is contained in:
snowy-yang committed 2026-09-26 00:32:45 +08:00
1 parent 4962542e78
commit ce7ea66d6a
8 files changed
+520 -490

No files matched your search

+40 -44
View File
@@ -15,7 +15,6 @@
from __future__ import annotations
import contextlib
import logging
import os
import re
@@ -27,13 +26,12 @@ import time
from collections import deque
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
from requests.adapters import HTTPAdapter
from core.async_bridge import run_sync
from music_api import APIException, NeteaseAPI, load_cookies
# P2-5:worker 线程的输出一律走 logger(坑 11——NSSM GBK 重定向下裸 print
@@ -80,7 +78,7 @@ def _sniff_audio_header(path) -> bool:
def get_http_session() -> requests.Session:
"""获取当前线程的共享 requests.Session(带连接池 + keep-alive)"""
session: Optional[requests.Session] = getattr(_SESSION_LOCAL, "session", None)
session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None)
if session is None:
session = requests.Session()
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
@@ -96,7 +94,7 @@ def get_http_session() -> requests.Session:
# F78:锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长
# A3:计数在注册表守卫锁内完成("取出引用"即计数 +1),消除"取引用后、acquire 前
# 被另一个线程回收、第三个线程拿到新锁对象"的互斥破坏窗口
_FILE_LOCKS: Dict[str, "RefCountedFileLock"] = {}
_FILE_LOCKS: dict[str, RefCountedFileLock] = {}
_FILE_LOCKS_GUARD = threading.Lock()
@@ -125,7 +123,7 @@ class _FileLockHandle:
self._lock = None
self._key = ''
def __enter__(self) -> "RefCountedFileLock":
def __enter__(self) -> RefCountedFileLock:
key = str(self._path)
with _FILE_LOCKS_GUARD:
lock = _FILE_LOCKS.get(key)
@@ -159,18 +157,18 @@ class _FileLockHandle:
self._lock = None
def get_file_lock(path: Path) -> "_FileLockHandle":
def get_file_lock(path: Path) -> _FileLockHandle:
"""兼容入口:返回可用 with 语句的锁句柄(A3:引用计数在守卫锁内登记,无回收竞态)"""
return _FileLockHandle(path)
# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用)
_COVER_CACHE: Dict[str, bytes] = {}
_COVER_CACHE: dict[str, bytes] = {}
_COVER_CACHE_ORDER: deque = deque(maxlen=128)
_COVER_CACHE_LOCK = threading.Lock()
def fetch_cover_bytes(pic_url: str) -> Optional[bytes]:
def fetch_cover_bytes(pic_url: str) -> bytes | None:
"""下载并缓存封面图片字节;失败返回 None(B3:字节预算 10MB,拒绝超大盘点阵/伪装文件)"""
if not pic_url:
return None
@@ -243,17 +241,17 @@ class MusicInfo:
quality: str
lyric: str = ""
tlyric: str = ""
publish_year: Optional[int] = None
publish_year: int | None = None
@dataclass
class DownloadResult:
"""下载结果数据类"""
success: bool
file_path: Optional[str] = None
file_path: str | None = None
file_size: int = 0
error_message: str = ""
music_info: Optional[MusicInfo] = None
music_info: MusicInfo | None = None
class DownloadException(Exception):
@@ -330,7 +328,7 @@ class CountingSemaphore:
with self._lock:
self.active = max(0, self.active - 1)
def __enter__(self) -> "CountingSemaphore":
def __enter__(self) -> CountingSemaphore:
self.acquire()
return self
@@ -378,7 +376,7 @@ class MusicDownloader:
self,
download_dir: str = "downloads",
max_concurrent: int = 3,
ffmpeg_path: Optional[str] = None,
ffmpeg_path: str | None = None,
global_max_concurrent: int = 10,
):
"""
@@ -461,7 +459,7 @@ class MusicDownloader:
return ".mp3"
def _resolve_ffmpeg(self) -> Optional[str]:
def _resolve_ffmpeg(self) -> str | None:
"""懒加载解析 ffmpeg 路径
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
@@ -484,31 +482,29 @@ class MusicDownloader:
# ----------- 信息获取 -----------
def get_music_info(self, music_id: int, quality: str = "standard",
cookies: Optional[Dict[str, str]] = None) -> MusicInfo:
cookies: dict[str, str] | None = None) -> MusicInfo:
"""获取音乐详细信息(同步,URL/详情/歌词 3 个接口并行请求,加快单曲/批量启动)"""
try:
cookies = cookies if cookies is not None else load_cookies()
# 3 个接口并行请求,单曲下载/批量预取都能明显提速
# F99:URL/详情拿到后立即 shutdown(cancel_futures),
# 已知失败不再被无关的歌词请求拖着等完才报错
from concurrent.futures import ThreadPoolExecutor
_pool = ThreadPoolExecutor(max_workers=3)
url_f = _pool.submit(self.api.get_song_url, music_id, quality, cookies)
detail_f = _pool.submit(self.api.get_song_detail, music_id)
lyric_f = _pool.submit(self.api.get_lyric, music_id, cookies)
try:
url_result = url_f.result()
detail_result = detail_f.result()
try:
lyric_result = lyric_f.result()
except Exception:
lyric_result = None # 歌词接口抖动不阻断解析/下载
finally:
try:
_pool.shutdown(wait=False, cancel_futures=True)
except TypeError:
_pool.shutdown(wait=False)
# F99:URL/详情拿到后立即取消未完成的歌词请求,已知失败不再被拖着等完
# 异步化:改用 asyncio.gather 并发(api 方法已异步),失败语义与旧线程池一致
import asyncio
async def _gather_info():
url_t = self.api.get_song_url(music_id, quality, cookies)
detail_t = self.api.get_song_detail(music_id)
lyric_t = self.api.get_lyric(music_id, cookies)
return await asyncio.gather(url_t, detail_t, lyric_t, return_exceptions=True)
url_result, detail_result, lyric_result = run_sync(_gather_info())
if isinstance(url_result, BaseException):
raise url_result
if isinstance(detail_result, BaseException):
raise detail_result
if isinstance(lyric_result, BaseException):
lyric_result = None # 歌词接口抖动不阻断解析/下载
if not url_result.get("data") or not url_result["data"]:
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
@@ -602,11 +598,11 @@ class MusicDownloader:
music_id: int,
quality: str = "standard",
write_meta: bool = True,
meta_fields: Optional[List[str]] = None,
cookies: Optional[Dict[str, str]] = None,
meta_fields: list[str] | None = None,
cookies: dict[str, str] | None = None,
retries: int = 2,
cancel_event: Optional[threading.Event] = None,
music_info: Optional[MusicInfo] = None,
cancel_event: threading.Event | None = None,
music_info: MusicInfo | None = None,
progress_cb=None,
) -> DownloadResult:
"""同步下载音乐到本地
@@ -719,7 +715,7 @@ class MusicDownloader:
progress_cb(resume_from, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
resp: Optional[requests.Response] = None
resp: requests.Response | None = None
try:
resp = get_http_session().get(
music_info.download_url,
@@ -878,7 +874,7 @@ class MusicDownloader:
self,
file_path: Path,
music_info: MusicInfo,
fields: Optional[List[str]] = None,
fields: list[str] | None = None,
) -> bool:
"""使用 ffmpeg 写入音频元数据
@@ -909,7 +905,7 @@ class MusicDownloader:
file_path.stem + ".tagging" + file_path.suffix
)
# 临时封面文件
cover_tmp: Optional[str] = None
cover_tmp: str | None = None
cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理
try:
@@ -934,7 +930,7 @@ class MusicDownloader:
# 构造 ffmpeg 命令
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
cmd: List[str] = [
cmd: list[str] = [
ffmpeg_path, "-y", "-loglevel", "error",
"-i", str(file_path),
]
@@ -1030,7 +1026,7 @@ class MusicDownloader:
except OSError:
pass
def _build_format_args(self, file_ext: str, has_cover: bool) -> List[str]:
def _build_format_args(self, file_ext: str, has_cover: bool) -> list[str]:
"""根据文件格式构造 mapping / 编码参数
F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""