Archived
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:
1 parent
4962542e78
commit
ce7ea66d6a
8 files changed
+520
-490
No files matched your search
+40
-44
@@ -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 已有封面静默删掉。"""
|
||||
|
||||
Reference in new issue
Block a user