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 沙箱)。
This commit is contained in:
snowy-yang committed 2026-09-27 07:41:16 +08:00
1 parent 1973d6a0e8
commit f27bd7dd74
2 files changed
+386 -38

No files matched your search

+307
View File
@@ -0,0 +1,307 @@
# -*- coding: utf-8 -*-
"""`/song` 端点四种 type 的行为契约。
为什么需要它:golden 快照里的 /song 只有 **一条**(song_badid,走参数校验),
url / name / lyric / json 四条业务分支零覆盖。把 99 行 / 圈 44 的单函数拆成
_parse_song_query + 三个响应函数后,需要行为级测试钉住:
- 四种 type 各自打到哪个上游方法、返回体形状(含 `id` 必须是**字符串**:
它来自 extract_id、原样进响应,golden 按字符串比对)
- 校验顺序(缺参 → ID 非数字 → 音质 → 类型)——同时传错时报哪个
- json 分支的三个上游任一失败时的降级口径(歌词不阻断、详情失败转 404)
- 解析日志**只在 json 分支**记录(历史实现如此,不是遗漏)
断言只看「调用后世界里发生了什么」:状态码 / 关键字段 / 上游收到了什么参数,
不引用任何私有函数名,故可跨后续重构复用。
"""
import sqlite3
import time
import pytest
from fastapi.testclient import TestClient
import auth_system
import core.db as core_db
import music_api
from core import session as cs
from core.state import GLOBAL_MUSIC_U_CACHE
from web import ratelimit
SECRET = 'song-test-secret'
# create_app(api_prefix='') 时路由挂在**根**(与 test_golden 同一挂载方式),
# 故这里是 /song 而不是 /api/v1/song。
PATH = '/song'
class FakeAPI:
"""music_api.NeteaseAPI 的替身:记录调用,返回预置结果或抛预置异常。"""
def __init__(self) -> None:
self.calls: list[tuple] = []
self.url_result: object = {'data': [{
'id': 1, 'url': 'http://x/a.mp3', 'level': 'lossless',
'size': 1024, 'type': 'mp3', 'br': 999,
}]}
self.detail_result: object = {'songs': [{
'name': '某首歌',
'al': {'name': '某专辑', 'picUrl': 'http://x/p.jpg'},
'ar': [{'name': '甲'}, {'name': '乙'}],
}]}
self.lyric_result: object = {'lrc': {'lyric': '歌词本体'},
'tlyric': {'lyric': '译词'},
'yrc': {'lyric': '逐字'}}
self.url_exc: Exception | None = None
self.detail_exc: Exception | None = None
self.lyric_exc: Exception | None = None
async def get_song_url(self, mid, level, cookies):
self.calls.append(('get_song_url', mid, level))
if self.url_exc is not None:
raise self.url_exc
return self.url_result
async def get_song_detail(self, mid):
self.calls.append(('get_song_detail', mid))
if self.detail_exc is not None:
raise self.detail_exc
return self.detail_result
async def get_lyric(self, mid, cookies):
self.calls.append(('get_lyric', mid))
if self.lyric_exc is not None:
raise self.lyric_exc
return self.lyric_result
@pytest.fixture()
def fake_api(monkeypatch):
fake = FakeAPI()
monkeypatch.setattr(music_api, 'NeteaseAPI', lambda *a, **kw: fake)
return fake
@pytest.fixture()
def logs(monkeypatch):
"""替换全局 Cookie 与解析日志,返回日志调用记录。
同时清掉 GLOBAL_MUSIC_U_CACHE 并置空限流闸:
- 该缓存是 5 分钟粘性的模块级状态,别的测试先跑会把值填上,导致本文件的
「个人 Cookie 与全局账号一致 → 拒绝」用例拿到缓存值而非本处 patch 的
Cookie(表现为单跑通过、全套失败)。monkeypatch.setitem 保证 teardown 还原。
- ratelimit 是进程内计数,让它参与会让用例受其它文件的请求数影响而随机 429。
"""
recs: list[tuple] = []
async def _noop_guard(*a, **kw):
return None
monkeypatch.setitem(GLOBAL_MUSIC_U_CACHE, 'v', None)
monkeypatch.setitem(GLOBAL_MUSIC_U_CACHE, 'ts', 0.0)
monkeypatch.setattr('web.routers.music._cookies', lambda: {'MUSIC_U': 'global'})
monkeypatch.setattr('web.routers.music._record_log', lambda *a: recs.append(a))
monkeypatch.setattr(ratelimit, 'guard', _noop_guard)
return recs
@pytest.fixture()
def client(tmp_path, monkeypatch):
"""自建站点库 + 造一份合法会话 Cookie(不触碰生产 data/)。"""
path = tmp_path / 'site.db'
conn = sqlite3.connect(str(path))
conn.executescript("""
CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, role TEXT DEFAULT 'user',
status INTEGER DEFAULT 1, login_seq INTEGER DEFAULT 0,
duration_seconds INTEGER DEFAULT 0, ban_reason TEXT DEFAULT '');
INSERT INTO users (id, username, role, status, login_seq, duration_seconds)
VALUES (1,'tester','user',1,7,100);
""")
conn.commit()
conn.close()
monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path)
monkeypatch.setattr(core_db, 'DB_PATH', path)
monkeypatch.setattr(auth_system.db, 'DATA_DIR', tmp_path)
monkeypatch.setattr(auth_system.db, 'DB_PATH', path)
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
from web.app import create_app
app = TestClient(create_app(api_prefix=''))
cookie = cs.dumps(SECRET, {'site_uid': 1, 'site_uid_seq': 7, 'site_uid_ts': int(time.time())})
return app, {'Cookie': f'session={cookie}'}
def _post(client, headers, payload):
return client.post(PATH, json=payload, headers=headers)
# ============ 四种 type 的正路 ============
def test_type_url_returns_direct_link(client, fake_api, logs):
app, h = client
r = _post(app, h, {'id': 123, 'type': 'url', 'level': 'lossless'})
assert r.status_code == 200, r.text
body = r.json()
assert body['message'] == '获取歌曲URL成功'
assert body['data']['url'] == 'http://x/a.mp3'
assert body['data']['size'] == 1024
assert body['data']['size_formatted'] # 上游 size 转可读串
assert body['data']['quality_name'] # 音质显示名
assert fake_api.calls == [('get_song_url', 123, 'lossless')]
assert logs == [], 'url 分支不记解析日志'
def test_type_url_empty_data_is_404(client, fake_api, logs):
"""上游返回空 data 列表 → 404(与「拿不到直链」同一口径)。"""
app, h = client
fake_api.url_result = {'data': []}
r = _post(app, h, {'id': 123, 'type': 'url'})
assert r.status_code == 404
assert '获取音乐URL失败' in r.json()['message']
def test_type_url_none_first_element_is_404(client, fake_api, logs):
"""data 首元素为 null → song_data 退化为 {} → 404(不是 500)。"""
app, h = client
fake_api.url_result = {'data': [None]}
r = _post(app, h, {'id': 123, 'type': 'url'})
assert r.status_code == 404
def test_type_name_uses_detail(client, fake_api, logs):
app, h = client
r = _post(app, h, {'id': 456, 'type': 'name'})
assert r.status_code == 200
assert r.json()['message'] == '获取歌曲信息成功'
assert r.json()['data'] == fake_api.detail_result
assert fake_api.calls == [('get_song_detail', 456)]
assert logs == []
def test_type_lyric_uses_lyric(client, fake_api, logs):
app, h = client
r = _post(app, h, {'id': 789, 'type': 'lyric'})
assert r.status_code == 200
assert r.json()['message'] == '获取歌词成功'
assert fake_api.calls == [('get_lyric', 789)]
assert logs == []
def test_type_is_default_url(client, fake_api, logs):
"""不传 type 时默认 url —— 与历史实现一致。"""
app, h = client
r = _post(app, h, {'id': 123})
assert r.status_code == 200
assert fake_api.calls == [('get_song_url', 123, 'lossless')]
# ============ json 分支:三源合并与降级 ============
def test_type_json_merges_three_sources(client, fake_api, logs):
app, h = client
r = _post(app, h, {'id': 123, 'type': 'json'})
assert r.status_code == 200
d = r.json()['data']
assert d['id'] == '123' and isinstance(d['id'], str), 'id 必须是 extract_id 的字符串原值'
assert d['name'] == '某首歌'
assert d['ar_name'] == '甲, 乙'
assert d['al_name'] == '某专辑'
assert d['pic'] == 'http://x/p.jpg'
assert d['lyric'] == '歌词本体'
assert d['tlyric'] == '译词'
assert d['yrc'] == '逐字'
assert d['url'] == 'http://x/a.mp3'
assert {c[0] for c in fake_api.calls} == {'get_song_detail', 'get_song_url', 'get_lyric'}
assert len(logs) == 1, 'json 分支记一条解析日志'
assert logs[0][0] == '解析'
def test_type_json_lyric_failure_does_not_break(client, fake_api, logs):
"""歌词取失败不阻断整次解析(return_exceptions 的关键语义)。"""
app, h = client
fake_api.lyric_exc = music_api.APIException('歌词挂了')
r = _post(app, h, {'id': 123, 'type': 'json'})
assert r.status_code == 200
d = r.json()['data']
assert d['lyric'] == '' and d['tlyric'] == '' and d['yrc'] == ''
assert d['name'] == '某首歌' and d['url'] == 'http://x/a.mp3'
def test_type_json_url_failure_yields_empty_url(client, fake_api, logs):
"""直链取失败 → url 置空、size 记「获取失败」,其余照常返回。"""
app, h = client
fake_api.url_exc = music_api.APIException('直链挂了')
r = _post(app, h, {'id': 123, 'type': 'json'})
assert r.status_code == 200
d = r.json()['data']
assert d['url'] == '' and d['size'] == '获取失败'
assert d['name'] == '某首歌'
def test_type_json_detail_failure_is_404(client, fake_api, logs):
"""详情取失败 → 404「未找到歌曲信息」(不是 500,也不静默返回空壳)。"""
app, h = client
fake_api.detail_exc = music_api.APIException('详情挂了')
r = _post(app, h, {'id': 123, 'type': 'json'})
assert r.status_code == 404
assert r.json()['message'] == '未找到歌曲信息'
assert logs == [], '未产出响应数据则不记解析日志'
def test_type_json_empty_songs_is_404(client, fake_api, logs):
"""详情返回但 songs 为空 → 同样 404。"""
app, h = client
fake_api.detail_result = {'songs': []}
r = _post(app, h, {'id': 123, 'type': 'json'})
assert r.status_code == 404
# ============ 参数校验与优先级 ============
@pytest.mark.parametrize('payload,expected', [
({}, "必须提供 'ids'、'id' 或 'url' 参数"),
({'id': 'abc'}, '无效的歌曲ID,请输入数字ID或分享链接'),
({'id': 1, 'level': 'bad'}, '无效的音质参数'),
({'id': 1, 'type': 'bad'}, '无效的类型参数'),
])
def test_validation_messages(client, fake_api, logs, payload, expected):
app, h = client
r = _post(app, h, payload)
assert r.status_code == 400
assert expected in r.json()['message']
assert fake_api.calls == [], '校验不通过不应触达上游'
def test_validation_precedence_id_before_level(client, fake_api, logs):
"""ID 与音质同时非法时报**先校验的那条**(顺序是用户可见行为)。"""
app, h = client
r = _post(app, h, {'id': 'abc', 'level': 'bad', 'type': 'bad'})
assert r.status_code == 400
assert '无效的歌曲ID' in r.json()['message']
def test_url_param_via_share_link(client, fake_api, logs):
"""用分享链接代替 id 也能解析出数字 ID(extract_id 的既有能力)。"""
app, h = client
r = _post(app, h, {'url': 'https://music.163.com/song?id=999', 'type': 'name'})
assert r.status_code == 200
assert fake_api.calls == [('get_song_detail', 999)]
def test_account_user_without_cookie_is_401(client, fake_api, logs):
"""account=user 但未提供个人 Cookie → 401(不静默回落全局账号)。"""
app, h = client
r = _post(app, h, {'id': 1, 'type': 'name', 'account': 'user'})
assert r.status_code == 401
assert fake_api.calls == []
def test_account_user_rejects_global_cookie(client, fake_api, logs, monkeypatch):
"""个人 Cookie 与全局账号一致(全局 Cookie 泄露被冒用)→ 401 并记审计。"""
app, h = client
monkeypatch.setattr('web.routers.music.media.parse_cookie_str',
lambda raw: {'MUSIC_U': 'global'})
r = _post(app, h, {'id': 1, 'type': 'name', 'account': 'user',
'user_cookie': 'MUSIC_U=global'})
assert r.status_code == 401
assert fake_api.calls == []
+79 -38
View File
@@ -18,9 +18,11 @@ import asyncio
import hmac import hmac
import logging import logging
import time import time
from dataclasses import dataclass
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
import music_api import music_api
from core import media, repo from core import media, repo
@@ -274,38 +276,49 @@ async def playlist(user: LoginUser, request: Request):
return success({'status': 'success', 'playlist': result}, '获取歌单详情成功') return success({'status': 'success', 'playlist': result}, '获取歌单详情成功')
@router.api_route('/song', methods=['GET', 'POST']) @dataclass(frozen=True)
async def song(user: LoginUser, request: Request): class _SongQuery:
"""歌曲信息(type: url/name/lyric/json)。""" """`/song` 的已校验入参。
await ratelimit.guard(request, bucket='free', limit=30, window=60)
data = await _request_data(request) ``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') song_ids = data.get('ids') or data.get('id')
url = data.get('url') url = data.get('url')
level = data.get('level', 'lossless')
info_type = data.get('type', 'url')
if not song_ids and not url: if not song_ids and not url:
return error("必须提供 'ids'、'id' 或 'url' 参数") return "必须提供 'ids'、'id' 或 'url' 参数"
music_id = _extract_id(song_ids or url) music_id = _extract_id(song_ids or url)
if not str(music_id).strip().isdigit(): if not music_id.strip().isdigit():
return error('无效的歌曲ID,请输入数字ID或分享链接') return '无效的歌曲ID,请输入数字ID或分享链接'
level = data.get('level', 'lossless')
if level not in VALID_QUALITY_LEVELS: if level not in VALID_QUALITY_LEVELS:
return error(f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}") return f"无效的音质参数,支持: {', '.join(VALID_QUALITY_LEVELS)}"
info_type = data.get('type', 'url')
if info_type not in _VALID_SONG_TYPES: if info_type not in _VALID_SONG_TYPES:
return error(f"无效的类型参数,支持: {', '.join(_VALID_SONG_TYPES)}") return f"无效的类型参数,支持: {', '.join(_VALID_SONG_TYPES)}"
return _SongQuery(music_id, level, info_type)
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() async def _song_url_response(api: Any, query: _SongQuery, cookies: dict[str, str]) -> JSONResponse:
try: """type=url:取直链,附带可读的大小与音质名。"""
if info_type == 'url': result = await api.get_song_url(query.mid, query.level, cookies)
result = await api.get_song_url(int(music_id), level, cookies)
if result and result.get('data') and len(result['data']) > 0: if result and result.get('data') and len(result['data']) > 0:
song_data = result['data'][0] or {} song_data = result['data'][0] or {}
if not song_data: if not song_data:
@@ -314,7 +327,7 @@ async def song(user: LoginUser, request: Request):
'id': song_data.get('id'), 'id': song_data.get('id'),
'url': song_data.get('url'), 'url': song_data.get('url'),
'level': song_data.get('level'), 'level': song_data.get('level'),
'quality_name': media.quality_display_name(song_data.get('level', level)), 'quality_name': media.quality_display_name(song_data.get('level', query.level)),
'size': song_data.get('size'), 'size': song_data.get('size'),
'size_formatted': media.format_file_size(song_data.get('size', 0)), 'size_formatted': media.format_file_size(song_data.get('size', 0)),
'type': song_data.get('type'), 'type': song_data.get('type'),
@@ -322,17 +335,18 @@ async def song(user: LoginUser, request: Request):
}, '获取歌曲URL成功') }, '获取歌曲URL成功')
return error('获取音乐URL失败,可能是版权限制或音质不支持', 404) return error('获取音乐URL失败,可能是版权限制或音质不支持', 404)
if info_type == 'name':
return success(await api.get_song_detail(int(music_id)), '获取歌曲信息成功')
if info_type == 'lyric': async def _song_json_response(api: Any, query: _SongQuery, cookies: dict[str, str],
return success(await api.get_lyric(int(music_id), cookies), '获取歌词成功') request: Request) -> JSONResponse:
"""type=json:并行取详情/直链/歌词,歌词失败不阻断整次解析。
# info_type == 'json':并行取详情/URL,歌词失败不阻断 解析日志只在**本分支**记录——历史实现如此,url/name/lyric 三路都不记。不要
为了「统一」把它上提到主函数,那会新增其余三路的可观察副作用。
"""
song_info, url_info, lyric_info = await asyncio.gather( song_info, url_info, lyric_info = await asyncio.gather(
api.get_song_detail(int(music_id)), api.get_song_detail(query.mid),
api.get_song_url(int(music_id), level, cookies), api.get_song_url(query.mid, query.level, cookies),
api.get_lyric(int(music_id), cookies), api.get_lyric(query.mid, cookies),
return_exceptions=True, return_exceptions=True,
) )
if isinstance(lyric_info, BaseException): if isinstance(lyric_info, BaseException):
@@ -345,13 +359,13 @@ async def song(user: LoginUser, request: Request):
song_data = song_info['songs'][0] song_data = song_info['songs'][0]
_al = song_data.get('al') or {} _al = song_data.get('al') or {}
_ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or [])))) _ar_names = ', '.join(filter(None, ((a or {}).get('name') for a in (song_data.get('ar') or []))))
response_data = { response_data: dict[str, Any] = {
'id': music_id, 'id': query.music_id,
'name': song_data.get('name') or '', 'name': song_data.get('name') or '',
'ar_name': _ar_names, 'ar_name': _ar_names,
'al_name': _al.get('name') or '', 'al_name': _al.get('name') or '',
'pic': _al.get('picUrl') or '', 'pic': _al.get('picUrl') or '',
'level': level, 'level': query.level,
'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '', 'lyric': ((lyric_info or {}).get('lrc') or {}).get('lyric') or '',
'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '', 'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '',
'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or '', 'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or '',
@@ -361,16 +375,43 @@ async def song(user: LoginUser, request: Request):
response_data.update({ response_data.update({
'url': url_data.get('url', ''), 'url': url_data.get('url', ''),
'size': media.format_file_size(url_data.get('size', 0)), 'size': media.format_file_size(url_data.get('size', 0)),
'level': url_data.get('level', level), 'level': url_data.get('level', query.level),
}) })
else: else:
response_data.update({'url': '', 'size': '获取失败'}) response_data.update({'url': '', 'size': '获取失败'})
try: try:
_record_log('解析', ratelimit.client_ip(request), response_data.get('name') or str(music_id)) _record_log('解析', ratelimit.client_ip(request), response_data.get('name') or query.music_id)
except Exception: except Exception:
pass pass
return success(response_data, '获取歌曲信息成功') 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: except APIException:
return error('服务暂时不可用,请稍后再试', 500) return error('服务暂时不可用,请稍后再试', 500)