Archived
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:
1 parent
1973d6a0e8
commit
f27bd7dd74
2 files changed
+431
-83
No files matched your search
@@ -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 == []
|
||||||
+124
-83
@@ -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,28 +276,128 @@ async def playlist(user: LoginUser, request: Request):
|
|||||||
return success({'status': 'success', 'playlist': result}, '获取歌单详情成功')
|
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'])
|
@router.api_route('/song', methods=['GET', 'POST'])
|
||||||
async def song(user: LoginUser, request: Request):
|
async def song(user: LoginUser, request: Request):
|
||||||
"""歌曲信息(type: url/name/lyric/json)。"""
|
"""歌曲信息(type: url/name/lyric/json)。"""
|
||||||
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
||||||
data = await _request_data(request)
|
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:
|
query = _parse_song_query(data)
|
||||||
return error("必须提供 'ids'、'id' 或 'url' 参数")
|
if isinstance(query, str):
|
||||||
music_id = _extract_id(song_ids or url)
|
return error(query)
|
||||||
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 data.get('account', 'admin') == 'user':
|
||||||
if account == 'user':
|
|
||||||
cookies = await _resolve_user_cookies(data, user, request)
|
cookies = await _resolve_user_cookies(data, user, request)
|
||||||
if not cookies:
|
if not cookies:
|
||||||
return error('请先扫码登录个人账号后再使用个人解析', 401)
|
return error('请先扫码登录个人账号后再使用个人解析', 401)
|
||||||
@@ -304,73 +406,12 @@ async def song(user: LoginUser, request: Request):
|
|||||||
|
|
||||||
api = music_api.NeteaseAPI()
|
api = music_api.NeteaseAPI()
|
||||||
try:
|
try:
|
||||||
if info_type == 'url':
|
if query.info_type == 'url':
|
||||||
result = await api.get_song_url(int(music_id), level, cookies)
|
return await _song_url_response(api, query, cookies)
|
||||||
if result and result.get('data') and len(result['data']) > 0:
|
if query.info_type == 'name':
|
||||||
song_data = result['data'][0] or {}
|
return success(await api.get_song_detail(query.mid), '获取歌曲信息成功')
|
||||||
if not song_data:
|
if query.info_type == 'lyric':
|
||||||
return error('获取音乐URL失败,可能是版权限制或音质不支持', 404)
|
return success(await api.get_lyric(query.mid, cookies), '获取歌词成功')
|
||||||
return success({
|
return await _song_json_response(api, query, cookies, request)
|
||||||
'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:
|
except APIException:
|
||||||
return error('服务暂时不可用,请稍后再试', 500)
|
return error('服务暂时不可用,请稍后再试', 500)
|
||||||
Reference in new issue
Block a user