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 6288a98ecc refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。

拆分(对外导入路径不变,行为零变更):
- music_downloader.py  -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py       -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py         -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py  -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
  streaming/tasks);services/runtime.py 保留为兼容门面

兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。

修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py  ).strip() or '*'
- core/state.py   threading.Lock() x2
- qr_login.py     datetime.now() / .strip()
- music_downloader/downloader.py  .lower()
- services/podcast.py  set() / time.time() x2

清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
  失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败

验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
2026-09-26 12:37:48 +08:00

377 lines
15 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 typing import Annotated, Any
from fastapi import APIRouter, Depends, Request
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}, '获取歌单详情成功')
@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)
song_ids = data.get('ids') or data.get('id')
url = data.get('url')
level = data.get('level', 'lossless')
info_type = data.get('type', 'url')
if not song_ids and not url:
return error("必须提供 'ids'、'id' 或 'url' 参数")
music_id = _extract_id(song_ids or url)
if not str(music_id).strip().isdigit():
return error('无效的歌曲ID,请输入数字ID或分享链接')
if level not in VALID_QUALITY_LEVELS:
return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}")
if info_type not in _VALID_SONG_TYPES:
return error(f"无效的类型参数,支持: {', '.join(_VALID_SONG_TYPES)}")
account = data.get('account', 'admin')
if account == 'user':
cookies = await _resolve_user_cookies(data, user, request)
if not cookies:
return error('请先扫码登录个人账号后再使用个人解析', 401)
else:
cookies = _cookies()
api = music_api.NeteaseAPI()
try:
if info_type == 'url':
result = await api.get_song_url(int(music_id), 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', 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)
if info_type == 'name':
return success(await api.get_song_detail(int(music_id)), '获取歌曲信息成功')
if info_type == 'lyric':
return success(await api.get_lyric(int(music_id), cookies), '获取歌词成功')
# info_type == 'json':并行取详情/URL,歌词失败不阻断
song_info, url_info, lyric_info = await asyncio.gather(
api.get_song_detail(int(music_id)),
api.get_song_url(int(music_id), level, cookies),
api.get_lyric(int(music_id), 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 = {
'id': music_id,
'name': song_data.get('name') or '',
'ar_name': _ar_names,
'al_name': _al.get('name') or '',
'pic': _al.get('picUrl') or '',
'level': 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', level),
})
else:
response_data.update({'url': '', 'size': '获取失败'})
try:
_record_log('解析', ratelimit.client_ip(request), response_data.get('name') or str(music_id))
except Exception:
pass
return success(response_data, '获取歌曲信息成功')
except APIException:
return error('服务暂时不可用,请稍后再试', 500)