# -*- coding: utf-8 -*- """封面字节缓存(同专辑只从网络拉一次)。 (自 music_downloader.py 拆出,行为与原实现一致;HTTP 改走 core.http_client。) """ from __future__ import annotations import threading from collections import deque from core.http_client import client as _http from core.http_client import iter_chunks from ._http import ( DEFAULT_UA, ) # 封面字节缓存:同一专辑封面只从网络拉取一次(元数据写入 与 ZIP 打包 共用) # 容量抽成常量:淘汰循环按它判定,不再读 deque.maxlen(该属性类型为 int|None, # 一旦有人把 deque 改成无界,len() >= None 会直接 TypeError) _COVER_CACHE_MAX = 128 _COVER_CACHE: dict[str, bytes] = {} _COVER_CACHE_ORDER: deque[str] = deque(maxlen=_COVER_CACHE_MAX) # 只保护字典/双端队列的读写,临界区内无 await——仍用 threading.Lock, # 便于同步线程(ZIP 组装)读取缓存时一并复用 _COVER_CACHE_LOCK = threading.Lock() # 封面字节预算:拒绝超大盘点阵/伪装文件 _COVER_MAX_BYTES = 10 * 1024 * 1024 async def fetch_cover_bytes(pic_url: str) -> bytes | None: """下载并缓存封面图片字节;失败返回 None(字节预算 10MB,拒绝超大盘点阵/伪装文件)""" if not pic_url: return None with _COVER_CACHE_LOCK: cached = _COVER_CACHE.get(pic_url) if cached: return cached try: resp = await _http.open('GET', pic_url, headers={'User-Agent': DEFAULT_UA}, timeout=15) try: if resp.status >= 400: return None try: declared = int(resp.headers.get('Content-Length') or 0) except ValueError: declared = 0 # 垃圾 Content-Length 不炸,按未知长度走流式 if declared > _COVER_MAX_BYTES: return None # bytearray 就地追加(原 bytes += 是 O(n²) 拷贝,10MB 封面最坏多拷上百 MB) data = bytearray() async for chunk in iter_chunks(resp, 64 * 1024): data += chunk if len(data) > _COVER_MAX_BYTES: return None if not data: return None payload = bytes(data) finally: # 所有路径统一关闭响应(原 Content-Length 异常路径漏关) resp.close() except Exception: return None with _COVER_CACHE_LOCK: if pic_url not in _COVER_CACHE: # 简单 FIFO 淘汰,防止缓存无界增长 while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_MAX: evict = _COVER_CACHE_ORDER.popleft() _COVER_CACHE.pop(evict, None) _COVER_CACHE[pic_url] = payload _COVER_CACHE_ORDER.append(pic_url) return payload