This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/services/api_service.py
T
snowy-yang 7a70eef636 refactor: 删除 services.runtime 兼容门面,7 处调用点直连真实子模块
为什么:上一次分包重构把 1900 行巨石按职责拆成 9 个子模块后,留了一个
runtime.py 作为「原样再导出」的兼容门面,好让调用点零改动。但迁移期结束后
没有一个调用点迁走——门面成了永久中间层:每次读代码都要先跳一层才知道
符号真正住在哪,pyright 也无法从门面追溯定义位置。

改法:保持「函数内延迟导入」的形式不变,只把导入目标换成真实子模块。
**刻意不提到模块顶部**——services/__init__.py 里有依赖自检
(缺 auth_system/podcast_api/music_api/music_downloader/qr_login 就 sys.exit(1)),
提到顶层会让「import web.*」这一步就触发该自检,改变启动语义,超出本次范围。

7 处调用点(AST 精确扫描确认全集):
- web/routers/download.py ×4:download / download_batch_zip / podcast_download /
  podcast_batch_zip,分别直连 services.downloads / batch / podcast / tasks
  外加 _shared._build_download_base 与 api_service.api_service
- web/routers/tasks.py ×2:_semaphores 取 _shared.BATCH_ZIP_SEM + api_service,
  topen 取 _shared.ENDPOINT_NAMES
- web/tasks_registry.py ×1:start_task 直连 _taskcore._start_task_thread

子模块短名统一加 `services_` 前缀别名(services_tasks 等),避免与函数内
既有局部名相撞。

顺带清理:9 个子模块 docstring 里「(自 services/runtime.py 拆出……)」的
历史说明在门面删除后已成悬空引用,一并去掉;包的模块划分说明改为
「不再有兼容门面层」。

验证:
- 符号可达:11 个被引符号在真实子模块上全部 hasattr 通过;
  services.runtime 已 ModuleNotFoundError;api_service 仍是同一实例对象
- 三个调用方模块均可正常 import
- 门禁:ruff 0 · pyright 0 errors 0 warnings · pytest 175 passed
- 真实 uvicorn 冒烟 8/8(含 POST /auth/login 400),临时数据目录独立落库
  9 张表,生产 data/ 指纹三项一致
2026-09-26 14:48:05 +08:00

180 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""服务类与进程级服务实例。
"""
import logging
import re
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlsplit
from core.config import (
Settings,
)
from core.utils import expand_shortlink as _expand_shortlink
from music_api import (
NeteaseAPI,
load_cookies,
)
from music_downloader import (
MusicDownloader,
)
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称
APIConfig = Settings
class APIResponse:
"""API响应工具类"""
@staticmethod
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> tuple[dict[str, Any], int]:
"""成功响应"""
response = {
'status': status_code,
'success': True,
'message': message
}
if data is not None:
response['data'] = data
return response, status_code
@staticmethod
def error(message: str, status_code: int = 400, error_code: str | None = None) -> tuple[dict[str, Any], int]:
"""错误响应"""
response = {
'status': status_code,
'success': False,
'message': message
}
if error_code:
response['error_code'] = error_code
return response, status_code
class MusicAPIService:
"""音乐API服务类"""
def __init__(self, config: APIConfig):
self.config = config
self.logger = self._setup_logger()
self.netease_api = NeteaseAPI()
# 下载目录传给下载器——原实现下载器固定 downloads,
# 服务配置指向自定义目录时音乐落盘位置与配置分离
self.downloader = MusicDownloader(download_dir=config.downloads_dir)
# 创建下载目录
self.downloads_path = Path(config.downloads_dir)
self.downloads_path.mkdir(exist_ok=True)
self.logger.info(f"音乐API服务初始化完成,下载目录: {self.downloads_path.absolute()}")
def _setup_logger(self) -> logging.Logger:
"""设置日志记录器"""
logger = logging.getLogger('music_api')
logger.setLevel(getattr(logging, self.config.log_level.upper()))
if not logger.handlers:
# 控制台处理器
console_handler = logging.StreamHandler()
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀)
try:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler('music_api.log', maxBytes=5 * 1024 * 1024,
backupCount=3, encoding='utf-8')
file_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
except Exception as e:
logger.warning(f"无法创建日志文件: {e}")
return logger
def _get_cookies(self) -> dict[str, str]:
"""直接从 cookie.txt 读取并解析为 dict"""
try:
cookies = load_cookies()
if not cookies:
self.logger.warning("cookie.txt 为空或不存在,部分接口(VIP 音质)将不可用")
return cookies
except Exception as e:
self.logger.error(f"读取 cookie.txt 异常: {e}")
return {}
def _extract_music_id(self, id_or_url: str) -> str:
"""提取音乐ID(正规 URL 解析替代子串/删除非数字启发式)
- 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF)
- music.163.com 链接用 parse_qs 取 id 参数(参数顺序无关)
"""
raw = str(id_or_url or '').strip()
try:
# 短链展开:仅接受"整个输入就是 163cn.tv 链接"(子串包含不算)
if re.match(r'^https?://(www\.)?163cn\.tv/\S+$', raw, re.IGNORECASE):
raw = _expand_shortlink(raw)
# 网易云链接:正规解析 query 取 id(不再 find('id=') 误吃 userid= 尾部)
if 'music.163.com' in raw:
try:
q = parse_qs(urlsplit(raw).query)
for key in ('id', 'songid'):
if q.get(key):
return str(q[key][0]).strip()
except Exception:
pass
# 兜底:老格式 #/song?id=xxx 的片段部分
m = re.search(r'[?&#]id=(\d+)', raw)
if m:
return m.group(1)
# 纯数字/普通文本直接返回
return raw
except Exception as e:
self.logger.error(f"提取音乐ID失败: {e}")
return str(id_or_url or '').strip()
def _format_file_size(self, size_bytes) -> str:
"""格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)"""
try:
size = float(size_bytes or 0)
except (TypeError, ValueError):
return "大小未知"
if size <= 0:
return "0B"
units = ["B", "KB", "MB", "GB", "TB"]
unit_index = 0
while size >= 1024.0 and unit_index < len(units) - 1:
size /= 1024.0
unit_index += 1
return f"{size:.2f}{units[unit_index]}"
def _get_quality_display_name(self, quality: str) -> str:
"""获取音质显示名称"""
quality_names = {
'standard': "标准音质",
'exhigh': "极高音质",
'lossless': "无损音质",
'hires': "Hi-Res音质",
'sky': "沉浸环绕声",
'jyeffect': "高清环绕声",
'jymaster': "超清母带",
'dolby': "杜比全景声"
}
return quality_names.get(quality, f"未知音质({quality})")
# 服务实例(框架无关)
config = APIConfig()
api_service = MusicAPIService(config)