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/web/routers/music.py
T
snowy-yang f27bd7dd74 refactor: /song 端点拆解(99 行 / 圈 18 → 主函数 27 行 / 圈 8)
为什么:song 是端点层最重的函数——参数校验(4 条)、账号 Cookie 选择、四种 type
分发、json 分支的三源合并与降级全塞在一处;而四种 type 的返回体形状与降级口径
**没有任何测试记录**(golden 里的 /song 只有一条参数校验用例 song_badid)。

改法:
- _parse_song_query:提取 + 校验,返回 _SongQuery 或**错误消息字符串**。返回
  字符串而非响应对象,是为了让它不依赖响应层、可单独断言(校验段 4 条错误都是
  默认 400,无需状态码)。
- _SongQuery:frozen dataclass。music_id 保持 extract_id 的**字符串原值**——它
  原样进响应体 id 字段、golden 按字符串比对;上游要的 int 走 .mid property。
- _song_url_response / _song_json_response:两个实体分支各自成函数。
- song 主函数只剩「校验 → 选 Cookie → 四路分发」。

刻意保留的语义(是决定,不是疏漏):
- 解析日志**只在 json 分支**记录,url/name/lyric 三路不记——历史实现如此。
  已写进 _song_json_response 的 docstring:不要为了「统一」把它上提到主函数,
  那会新增三路的可观察副作用。
- 校验顺序(缺参 → ID 非数字 → 音质 → 类型)原样保留,并用测试钉住「同时传错
  时报哪条」。顺序是用户可见行为。
- 三条 404 降级(上游 data 为空 / data=[null] / songs 为空)逐条保留。

复杂度(同口径 AST 实测,重构前后):
  song                 99 行 / 圈 18 / 嵌套 4  →  27 行 / 圈 8 / 嵌套 2
  _song_json_response   新增                  →  49 行 / 圈 6 / 嵌套 1
  _parse_song_query     新增                  →  21 行 / 圈 5 / 嵌套 1
  文件内 >80 行函数 1 → 0;最大圈 18 → 11(余下最大的是本次未动的 mask_server_nickname)
  注:上一轮口头报的「圈 44」与本轮口径不同,此处以本轮统一口径为准,重测为准绳。

等价性证据(三道,互相独立):
1. AST 字面量审计:字符串字面量 **227 处完全一致**;数值唯一差异是新增的
   dataclass frozen=True。即没有一条用户可见文案或阈值被改动。
2. 反向注入验证:把 HEAD 的旧 song 换回去,用同一套 tests/test_song_types.py 跑
   → 19 例**全过**,证明这批测试忠实编码了旧行为、对新实现构成等价性验证。
3. 新增 19 例行为契约测试:四种 type 正路、json 的三源合并与三种降级(歌词失败
   不阻断 / 直链失败置空 / 详情失败 404)、校验优先级、分享链接解析、
   account=user 无 Cookie 401、全局 Cookie 冒用拦截。

顺带修掉一个测试自身缺陷:新用例里「个人 Cookie 与全局账号一致」一条**单跑通过、
全套失败**——根因是它依赖 GLOBAL_MUSIC_U_CACHE(5 分钟粘性的模块级缓存),别的
文件先跑会把值填上,于是拿到缓存值而非本处 patch 的 Cookie。已改为 fixture 里
用 monkeypatch.setitem 显式重置该缓存,让用例自足。

验证:ruff 全过 · pyright 0/0 · pytest 233 passed(214→233)· 生产 data/ 未被
触碰(全部用例走 tmp_path 沙箱)。
2026-09-27 07:41:16 +08:00

418 lines
17 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 -*-
"""音乐端点。
端点:
- 免费解析类(需登录,不扣次数):
POST/GET /api/v1/toplist ↔ Flask /toplist
POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
POST/GET /api/v1/comments ↔ Flask /comments
POST/GET /api/v1/search ↔ Flask /search
POST/GET /api/v1/album ↔ Flask /album
POST/GET /api/v1/playlist ↔ Flask /playlist
POST/GET /api/v1/song ↔ Flask /song(四种 type:url/name/lyric/json)
参数获取口径:GET query / POST json / POST form 三路兼容。
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
"""
import asyncio
import hmac
import logging
import time
from dataclasses import dataclass
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
import music_api
from core import media, repo
from core.config import VALID_QUALITY_LEVELS
from core.state import GLOBAL_MUSIC_U_CACHE, SERVER_NICK_CACHE
from music_api import APIException
from web import ratelimit
from web.deps import SiteUser, require_login
from web.request_utils import cookies as _cookies
from web.request_utils import extract_id as _extract_id
from web.request_utils import request_data as _request_data
from web.request_utils import safe_int as _safe_int
from web.response import error, success
from web.serializers import record_req_log as _record_log
router = APIRouter()
LoginUser = Annotated[SiteUser, Depends(require_login)]
logger = logging.getLogger('web.music')
_VALID_SONG_TYPES = ['url', 'name', 'lyric', 'json']
_SERVER_NICK_LOCK: asyncio.Lock | None = None
def _nick_lock() -> asyncio.Lock:
global _SERVER_NICK_LOCK
if _SERVER_NICK_LOCK is None:
_SERVER_NICK_LOCK = asyncio.Lock()
return _SERVER_NICK_LOCK
async def mask_server_nickname(nick: str, user: SiteUser) -> str:
"""非管理员响应中把服务器账号昵称替换为站点名。
- 管理员本人可见真实昵称;
- 缓存 10 分钟内复用;上游拿不到时回落站点库里的最后已知昵称继续遮蔽。
"""
if not nick or user.is_admin:
return nick
import time
def _replace(n):
return nick.replace(n, '云村小助手') if (n and n in nick) else nick
if SERVER_NICK_CACHE['nick'] is None or time.time() - SERVER_NICK_CACHE['ts'] > 600:
async with _nick_lock():
if SERVER_NICK_CACHE['nick'] is None or time.time() - SERVER_NICK_CACHE['ts'] > 600:
fetched = ''
try:
fetched = (await music_api.NeteaseAPI().get_account_info(_cookies()) or {}).get('nickname', '')
except Exception:
fetched = ''
if fetched:
SERVER_NICK_CACHE['nick'] = fetched
SERVER_NICK_CACHE['ts'] = time.time()
try:
await repo.set_setting('server_nickname', fetched)
except Exception:
pass
else:
try:
persisted = await repo.get_setting('server_nickname', '')
except Exception:
persisted = ''
if persisted:
SERVER_NICK_CACHE['nick'] = persisted
SERVER_NICK_CACHE['ts'] = time.time()
else:
# 库里也从未有过:缩短重试间隔,本次无遮蔽依据只能放行
SERVER_NICK_CACHE['ts'] = time.time() - 600 + 30
return _replace(SERVER_NICK_CACHE['nick'])
@router.api_route('/toplist', methods=['GET', 'POST'])
async def toplist(_user: LoginUser, request: Request):
"""官方榜单列表。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
try:
result = await music_api.NeteaseAPI().get_toplist()
return success(result, '获取榜单列表成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
@router.api_route('/artist_songs', methods=['GET', 'POST'])
async def artist_songs(_user: LoginUser, request: Request):
"""歌手信息与热门歌曲。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
artist_id = data.get('id') or data.get('artist_id')
if artist_id in (None, ''):
return error('缺少 artist_id 参数')
artist_id = _extract_id(artist_id)
if not str(artist_id).strip().isdigit():
return error('无效的歌手ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_artist_hot_songs(int(artist_id))
return success(result, '获取歌手歌曲成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
@router.api_route('/comments', methods=['GET', 'POST'])
async def comments(_user: LoginUser, request: Request):
"""歌曲评论(分页)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
song_id = data.get('id') or data.get('song_id')
limit = max(1, min(_safe_int(data.get('limit'), 20), 100))
offset = max(0, _safe_int(data.get('offset'), 0))
if song_id in (None, ''):
return error('缺少 song_id 参数')
song_id = _extract_id(song_id)
if not str(song_id).strip().isdigit():
return error('无效的歌曲ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_song_comments(int(song_id), limit, offset)
return success(result, '获取评论成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
@router.api_route('/search', methods=['GET', 'POST'])
async def search(user: LoginUser, request: Request):
"""搜索(歌曲/专辑/歌手/歌单),offset 透传上游。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
keyword = data.get('keyword') or data.get('keywords') or data.get('q')
limit = max(1, _safe_int(data.get('limit'), 30))
offset = max(0, _safe_int(data.get('offset'), 0))
search_type = data.get('type', '1')
if not keyword:
return error('缺少 keyword 参数')
if limit > 100:
limit = 100
try:
stype = int(search_type)
except (TypeError, ValueError):
return error('参数格式错误:type 必须为数字')
try:
result = await music_api.NeteaseAPI().search_music(
str(keyword), _cookies(), limit, stype, offset=offset)
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
# 向后兼容字段 + 服务器昵称遮蔽
if result:
for song in result:
if song.get('type', 'song') == 'song' and 'artists' in song:
song['artist_string'] = song['artists']
if song.get('type') == 'playlist':
if song.get('creator'):
song['creator'] = await mask_server_nickname(song['creator'], user)
if song.get('name'):
song['name'] = await mask_server_nickname(song['name'], user)
return success(result, '搜索完成')
# ============ 个人/全局账号 Cookie ============
def _global_music_u() -> str:
"""全局账号 MUSIC_U(5 分钟缓存)。"""
now = time.time()
if GLOBAL_MUSIC_U_CACHE['v'] is None or now - GLOBAL_MUSIC_U_CACHE['ts'] > 300:
try:
ck = _cookies()
GLOBAL_MUSIC_U_CACHE['v'] = (ck.get('MUSIC_U') or '').strip()
except Exception:
GLOBAL_MUSIC_U_CACHE['v'] = ''
GLOBAL_MUSIC_U_CACHE['ts'] = now
return GLOBAL_MUSIC_U_CACHE['v']
async def _resolve_user_cookies(
data: dict[str, Any], user: 'SiteUser | None', request: Request
) -> dict[str, str]:
"""解析个人账号 Cookie(浏览器传入的 user_cookie,不落服务器)。
全局账号(cookie.txt)零个人身份权限:浏览器提交的 MUSIC_U 若与全局账号一致
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计。
"""
ck = media.parse_cookie_str(data.get('user_cookie'))
mu = (ck.get('MUSIC_U') or '').strip()
gmu = _global_music_u()
if mu and gmu and hmac.compare_digest(mu, gmu):
try:
import auth_system
await asyncio.to_thread(auth_system.add_log, 'fail',
user.username if user else '匿名',
ratelimit.client_ip(request), '全局账号冒用拦截',
'浏览器提交的 MUSIC_U 与全局账号一致,已按未登录拒绝')
except Exception:
pass
logger.warning('[全局账号冒用拦截] %s 提交的 MUSIC_U 与全局账号一致,已按未登录处理',
request.url.path)
return {}
return ck
# ============ 迁移端:专辑 / 歌单 / 歌曲 ============
@router.api_route('/album', methods=['GET', 'POST'])
async def album(_user: LoginUser, request: Request):
"""专辑详情。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
album_id = data.get('id')
if not album_id:
return error("参数 'album_id' 不能为空", 400)
album_id = _extract_id(album_id)
if not str(album_id).strip().isdigit():
return error('无效的专辑ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_album_detail(int(album_id), _cookies())
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
return success({'status': 200, 'album': result}, '获取专辑详情成功')
@router.api_route('/playlist', methods=['GET', 'POST'])
async def playlist(user: LoginUser, request: Request):
"""歌单详情(非管理员遮蔽服务器账号昵称)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
playlist_id = data.get('id')
if not playlist_id:
return error("参数 'playlist_id' 不能为空", 400)
playlist_id = _extract_id(playlist_id)
if not str(playlist_id).strip().isdigit():
return error('无效的歌单ID,请输入数字ID或分享链接')
try:
result = await music_api.NeteaseAPI().get_playlist_detail(int(playlist_id), _cookies())
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
if isinstance(result, dict):
if result.get('creator'):
result['creator'] = await mask_server_nickname(result['creator'], user)
if result.get('name'):
result['name'] = await mask_server_nickname(result['name'], user)
return success({'status': 'success', 'playlist': result}, '获取歌单详情成功')
@dataclass(frozen=True)
class _SongQuery:
"""`/song` 的已校验入参。
``music_id`` 保持 ``extract_id`` 的原始返回(字符串):它会**原样**进入响应体
的 ``id`` 字段,golden 快照按字符串比对。转 int 只在上游调用时做(见 ``mid``)。
"""
music_id: str
level: str
info_type: str
@property
def mid(self) -> int:
"""上游接口要的整型 ID(响应体仍用原字符串)。"""
return int(self.music_id)
def _parse_song_query(data: dict[str, Any]) -> _SongQuery | str:
"""提取并校验 /song 入参:成功给 _SongQuery,失败给**错误消息**。
返回消息字符串而非响应对象,是为了让本函数不依赖响应层、可单独断言。
校验顺序即用户可见的错误优先级(缺参 → ID 非数字 → 音质 → 类型),
与历史实现逐条一致——改顺序会改变「同时传错时先报哪个」。
"""
song_ids = data.get('ids') or data.get('id')
url = data.get('url')
if not song_ids and not url:
return "必须提供 'ids'、'id' 或 'url' 参数"
music_id = _extract_id(song_ids or url)
if not music_id.strip().isdigit():
return '无效的歌曲ID,请输入数字ID或分享链接'
level = data.get('level', 'lossless')
if level not in VALID_QUALITY_LEVELS:
return f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}"
info_type = data.get('type', 'url')
if info_type not in _VALID_SONG_TYPES:
return f"无效的类型参数,支持: {', '.join(_VALID_SONG_TYPES)}"
return _SongQuery(music_id, level, info_type)
async def _song_url_response(api: Any, query: _SongQuery, cookies: dict[str, str]) -> JSONResponse:
"""type=url:取直链,附带可读的大小与音质名。"""
result = await api.get_song_url(query.mid, query.level, cookies)
if result and result.get('data') and len(result['data']) > 0:
song_data = result['data'][0] or {}
if not song_data:
return error('获取音乐URL失败,可能是版权限制或音质不支持', 404)
return success({
'id': song_data.get('id'),
'url': song_data.get('url'),
'level': song_data.get('level'),
'quality_name': media.quality_display_name(song_data.get('level', query.level)),
'size': song_data.get('size'),
'size_formatted': media.format_file_size(song_data.get('size', 0)),
'type': song_data.get('type'),
'bitrate': song_data.get('br'),
}, '获取歌曲URL成功')
return error('获取音乐URL失败,可能是版权限制或音质不支持', 404)
async def _song_json_response(api: Any, query: _SongQuery, cookies: dict[str, str],
request: Request) -> JSONResponse:
"""type=json:并行取详情/直链/歌词,歌词失败不阻断整次解析。
解析日志只在**本分支**记录——历史实现如此,url/name/lyric 三路都不记。不要
为了「统一」把它上提到主函数,那会新增其余三路的可观察副作用。
"""
song_info, url_info, lyric_info = await asyncio.gather(
api.get_song_detail(query.mid),
api.get_song_url(query.mid, query.level, cookies),
api.get_lyric(query.mid, cookies),
return_exceptions=True,
)
if isinstance(lyric_info, BaseException):
lyric_info = None
if isinstance(url_info, BaseException):
url_info = None
if isinstance(song_info, BaseException) or not song_info or not song_info.get('songs'):
return error('未找到歌曲信息', 404)
song_data = song_info['songs'][0]
_al = song_data.get('al') or {}
_ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or []))))
response_data: dict[str, Any] = {
'id': query.music_id,
'name': song_data.get('name') or '',
'ar_name': _ar_names,
'al_name': _al.get('name') or '',
'pic': _al.get('picUrl') or '',
'level': query.level,
'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '',
'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '',
'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or '',
}
if url_info and url_info.get('data') and len(url_info['data']) > 0:
url_data = url_info['data'][0]
response_data.update({
'url': url_data.get('url', ''),
'size': media.format_file_size(url_data.get('size', 0)),
'level': url_data.get('level', query.level),
})
else:
response_data.update({'url': '', 'size': '获取失败'})
try:
_record_log('解析', ratelimit.client_ip(request), response_data.get('name') or query.music_id)
except Exception:
pass
return success(response_data, '获取歌曲信息成功')
@router.api_route('/song', methods=['GET', 'POST'])
async def song(user: LoginUser, request: Request):
"""歌曲信息(type: url/name/lyric/json)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request)
query = _parse_song_query(data)
if isinstance(query, str):
return error(query)
if data.get('account', 'admin') == 'user':
cookies = await _resolve_user_cookies(data, user, request)
if not cookies:
return error('请先扫码登录个人账号后再使用个人解析', 401)
else:
cookies = _cookies()
api = music_api.NeteaseAPI()
try:
if query.info_type == 'url':
return await _song_url_response(api, query, cookies)
if query.info_type == 'name':
return success(await api.get_song_detail(query.mid), '获取歌曲信息成功')
if query.info_type == 'lyric':
return success(await api.get_lyric(query.mid, cookies), '获取歌词成功')
return await _song_json_response(api, query, cookies, request)
except APIException:
return error('服务暂时不可用,请稍后再试', 500)