feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
"""5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块(对应 sdk/5sing/index.ts)。
上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
榜单/热搜/歌手/评论(上游均无)。
取流入口在 ``app/sdk/apis/fivesing.py``;songmid 约定 ``<songId>|<songType>``(如 "932557|yc")。
登录:上游 New(cookie) 的 cookie 会随请求发送;本移植从 auth_store 的 5sing 记录读取(无则匿名)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.fivesing import lyric, music_search, parse, playlist
from app.sdk.platforms.fivesing.util import build_song_link, extract_song_id, get_status
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现,避免 apis → 本包 util 的初始化顺序耦合。"""
from app.sdk.apis.fivesing import get_music_url
return await get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
ids = extract_song_id(song_info.get("songmid") or song_info.get("id"), song_info.get("songType"))
if not ids["songID"] or not ids["songType"]:
return "http://5sing.kugou.com/"
return build_song_link(ids["songType"], ids["songID"])
class _UserPlaylist:
"""上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义。"""
@staticmethod
async def get_list(_page: int = 1, _limit: int = 20) -> dict[str, Any]:
raise RuntimeError("user playlists not supported")
user_playlist = _UserPlaylist()
def status() -> dict[str, Any]:
return get_status()
MODULE = Platform(
id="5sing",
name="5sing",
music_search=music_search,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_music_detail_page_url=get_music_detail_page_url,
)
+41
View File
@@ -0,0 +1,41 @@
"""5sing 歌词(对应 sdk/5sing/lyric.ts,上游 fivesing/lyric.go 的 GetLyrics)。
接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
→ data.dynamicWords 即 LRC 文本。
上游 ID 约定:model.Song.ID = "<songid>|<songtype>",从 songmid 的 "|" 拆分
(路由层只透传 songmid),也接受 songInfo.songType 字段。
统一接口:返回 ``{lyric, tlyric: ''}``(5sing 无翻译歌词)。
实况(2026-10-23 匿名实测):newget 响应不再包含 dynamicWords(搜索前 10 条与多个关键词均如此,
TS 版同样结果),因此此刻匿名取歌词会如实抛 "lyrics not found";绑定 cookie 后是否有变化尚未验证。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_dynamic_lyric
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
async def get_lyric_text(song_id: str, song_type: str) -> str:
"""直接返回歌词文本(内部/测试用)。"""
lyric = await fetch_dynamic_lyric(song_id, song_type)
if not lyric:
raise RuntimeError("lyrics not found")
return lyric
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"]:
raise RuntimeError("invalid id")
lyric = await fetch_dynamic_lyric(ids["songID"], ids["songType"])
if not lyric:
raise RuntimeError("lyrics not found")
return {"lyric": lyric, "tlyric": ""}
+118
View File
@@ -0,0 +1,118 @@
"""5sing 歌曲信息 / 取流底层(对应 sdk/5sing/musicInfo.ts)。
链路(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink):
取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址;
上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
get_first_valid,
kind_name,
)
QUALITY_TIERS = ("sq", "hq", "lq")
def _tier_url(data: dict[str, Any], tier: str) -> str:
"""data 里某档位的可用地址(主地址 → backup)。"""
return get_first_valid(data.get(f"{tier}url"), data.get(f"{tier}url_backup"))
async def fetch_audio_link(song_id: str, song_type: str, prefer_low: bool = False) -> dict[str, str]:
"""上游 fetchAudioLink:仅获取音频链接。"""
url = (
f"{MOBILE_API}/song/getSongUrl?songid={encode_uri_component(song_id)}"
f"&songtype={encode_uri_component(song_type)}"
)
resp = await fetch_5sing_json(url)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 取流失败(HTTP {resp.status_code})")
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
if code != 1000:
# 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
message = body.get("message")
raise RuntimeError(f"5sing 取流失败({message})" if message else "api returned error code")
data = as_dict(body.get("data"))
order = tuple(reversed(QUALITY_TIERS)) if prefer_low else QUALITY_TIERS
for tier in order:
hit = _tier_url(data, tier)
if hit and hit.startswith(("http://", "https://")):
return {"type": tier, "url": hit}
raise RuntimeError("no valid download url found")
async def fetch_new_get(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象)。"""
url = f"{MOBILE_API}/song/newget?songid={encode_uri_component(song_id)}&songtype={encode_uri_component(song_type)}"
resp = await fetch_5sing_json(url)
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 歌曲信息获取失败(HTTP {resp.status_code})")
data = resp.body.get("data")
return data if isinstance(data, dict) else {}
async def fetch_dynamic_lyric(song_id: str, song_type: str) -> str:
"""上游 newget 歌词:data.dynamicWords(LRC 文本)。"""
data = await fetch_new_get(song_id, song_type)
return str(data.get("dynamicWords") or "")
async def fetch_song_info(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。"""
hit = await fetch_audio_link(song_id, song_type)
name = ""
artist = ""
cover = ""
try:
data = await fetch_new_get(song_id, song_type)
name = str(data.get("SN") or "")
user = as_dict(data.get("user"))
artist = str(user.get("NN") or "")
cover = str(user.get("I") or "")
except Exception: # noqa: BLE001 - 上游 newget 失败时继续(不阻断)
pass
if not name:
name = f"5sing_{song_type}_{song_id}"
return {
"songmid": f"{song_id}|{song_type}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": cover or None,
"lrc": None,
"types": [],
"_types": {},
"url": hit["url"],
"type": hit["type"],
"link": build_song_link(song_type, song_id),
"songType": song_type,
"typeName": kind_name(song_type),
"source": "5sing",
}
def song_interval(song_size: Any) -> int:
"""时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式)。"""
return estimate_duration(song_size)
@@ -0,0 +1,73 @@
"""5sing 歌曲搜索(对应 sdk/5sing/musicSearch.ts,上游 fivesing/song.go 的 Search)。
接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;
本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import (
SEARCH_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
def map_search_song(item: dict[str, Any]) -> dict[str, Any]:
"""上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing')。"""
song_type = str(item.get("typeEname") or "")
duration = estimate_duration(item.get("songSize"))
return {
"songmid": f"{item.get('songId', '')}|{song_type}",
"name": remove_em_tags(unescape_html(item.get("songName"))),
"singer": remove_em_tags(unescape_html(item.get("singer") or item.get("nickName"))),
"albumId": "",
"albumName": "",
"interval": format_play_time(duration) if duration > 0 else 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
# 统一种类字段(上游 typeEname / typeName)
"songType": song_type,
"typeName": str(item.get("typeName") or kind_name(song_type)),
"size": int(item.get("songSize") or 0),
"link": build_song_link(song_type, item.get("songId")),
"source": "5sing",
}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "allPage": 0, "total": 0, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=0"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
songs = [map_search_song(item) for item in raw_list[:page_size] if isinstance(item, dict)]
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(songs)
# 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
all_page = int(page_info.get("totalPages") or 0) or (page_no + 1 if len(songs) >= page_size else page_no)
return {"list": songs, "allPage": all_page, "total": total, "limit": page_size, "source": "5sing"}
+25
View File
@@ -0,0 +1,25 @@
"""5sing 链接解析(对应 sdk/5sing/parse.ts)。
歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetch_song_info(直链 + newget 元数据,无直链即报错)
歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId)
专辑: 上游无专辑概念(不实现)
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_song_info
from app.sdk.platforms.fivesing.playlist import parse_playlist_link
from app.sdk.platforms.fivesing.util import extract_song_link
async def parse_song(link: str) -> dict[str, Any]:
parsed = extract_song_link(link)
if not parsed:
raise RuntimeError("invalid 5sing link")
return await fetch_song_info(parsed["songID"], parsed["songType"])
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+282
View File
@@ -0,0 +1,282 @@
"""5sing 歌单(对应 sdk/5sing/playlist.ts,上游 fivesing/fivesing.go + playlist.go)。
搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/...
上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植并发 5。
详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块
③ 数字 userId 页会 302 到个性域名页,fetch_5sing 已手动跟跳。
分类/个人歌单:上游显式返回不支持,本移植保留同样语义(抛错)。
链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名与省略 user 段,ID 以 API 反查为准。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
SEARCH_API,
build_playlist_link,
build_song_link,
encode_uri_component,
extract_playlist_link,
fetch_5sing,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:显式不支持。"""
raise RuntimeError("playlist categories not supported")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:显式不支持。"""
raise RuntimeError("playlist categories not supported")
def _map_playlist_song(song_id: str, kind: str, name: str, artist: str) -> dict[str, Any]:
"""歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状。"""
return {
"songmid": f"{song_id}|{kind}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
"songType": kind,
"typeName": kind_name(kind),
"link": build_song_link(kind, song_id),
"source": "5sing",
}
def parse_songs_from_html(html_content: str) -> list[dict[str, Any]]:
"""上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
块正则 ``<li class="p_rel">([\\s\\S]*?)</li>``;块内 href 提取 yc/fc/bz 与 id,
s_soner 提取歌手(上游的拼写错误 soner 保留)。
"""
blocks = re.findall(r'<li class="p_rel">([\s\S]*?)</li>', str(html_content or ""))
if not blocks:
raise RuntimeError("no songs found in playlist html (structure mismatch)")
# 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
song_re = re.compile(r'href="(?:(?:https?:)?//)?5sing\.kugou\.com/(yc|fc|bz)/(\d+)\.html"[^>]*>([^<]+)</a>')
artist_re = re.compile(r'class="s_soner[^"]*".*?>([^<]+)</a>')
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for block in blocks:
song_match = song_re.search(block)
if not song_match:
continue
kind, song_id, raw_name = song_match.group(1), song_match.group(2), song_match.group(3)
artist = "Unknown"
artist_match = artist_re.search(block)
if artist_match:
artist = artist_match.group(1)
unique_key = f"{kind}|{song_id}"
if unique_key in seen:
continue
seen.add(unique_key)
name = remove_em_tags(unescape_html(raw_name)).strip()
artist = remove_em_tags(unescape_html(artist)).strip()
songs.append(_map_playlist_song(song_id, kind, name, artist))
return songs
async def fetch_creator_name(playlist_id: str) -> str:
"""上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名。"""
if not playlist_id:
return ""
try:
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=user"
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return ""
data = resp.body.get("data")
if not isinstance(data, dict):
return ""
user = as_dict(data.get("user"))
return str(user.get("NN") or "")
except Exception: # noqa: BLE001 - 反查失败保留 ID 兜底
return ""
def _map_playlist_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐)。"""
playlist_id = str(item.get("songListId") or "")
user_id = str(item.get("userId") or "")
desc = remove_em_tags(unescape_html(item.get("content")))
creator = str(item.get("userName") or "") or (f"ID: {user_id}" if user_id else "")
return {
"id": playlist_id,
"name": remove_em_tags(unescape_html(item.get("title"))),
"cover": str(item.get("pictureUrl") or ""),
"trackCount": int(item.get("songCnt") or 0),
"playCount": int(item.get("playCount") or 0),
"creator": creator,
"creatorId": user_id,
# 上游怪癖:content 为字符串 "0" 时视为空简介
"description": "" if desc == "0" else desc,
"link": build_playlist_link(user_id, playlist_id),
"source": "5sing",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist(type=1),补创建者名(并发 5)。"""
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "total": 0, "page": page_no, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=1"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 歌单搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
playlists = [_map_playlist_summary(item) for item in raw_list[:page_size] if isinstance(item, dict)]
# 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
targets = [pl for pl in playlists if pl["creatorId"] and pl["creator"] == f"ID: {pl['creatorId']}"]
for start in range(0, len(targets), 5):
chunk = targets[start : start + 5]
async def fill(pl: dict[str, Any]) -> None:
name = await fetch_creator_name(pl["id"])
if name:
pl["creator"] = name
await asyncio.gather(*(fill(pl) for pl in chunk))
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(playlists)
return {"list": playlists, "total": total, "page": page_no, "limit": page_size, "source": "5sing"}
async def fetch_playlist_all(playlist_id_raw: str) -> dict[str, Any]:
"""元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全)。"""
playlist_id = str(playlist_id_raw or "").strip()
if not playlist_id:
raise RuntimeError("invalid 5sing playlist id")
# 1) API 元数据(标题/封面/统计/UserID)
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=ID,user"
)
if resp.status_code != 200:
raise RuntimeError(f"fetch info failed: HTTP {resp.status_code}")
# 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
body = as_dict(resp.body)
data = body.get("data")
if not isinstance(data, dict):
raise RuntimeError("playlist info not found or invalid (api returned empty list)")
user = as_dict(data.get("user"))
user_id = str(user.get("ID") or "")
if not user_id:
raise RuntimeError("playlist user not found or invalid id")
playlist = {
"id": playlist_id,
"name": str(data.get("T") or ""),
"cover": str(data.get("P") or ""),
"trackCount": int(data.get("E") or 0),
"playCount": int(data.get("H") or 0),
"creator": str(user.get("NN") or ""),
"description": str(data.get("C") or ""),
"link": build_playlist_link(user_id, playlist_id),
"userId": user_id,
"source": "5sing",
}
# 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch_5sing 已跟跳)
page = await fetch_5sing(playlist["link"])
if page.status_code != 200:
raise RuntimeError(f"fetch html failed: HTTP {page.status_code}")
# 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
songs: list[dict[str, Any]] = []
try:
songs = parse_songs_from_html(str(page.body if isinstance(page.body, str) else ""))
except Exception: # noqa: BLE001 - 结构不匹配时返回空列表
songs = []
return {"playlist": playlist, "songs": songs}
def _playlist_info(playlist: dict[str, Any]) -> dict[str, Any]:
return {
"id": playlist["id"],
"name": playlist["name"],
"img": playlist["cover"],
"desc": playlist["description"],
"author": playlist["creator"],
"link": playlist["link"],
"trackCount": playlist["trackCount"],
"playCount": playlist["playCount"],
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E)。"""
detail = await fetch_playlist_all(playlist_id)
playlist, songs = detail["playlist"], detail["songs"]
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
start = (page_no - 1) * page_size
total = len(songs)
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs[start : start + page_size],
"page": page_no,
"limit": page_size,
"total": total,
"allPage": math.ceil(total / page_size) or 1,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:仅返回曲目列表(全量)。"""
detail = await fetch_playlist_all(playlist_id)
return detail["songs"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(全量曲目)。"""
parsed = extract_playlist_link(link)
if not parsed:
raise RuntimeError("invalid 5sing playlist link")
detail = await fetch_playlist_all(parsed["playlistId"])
playlist, songs = detail["playlist"], detail["songs"]
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs,
"page": 1,
"limit": len(songs) or 1,
"total": len(songs),
"allPage": 1,
}
+196
View File
@@ -0,0 +1,196 @@
"""5sing 公共工具(对应 sdk/5sing/util.ts,上游 fivesing/fivesing.go 的等价实现)。
平台 id 统一为 ``5sing``(上游 Go 包名 fivesing,model.Source 用 "fivesing";本仓库契约要求 5sing)。
上游 ``New(cookie)`` 的 cookie 实际参与请求(搜索 / getSongUrl / newget / 歌单页),
这里从 auth_store 的 ``5sing`` 记录读取(无则匿名)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
UserAgent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
)
#: 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302)
MOBILE_API = "http://mobileapi.5sing.kugou.com"
#: 搜索接口(type=0 单曲 / type=1 歌单)
SEARCH_API = "http://search.5sing.kugou.com/home/json"
#: 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏)
SONG_KINDS: dict[str, str] = {"yc": "原创", "fc": "翻唱", "bz": "伴奏"}
HTML_ENTITIES: dict[str, str] = {
"amp": "&",
"lt": "<",
"gt": ">",
"quot": '"',
"apos": "'",
"nbsp": " ",
"copy": "©",
"reg": "®",
"hellip": "…",
"mdash": "—",
"ndash": "–",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def get_first_valid(*urls: Any) -> str:
"""上游 getFirstValid:返回第一个非空字符串(空串视为无效)。"""
for url in urls:
if url:
return str(url)
return ""
def remove_em_tags(text: Any) -> str:
"""去掉搜索结果里 ``<em class="keyword">`` 高亮标签。"""
return str(text if text is not None else "").replace('<em class="keyword">', "").replace("</em>", "").strip()
def unescape_html(text: Any) -> str:
"""Go html.UnescapeString 的等价实现(常用实体 + 数字实体)。"""
source = str(text if text is not None else "")
def repl(match: re.Match[str]) -> str:
entity = match.group(1)
try:
if entity.startswith(("#x", "#X")):
return chr(int(entity[2:], 16))
if entity.startswith("#"):
return chr(int(entity[1:], 10))
except ValueError:
return match.group(0)
return HTML_ENTITIES.get(entity.lower(), match.group(0))
return re.sub(r"&(#x?[0-9a-fA-F]+|[a-zA-Z]+);", repl, source)
def get_5sing_cookie() -> str:
"""上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用)。"""
cred = get_credentials("5sing") or {}
return str(cred.get("cookie") or "")
def get_status() -> dict[str, Any]:
"""登录态(凭据存在性判断)。"""
cred = get_credentials("5sing") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
def is_foreign_source(song_info: dict[str, Any]) -> bool:
"""是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查)。"""
source = song_info.get("source")
return bool(source) and source != "5sing"
async def fetch_5sing(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""带 Cookie/UA 的请求;httpFetch 不跟随 302,这里手动跟跳最多 3 次。
5sing 老链接会 http→https / 数字 userId → 个性域名跳转,必须跟。
"""
cookie = get_5sing_cookie()
all_headers = {"User-Agent": UserAgent}
if cookie:
all_headers["Cookie"] = cookie
if headers:
all_headers.update({str(key): str(value) for key, value in headers.items()})
current = str(url)
response = None
for _hop in range(4):
response = await http_fetch(current, headers=all_headers, timeout=timeout_ms)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
from urllib.parse import urljoin
try:
current = urljoin(current, location)
except ValueError:
return response
continue
return response
return response
async def fetch_5sing_json(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""JSON 接口带退避重试(手机端 API 频率控制:code 13 / "操作太频繁")。"""
response = None
for attempt in range(3):
response = await fetch_5sing(url, headers=headers, timeout_ms=timeout_ms)
body = response.body if isinstance(response.body, dict) else {}
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
message = str(body.get("message") or "")
if (code == 13 or "太频繁" in message) and attempt < 2:
await asyncio.sleep(0.8 * (attempt + 1))
continue
return response
return response
def build_song_link(song_type: str, song_id: Any) -> str:
"""歌曲页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{song_type}/{song_id}.html" if song_type and song_id else ""
def build_playlist_link(user_id: Any, playlist_id: str) -> str:
"""歌单页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{user_id}/dj/{playlist_id}.html" if user_id and playlist_id else ""
def extract_song_id(songmid: Any, song_type_fallback: Any = None) -> dict[str, str]:
"""解析歌曲的 ``<songid>|<songtype>`` 约定(兼容只给 songType 字段的调用)。"""
raw = str(songmid if songmid is not None else "")
parts = raw.split("|")
id_part = parts[0] if parts else ""
type_part = parts[1] if len(parts) > 1 else ""
return {"songID": id_part or "", "songType": str(type_part or song_type_fallback or "")}
def extract_song_link(link: Any) -> dict[str, str] | None:
"""上游 Parse 的正则:5sing.kugou.com/(\\w+)/(\\d+).html(专辑/歌手页不匹配)。"""
match = re.search(r"5sing\.kugou\.com/(\w+)/(\d+)\.html", str(link if link is not None else ""))
if not match:
return None
return {"songType": match.group(1), "songID": match.group(2)}
def extract_playlist_link(link: Any) -> dict[str, str] | None:
"""歌单链接解析(放宽为任意单段 userId,含个性域名与省略 user 段)。"""
match = re.search(
r"5sing\.kugou\.com/(?:([^/?#]+)/)?dj/([a-zA-Z0-9]+)\.html", str(link if link is not None else "")
)
if not match:
return None
return {"userId": match.group(1) or "", "playlistId": match.group(2)}
def estimate_duration(song_size: Any) -> int:
"""上游 Search 的时长估算:songSize*8/320000(320kbps 假设;0 返回 0)。"""
try:
size = float(song_size or 0)
except (TypeError, ValueError):
size = 0.0
return math.trunc((size * 8) / 320000) if size > 0 else 0
def kind_name(song_type: str) -> str:
"""作品类型中文名(typeName 缺失时兜底)。"""
return SONG_KINDS.get(song_type, "")