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 6c3181341b feat(routes): 迁移账号/全局歌单端点 + 鉴权依赖测试
迁移路由(/api/v1/*,与 Flask 同端点逐字段对齐):
- account/vip:admin/user 双路径 + 5 分钟缓存(降级结果 30s),
  VIP 探测单飞锁与 Flask 同源跨栈共享;未登录可访问(同 Flask)
- global/playlists、global/playlist:管理员闸(require_admin,403 对齐)
- web/deps.py 新增 require_admin;_resolve_user_cookies 支持可选用户(匿名审计保底)

测试:46/46(新增 10 项鉴权依赖:有效/管理员/封禁/禁用/顶号/伪造 Cookie、
401/403 闸);ruff All checks passed;pyright 0 errors。

契约奇偶校验:16 项全 OK(新增 account:vip、global:playlists/playlist、
非管理员 403);uvicorn 实跑双栈 account/vip 均 200、未授权歌单 403。
2026-09-26 01:15:22 +08:00

427 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 -*-
"""音乐端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
已迁移:
- 免费解析类(需登录,不扣次数):
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)
参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
"""
import asyncio
import hmac
import logging
import re
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, REQ_LOG, SERVER_NICK_CACHE
from music_api import APIException
from web import ratelimit
from web.deps import SiteUser, require_login
from web.response import error, success
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 _request_data(request: Request) -> dict[str, Any]:
"""三路兼容取参:GET query 优先,其次 POST JSON,最后表单。"""
data: dict[str, Any] = {}
if request.method == 'GET':
data.update(dict(request.query_params))
return data
try:
body = await request.json()
if isinstance(body, dict):
data.update(body)
except Exception:
pass
if not data:
try:
form = await request.form()
data.update(dict(form.items()))
except Exception:
pass
return data
def _safe_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _extract_id(raw: Any) -> str:
"""URL/纯数字 → ID(与 Flask _extract_music_id 的查询参数口径一致)。"""
s = str(raw or '').strip()
if 'music.163.com' in s:
from urllib.parse import parse_qs, urlsplit
try:
q = parse_qs(urlsplit(s).query)
for key in ('id', 'songid'):
if q.get(key):
return str(q[key][0]).strip()
except Exception:
pass
m = re.search(r'[?&#]id=(\d+)', s)
if m:
return m.group(1)
return s
def _cookies() -> dict[str, str]:
"""全局账号 Cookie(管理员 cookie.txt)。"""
return music_api.load_cookies()
async def mask_server_nickname(nick: str, user: SiteUser) -> str:
"""非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。
- 管理员本人可见真实昵称;
- 缓存 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)
# 向后兼容字段 + 服务器昵称遮蔽(与 Flask 侧同口径)
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 分钟缓存,与 Flask main._global_music_u 同源同缓存)。"""
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 冒用),一律拒绝并记审计(与 Flask 同口径)。
"""
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
def _record_log(action: str, ip: str, song: str) -> None:
"""用户解析/下载操作记录(IP)——写入共享 REQ_LOG(与 Flask _record_log 同口径)。"""
REQ_LOG.append({
'action': action,
'ip': ip,
'song': str(song or '')[:100],
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
})
if len(REQ_LOG) > 2000:
del REQ_LOG[:len(REQ_LOG) - 2000]
# ============ 迁移端:专辑 / 歌单 / 歌曲 ============
@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)