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:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,134 @@
|
||||
"""Apple Music 平台模块(对应 sdk/apple/index.ts,上游 music-lib/apple/apple.go 的 Node/TS 移植)。
|
||||
|
||||
能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到)/ 封面 /
|
||||
专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
|
||||
链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 apis/apple.py)。
|
||||
|
||||
凭据:auth_store 的 apple 记录,cookie 形如
|
||||
``"media-user-token=xxx;storefront=cn;token=xxx"``,也支持 storefront/mediaUserToken/token 直填字段。
|
||||
未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from app.sdk.auth_store import get_credentials
|
||||
from app.sdk.platforms._base import Platform
|
||||
from app.sdk.platforms.apple import album, lyric, music_search, parse, playlist
|
||||
from app.sdk.platforms.apple.util import (
|
||||
apple_artwork_url,
|
||||
apple_song_from_catalog,
|
||||
apple_web_url,
|
||||
extract_apple_id,
|
||||
fetch_apple_song_resource,
|
||||
get_apple_config,
|
||||
get_apple_token,
|
||||
)
|
||||
|
||||
|
||||
def _song_id_of(song_info: dict[str, Any]) -> str:
|
||||
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
|
||||
|
||||
|
||||
async def _init() -> bool:
|
||||
"""预热 developer token(失败不阻塞启动,路由调用时会重试)。"""
|
||||
try:
|
||||
await get_apple_token()
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - token 预热失败不影响后续请求的重试
|
||||
return False
|
||||
|
||||
|
||||
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
from app.sdk.apis.apple import get_music_url
|
||||
|
||||
return await get_music_url(song_info, quality, retry_num)
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
return await lyric.get_lyric(song_info)
|
||||
|
||||
|
||||
async def get_pic(song_info: dict[str, Any]) -> str | None:
|
||||
"""封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort)。"""
|
||||
if song_info.get("img"):
|
||||
return str(song_info["img"])
|
||||
song_id = _song_id_of(song_info)
|
||||
if not song_id:
|
||||
return None
|
||||
try:
|
||||
res = await fetch_apple_song_resource(song_id)
|
||||
if not res:
|
||||
return None
|
||||
return apple_artwork_url(res.get("attributes", {}).get("artwork"), 600) or None
|
||||
except Exception: # noqa: BLE001 - 封面取不到返回 None
|
||||
return None
|
||||
|
||||
|
||||
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""歌曲详情(best-effort;等价上游 Parse(id))。"""
|
||||
song_id = _song_id_of(song_info)
|
||||
if not song_id:
|
||||
return None
|
||||
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
|
||||
return apple_song_from_catalog(res) if res else None
|
||||
|
||||
|
||||
class _AppleAlbum:
|
||||
"""契约 album 能力位(含 TS index.ts 里挂的 parseAlbumLink)。"""
|
||||
|
||||
get_album_detail = staticmethod(album.get_album_detail)
|
||||
get_album_songs = staticmethod(album.get_album_songs)
|
||||
search_album = staticmethod(album.search_album)
|
||||
parse_album_link = staticmethod(album.parse_album_link)
|
||||
|
||||
|
||||
class _AppleSongList:
|
||||
"""契约别名 songList:Apple 的歌单分类即 genre curators,供 /song-list/apple 复用。"""
|
||||
|
||||
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "热门", "id": "1526756058"}]
|
||||
|
||||
@staticmethod
|
||||
async def get_tags() -> dict[str, Any]:
|
||||
return await playlist.get_categories()
|
||||
|
||||
@staticmethod
|
||||
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
cate = str(tag_id or sort_id or "").strip()
|
||||
return await playlist.get_category_playlists(cate, page, limit)
|
||||
|
||||
|
||||
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
|
||||
if song_info.get("link"):
|
||||
return str(song_info["link"])
|
||||
song_id = _song_id_of(song_info)
|
||||
if not song_id:
|
||||
return "https://music.apple.com/"
|
||||
return apple_web_url("song", song_id, get_apple_config()["storefront"])
|
||||
|
||||
|
||||
def status() -> dict[str, Any]:
|
||||
"""登录状态(media-user-token 存在性判断;无在线校验接口)。"""
|
||||
cred = get_credentials("apple") or {}
|
||||
cfg = get_apple_config()
|
||||
if cfg["mediaUserToken"] or cred.get("cookie"):
|
||||
return {"logged": True, "updatedAt": cred.get("updatedAt")}
|
||||
return {"logged": False}
|
||||
|
||||
|
||||
MODULE = Platform(
|
||||
id="apple",
|
||||
name="Apple Music",
|
||||
init=_init,
|
||||
music_search=music_search,
|
||||
album=_AppleAlbum,
|
||||
playlist=playlist,
|
||||
song_list=_AppleSongList,
|
||||
parse=parse,
|
||||
get_music_url=_get_music_url,
|
||||
get_lyric=get_lyric,
|
||||
get_pic=get_pic,
|
||||
get_music_info=get_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。
|
||||
|
||||
详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
|
||||
→ data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
|
||||
链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import as_dict, as_list
|
||||
from app.sdk.platforms.apple.music_search import search_album
|
||||
from app.sdk.platforms.apple.util import (
|
||||
apple_album_to_playlist,
|
||||
apple_amp_get,
|
||||
apple_song_from_catalog,
|
||||
extract_apple_id,
|
||||
fetch_all_track_resources,
|
||||
get_apple_config,
|
||||
)
|
||||
|
||||
|
||||
async def get_album_detail(raw_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
|
||||
"""专辑详情(page/limit 在完整曲目上切片,与其它平台 get_album_detail 契约一致)。"""
|
||||
album_id = extract_apple_id(str(raw_id or ""), "album")
|
||||
if not album_id:
|
||||
raise RuntimeError(f"invalid apple music album id/link: {raw_id}")
|
||||
|
||||
cfg = get_apple_config()
|
||||
body = as_dict(
|
||||
await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/albums/{album_id}", {"extend": "extendedAssetUrls"})
|
||||
)
|
||||
data = as_list(body.get("data"))
|
||||
album = as_dict(data[0]) if data else {}
|
||||
if not album:
|
||||
raise RuntimeError(f"apple album not found: {album_id}")
|
||||
|
||||
tracks = await fetch_all_track_resources(as_dict(album.get("relationships")).get("tracks"))
|
||||
songs = [apple_song_from_catalog(track, album_id) for track in tracks]
|
||||
|
||||
p = max(1, int(page or 1))
|
||||
size = max(1, int(limit or 50))
|
||||
summary = apple_album_to_playlist(album)
|
||||
# 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
|
||||
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
|
||||
summary["trackCount"] = len(songs)
|
||||
|
||||
return {
|
||||
"source": "apple",
|
||||
"info": {
|
||||
"id": album_id,
|
||||
"name": summary["name"],
|
||||
"img": summary["cover"],
|
||||
"desc": summary["description"],
|
||||
"author": summary["creator"],
|
||||
"link": summary["link"],
|
||||
"trackCount": summary["trackCount"],
|
||||
"releaseDate": summary["extra"]["release_date"],
|
||||
"recordLabel": summary["extra"]["record_label"],
|
||||
},
|
||||
"list": songs[(p - 1) * size : (p - 1) * size + size],
|
||||
"page": p,
|
||||
"limit": size,
|
||||
"total": len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
|
||||
"""上游 GetAlbumSongs:只返回曲目数组。"""
|
||||
detail = await get_album_detail(album_id, 1, 100000)
|
||||
return detail["list"]
|
||||
|
||||
|
||||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||||
"""上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页)。"""
|
||||
detail = await get_album_detail(link, 1, 100000)
|
||||
return {
|
||||
"source": "apple",
|
||||
"id": detail["info"]["id"],
|
||||
"info": detail["info"],
|
||||
"list": detail["list"],
|
||||
"page": 1,
|
||||
"limit": max(detail["total"], 1),
|
||||
"total": detail["total"],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["get_album_detail", "get_album_songs", "parse_album_link", "search_album"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 apple.go GetLyrics)。
|
||||
|
||||
通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
|
||||
→ data[0].relationships.lyrics.data[*].attributes.ttml
|
||||
上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
|
||||
|
||||
注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
|
||||
(即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词需要 media-user-token(订阅态);
|
||||
本实现保持上游行为:拿不到就返回 ``{lyric: '', tlyric: ''}``,不抛错。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import as_dict, as_list
|
||||
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
|
||||
if not song_id:
|
||||
raise RuntimeError("apple 歌曲 id 无效")
|
||||
cfg = get_apple_config()
|
||||
body = as_dict(
|
||||
await apple_amp_get_tracked(
|
||||
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
|
||||
{"include": "lyrics", "extend": "extendedAssetUrls"},
|
||||
)
|
||||
)
|
||||
data = as_list(body.get("data"))
|
||||
first = as_dict(data[0]) if data else {}
|
||||
relationships = as_dict(first.get("relationships"))
|
||||
lyrics = as_list(as_dict(relationships.get("lyrics")).get("data"))
|
||||
for rel in lyrics:
|
||||
text = str(as_dict(as_dict(rel).get("attributes")).get("ttml") or "")
|
||||
if text:
|
||||
return {"lyric": text, "tlyric": ""}
|
||||
return {"lyric": "", "tlyric": ""}
|
||||
|
||||
|
||||
async def get_lyrics_text(song_id: str) -> str:
|
||||
"""非取消句柄版:直接返回歌词文本(内部/测试用)。"""
|
||||
result = await get_lyric({"songmid": song_id})
|
||||
return result.get("lyric") or ""
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。
|
||||
|
||||
通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.py)
|
||||
单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
|
||||
因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import as_dict, as_list
|
||||
from app.sdk.platforms.apple.util import (
|
||||
apple_album_to_playlist,
|
||||
apple_amp_get,
|
||||
apple_playlist_to_playlist,
|
||||
apple_song_from_catalog,
|
||||
get_apple_config,
|
||||
)
|
||||
|
||||
#: amp-api search 单页硬上限
|
||||
SEARCH_PAGE_MAX = 25
|
||||
|
||||
|
||||
async def _catalog_search(keyword: str, types: str, page: int, limit: int) -> dict[str, Any]:
|
||||
word = str(keyword or "").strip()
|
||||
p = max(1, int(page or 1))
|
||||
size = min(max(1, int(limit or 20)), 100)
|
||||
if not word:
|
||||
return {"body": None, "page": p, "limit": size}
|
||||
cfg = get_apple_config()
|
||||
body = await apple_amp_get(
|
||||
f"/v1/catalog/{cfg['storefront']}/search",
|
||||
{
|
||||
"term": word,
|
||||
"types": types,
|
||||
"limit": min(size, SEARCH_PAGE_MAX),
|
||||
"offset": (p - 1) * size,
|
||||
},
|
||||
)
|
||||
return {"body": body, "page": p, "limit": size}
|
||||
|
||||
|
||||
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
|
||||
del retry_num # 契约参数,amp-api 无重试语义
|
||||
result = await _catalog_search(keyword, "songs", page, limit)
|
||||
p, size = result["page"], result["limit"]
|
||||
results = as_dict(as_dict(result["body"]).get("results"))
|
||||
bucket = as_dict(results.get("songs"))
|
||||
songs = [apple_song_from_catalog(item) for item in as_list(bucket.get("data"))]
|
||||
has_next = bool(bucket.get("next"))
|
||||
return {
|
||||
"list": songs,
|
||||
"allPage": p + 1 if has_next else p,
|
||||
"total": (p - 1) * size + len(songs),
|
||||
"limit": size,
|
||||
"source": "apple",
|
||||
}
|
||||
|
||||
|
||||
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""上游 SearchAlbum:搜索专辑(返回歌单摘要形状)。"""
|
||||
result = await _catalog_search(keyword, "albums", page, limit)
|
||||
p, size = result["page"], result["limit"]
|
||||
results = as_dict(as_dict(result["body"]).get("results"))
|
||||
items = as_list(as_dict(results.get("albums")).get("data"))
|
||||
songs = [apple_album_to_playlist(item) for item in items]
|
||||
return {
|
||||
"list": songs,
|
||||
"total": (p - 1) * size + len(songs),
|
||||
"page": p,
|
||||
"limit": size,
|
||||
"source": "apple",
|
||||
}
|
||||
|
||||
|
||||
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""上游 SearchPlaylist:搜索歌单。"""
|
||||
result = await _catalog_search(keyword, "playlists", page, limit)
|
||||
p, size = result["page"], result["limit"]
|
||||
results = as_dict(as_dict(result["body"]).get("results"))
|
||||
items = as_list(as_dict(results.get("playlists")).get("data"))
|
||||
playlists = [apple_playlist_to_playlist(item) for item in items]
|
||||
return {
|
||||
"list": playlists,
|
||||
"total": (p - 1) * size + len(playlists),
|
||||
"page": p,
|
||||
"limit": size,
|
||||
"source": "apple",
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 apple.go Parse / ParseAlbum / ParsePlaylist)。
|
||||
|
||||
song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
|
||||
album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
|
||||
playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
|
||||
上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.apple.album import parse_album_link
|
||||
from app.sdk.platforms.apple.playlist import parse_playlist_link
|
||||
from app.sdk.platforms.apple.util import apple_song_from_catalog, extract_apple_id, fetch_apple_song_resource
|
||||
|
||||
|
||||
async def parse_song(link: str) -> dict[str, Any]:
|
||||
"""上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链)。"""
|
||||
song_id = extract_apple_id(str(link or ""), "song")
|
||||
if not song_id:
|
||||
raise RuntimeError(f"invalid apple music song link: {link}")
|
||||
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
|
||||
if not res:
|
||||
raise RuntimeError(f"apple song not found: {song_id}")
|
||||
return apple_song_from_catalog(res)
|
||||
|
||||
|
||||
async def parse_album(link: str) -> dict[str, Any]:
|
||||
return await parse_album_link(link)
|
||||
|
||||
|
||||
async def parse_playlist(link: str) -> dict[str, Any]:
|
||||
return await parse_playlist_link(link)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。
|
||||
|
||||
详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
|
||||
曲目超过一页时沿 relationships.tracks.next 全量拉取(带 20 页上限防死循环)。
|
||||
分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
|
||||
GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
|
||||
链接:extract_apple_id(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import as_dict, as_list
|
||||
from app.sdk.platforms.apple.music_search import search_playlist
|
||||
from app.sdk.platforms.apple.util import (
|
||||
apple_amp_get,
|
||||
apple_playlist_to_playlist,
|
||||
apple_song_from_catalog,
|
||||
extract_apple_id,
|
||||
fetch_all_track_resources,
|
||||
get_apple_config,
|
||||
)
|
||||
|
||||
#: 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators)
|
||||
APPLE_CURATOR_CATEGORIES: list[dict[str, str]] = [
|
||||
{"id": "1526756058", "name": "热门"},
|
||||
{"id": "1479949880", "name": "C-Pop"},
|
||||
{"id": "1019400042", "name": "国语流行"},
|
||||
{"id": "1019398918", "name": "粤语流行"},
|
||||
{"id": "1019399540", "name": "国际流行"},
|
||||
{"id": "1019399551", "name": "K-Pop"},
|
||||
{"id": "1019399547", "name": "J-Pop"},
|
||||
{"id": "989061415", "name": "嘻哈/说唱"},
|
||||
{"id": "1019400044", "name": "R&B"},
|
||||
{"id": "1019400046", "name": "摇滚"},
|
||||
{"id": "1019397973", "name": "另类音乐"},
|
||||
{"id": "976439535", "name": "舞曲"},
|
||||
{"id": "976439536", "name": "电子"},
|
||||
{"id": "976439541", "name": "独立音乐"},
|
||||
{"id": "1019399549", "name": "爵士乐"},
|
||||
{"id": "1019398924", "name": "古典音乐"},
|
||||
{"id": "976439528", "name": "蓝调"},
|
||||
{"id": "976439534", "name": "乡村音乐"},
|
||||
{"id": "1531542847", "name": "拉丁音乐"},
|
||||
{"id": "988656348", "name": "非洲音乐"},
|
||||
{"id": "976439543", "name": "金属乐"},
|
||||
{"id": "976439550", "name": "朋克乐"},
|
||||
{"id": "1019400049", "name": "不插电"},
|
||||
{"id": "1231181168", "name": "影视原声"},
|
||||
{"id": "1441811365", "name": "DJ 混音精选"},
|
||||
{"id": "1532467784", "name": "瞩目之星"},
|
||||
{"id": "1554938339", "name": "年代之声"},
|
||||
{"id": "1564180390", "name": "空间音频"},
|
||||
{"id": "989010186", "name": "亲子"},
|
||||
]
|
||||
|
||||
|
||||
async def get_categories() -> dict[str, Any]:
|
||||
"""上游 GetPlaylistCategories:静态返回 Apple Music genre curators。"""
|
||||
return {
|
||||
"source": "apple",
|
||||
"list": [
|
||||
{"id": item["id"], "name": item["name"], "group": "Apple Music 分类", "source": "apple"}
|
||||
for item in APPLE_CURATOR_CATEGORIES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐)。"""
|
||||
cate = str(category_id or "").strip()
|
||||
if not cate:
|
||||
raise RuntimeError("apple 分类 id 不能为空")
|
||||
p = max(1, int(page or 1))
|
||||
size = min(max(1, int(limit or 20)), 300)
|
||||
api_max = 25
|
||||
offset = (p - 1) * size
|
||||
all_playlists: list[dict[str, Any]] = []
|
||||
|
||||
while len(all_playlists) < size:
|
||||
batch_size = min(api_max, size - len(all_playlists))
|
||||
try:
|
||||
body = as_dict(
|
||||
await apple_amp_get(
|
||||
f"/v1/catalog/cn/apple-curators/{cate}/playlists",
|
||||
{"limit": batch_size, "offset": offset, "l": "zh-Hans-CN"},
|
||||
)
|
||||
)
|
||||
except Exception: # 上游:已有数据时返回已取部分
|
||||
if all_playlists:
|
||||
break
|
||||
raise
|
||||
data = as_list(body.get("data"))
|
||||
all_playlists.extend(apple_playlist_to_playlist(item) for item in data)
|
||||
# 没有更多数据或没有下一页
|
||||
if len(data) < batch_size or not body.get("next"):
|
||||
break
|
||||
offset += len(data)
|
||||
|
||||
return {"source": "apple", "list": all_playlists[:size], "page": p, "limit": size, "total": len(all_playlists)}
|
||||
|
||||
|
||||
async def get_playlist_detail(raw_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
"""歌单详情(全量曲目后按 page/limit 切片)。"""
|
||||
playlist_id = extract_apple_id(str(raw_id or ""), "playlist")
|
||||
if not playlist_id:
|
||||
raise RuntimeError(f"invalid apple music playlist id/link: {raw_id}")
|
||||
|
||||
cfg = get_apple_config()
|
||||
body = as_dict(
|
||||
await apple_amp_get(
|
||||
f"/v1/catalog/{cfg['storefront']}/playlists/{playlist_id}",
|
||||
{"limit[tracks]": 300, "extend": "extendedAssetUrls"},
|
||||
)
|
||||
)
|
||||
data = as_list(body.get("data"))
|
||||
item = as_dict(data[0]) if data else {}
|
||||
if not item:
|
||||
raise RuntimeError(f"apple playlist not found: {playlist_id}")
|
||||
|
||||
tracks = await fetch_all_track_resources(as_dict(item.get("relationships")).get("tracks"))
|
||||
songs = [apple_song_from_catalog(track) for track in tracks]
|
||||
|
||||
p = max(1, int(page or 1))
|
||||
size = max(1, int(limit or 100))
|
||||
summary = apple_playlist_to_playlist(item)
|
||||
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
|
||||
summary["trackCount"] = len(songs)
|
||||
|
||||
return {
|
||||
"source": "apple",
|
||||
"info": {
|
||||
"id": playlist_id,
|
||||
"name": summary["name"],
|
||||
"img": summary["cover"],
|
||||
"desc": summary["description"],
|
||||
"author": summary["creator"],
|
||||
"link": summary["link"],
|
||||
"trackCount": summary["trackCount"],
|
||||
},
|
||||
"list": songs[(p - 1) * size : (p - 1) * size + size],
|
||||
"page": p,
|
||||
"limit": size,
|
||||
"total": len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
|
||||
"""上游 GetPlaylistSongs:只返回曲目数组。"""
|
||||
detail = await get_playlist_detail(playlist_id, 1, 100000)
|
||||
return detail["list"]
|
||||
|
||||
|
||||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||||
"""上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页)。"""
|
||||
detail = await get_playlist_detail(link, 1, 100000)
|
||||
return {
|
||||
"source": "apple",
|
||||
"id": detail["info"]["id"],
|
||||
"info": detail["info"],
|
||||
"list": detail["list"],
|
||||
"page": 1,
|
||||
"limit": max(detail["total"], 1),
|
||||
"total": detail["total"],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APPLE_CURATOR_CATEGORIES",
|
||||
"get_categories",
|
||||
"get_category_playlists",
|
||||
"get_playlist_detail",
|
||||
"get_playlist_songs",
|
||||
"parse_playlist_link",
|
||||
"search_playlist",
|
||||
]
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Apple Music 公共工具(对应 sdk/apple/util.ts,上游 music-lib/apple/apple.go 的移植基础层)。
|
||||
|
||||
* ``New(cookie)`` → auth_store 的 apple 记录:cookie 支持
|
||||
``"media-user-token=xxx;storefront=cn;token=xxx"`` 形态。
|
||||
* ``fetch_apple_token``:从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)。
|
||||
当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi 开头而非上游正则写的 eyJh,
|
||||
故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取。
|
||||
* amp 请求(httpFetch 不自动跟 302,首页跳转用 fetch_follow 手动跟)。
|
||||
* 模型转换与 ID 提取(字段映射同上游,apple_test.go 的断言逐条保留)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit
|
||||
|
||||
from app.sdk.auth_store import get_credentials
|
||||
from app.sdk.common.request import HttpResponse, http_fetch
|
||||
from app.sdk.common.utils import as_dict, as_list, format_play_time
|
||||
|
||||
APPLE_HOMEPAGE_URL = "https://music.apple.com"
|
||||
APPLE_AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
DEFAULT_STOREFRONT = "us"
|
||||
APPLE_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
#: JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
|
||||
TOKEN_CACHE_TTL_MS = 12 * 60 * 60 * 1000
|
||||
|
||||
_cached_token = ""
|
||||
_cached_at = 0.0
|
||||
#: 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
|
||||
_bypass_explicit_token = False
|
||||
_token_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def parse_cookie_parts(cookie: str) -> dict[str, str]:
|
||||
"""上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白。"""
|
||||
result: dict[str, str] = {}
|
||||
for part in str(cookie or "").split(";"):
|
||||
trimmed = part.strip()
|
||||
idx = trimmed.find("=")
|
||||
if idx > 0:
|
||||
result[trimmed[:idx].strip()] = trimmed[idx + 1 :].strip()
|
||||
return result
|
||||
|
||||
|
||||
def get_apple_config() -> dict[str, str]:
|
||||
"""读取 apple 平台运行时配置(对应上游 New(cookie))。"""
|
||||
cred = get_credentials("apple") or {}
|
||||
parts = parse_cookie_parts(str(cred.get("cookie") or ""))
|
||||
storefront = str(cred.get("storefront") or parts.get("storefront") or DEFAULT_STOREFRONT).strip()
|
||||
return {
|
||||
"storefront": storefront or DEFAULT_STOREFRONT,
|
||||
"mediaUserToken": str(cred.get("mediaUserToken") or parts.get("media-user-token") or "").strip(),
|
||||
"token": str(cred.get("token") or parts.get("token") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def decode_apple_token_payload(token: str) -> dict[str, Any]:
|
||||
"""解码 JWT payload(Apple token 为 ES256 JWT)。"""
|
||||
try:
|
||||
part = str(token or "").split(".")[1]
|
||||
padded = part.replace("-", "+").replace("_", "/")
|
||||
padded += "=" * (-len(padded) % 4)
|
||||
data = json.loads(base64.b64decode(padded).decode("utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (IndexError, ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def is_apple_token_expired(token: str) -> bool:
|
||||
try:
|
||||
exp = float(decode_apple_token_payload(token).get("exp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
exp = 0
|
||||
return exp > 0 and exp * 1000 < time.time() * 1000 + 60 * 1000
|
||||
|
||||
|
||||
def extract_apple_tokens(js: str) -> list[str]:
|
||||
"""从 index JS 中提取所有 JWT 形态的候选 token(含引号前缀的主匹配 + 无前缀回退)。"""
|
||||
pattern = r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
|
||||
found: list[str] = []
|
||||
found.extend(m.group(1) for m in re.finditer(rf"""(?:=["']){pattern}""", str(js or "")))
|
||||
if not found:
|
||||
found.extend(m.group(1) for m in re.finditer(pattern, str(js or "")))
|
||||
# 去重且保持顺序
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def pick_apple_token(js: str) -> str:
|
||||
"""选择可用 token:优先 issuer=AMPWebPlay,其次第一个未过期;全部过期视为抓取失败。"""
|
||||
tokens = extract_apple_tokens(js)
|
||||
if not tokens:
|
||||
return ""
|
||||
alive = [token for token in tokens if not is_apple_token_expired(token)]
|
||||
if not alive:
|
||||
return ""
|
||||
preferred = next(
|
||||
(token for token in alive if str(decode_apple_token_payload(token).get("iss") or "") == "AMPWebPlay"), None
|
||||
)
|
||||
return preferred or alive[0] or ""
|
||||
|
||||
|
||||
async def fetch_follow(url: str, headers: dict[str, str] | None = None, max_hops: int = 5) -> HttpResponse:
|
||||
"""手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳)。"""
|
||||
current = str(url)
|
||||
response = None
|
||||
for _hop in range(max_hops):
|
||||
response = await http_fetch(current, headers=headers or {}, timeout=20_000)
|
||||
status = response.status_code or 0
|
||||
location = response.headers.get("location", "")
|
||||
if 300 <= status < 400 and location:
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
return response
|
||||
raise RuntimeError("apple: too many redirects")
|
||||
|
||||
|
||||
def _response_text(response: HttpResponse) -> str:
|
||||
if isinstance(response.body, str):
|
||||
return response.body
|
||||
if response.raw:
|
||||
return response.raw.decode("utf-8", errors="replace")
|
||||
return str(response.body or "")
|
||||
|
||||
|
||||
async def fetch_apple_token() -> str:
|
||||
"""上游 fetchAppleToken:首页 → index-legacy JS → bearer token。"""
|
||||
home = await fetch_follow(APPLE_HOMEPAGE_URL, {"User-Agent": APPLE_UA})
|
||||
if home.status_code != 200:
|
||||
raise RuntimeError(f"apple: fetch homepage error: HTTP {home.status_code}")
|
||||
html = _response_text(home)
|
||||
|
||||
# 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
|
||||
match = re.search(r"/(assets/index-legacy[~-][^/\"']+\.js)", html)
|
||||
if not match:
|
||||
match = re.search(r"/(assets/index[~-][^/\"']+\.js)", html)
|
||||
if not match:
|
||||
raise RuntimeError("apple: index.js URI not found")
|
||||
|
||||
js_res = await fetch_follow(f"{APPLE_HOMEPAGE_URL}/{match.group(1)}", {"User-Agent": APPLE_UA})
|
||||
if js_res.status_code != 200:
|
||||
raise RuntimeError(f"apple: fetch index.js error: HTTP {js_res.status_code}")
|
||||
token = pick_apple_token(_response_text(js_res))
|
||||
if not token:
|
||||
raise RuntimeError("apple: token not found in index.js")
|
||||
return token
|
||||
|
||||
|
||||
async def get_apple_token(force: bool = False) -> str:
|
||||
"""获取 developer token(缓存 + JWT exp 校验 + 并发去重)。"""
|
||||
global _cached_token, _cached_at
|
||||
cfg = get_apple_config()
|
||||
if not force and not _bypass_explicit_token and cfg["token"]:
|
||||
return cfg["token"]
|
||||
if (
|
||||
not force
|
||||
and _cached_token
|
||||
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
|
||||
and not is_apple_token_expired(_cached_token)
|
||||
):
|
||||
return _cached_token
|
||||
async with _token_lock:
|
||||
# 双检:并发调用者拿到锁后直接复用前一次抓取结果
|
||||
cfg = get_apple_config()
|
||||
if not force and not _bypass_explicit_token and cfg["token"]:
|
||||
return cfg["token"]
|
||||
if (
|
||||
not force
|
||||
and _cached_token
|
||||
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
|
||||
and not is_apple_token_expired(_cached_token)
|
||||
):
|
||||
return _cached_token
|
||||
token = await fetch_apple_token()
|
||||
_cached_token = token
|
||||
_cached_at = time.time() * 1000
|
||||
return token
|
||||
|
||||
|
||||
def invalidate_apple_token() -> None:
|
||||
"""清除缓存 token(401/403 时调用,下次请求会回源重取)。"""
|
||||
global _cached_token, _cached_at, _bypass_explicit_token
|
||||
_cached_token = ""
|
||||
_cached_at = 0.0
|
||||
_bypass_explicit_token = True
|
||||
|
||||
|
||||
def reset_apple_token_state() -> None:
|
||||
"""仅测试/调试用:重置内存态。"""
|
||||
global _cached_token, _cached_at, _bypass_explicit_token
|
||||
_cached_token = ""
|
||||
_cached_at = 0.0
|
||||
_bypass_explicit_token = False
|
||||
|
||||
|
||||
def apple_amp_headers(token: str) -> dict[str, str]:
|
||||
cfg = get_apple_config()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Origin": APPLE_HOMEPAGE_URL,
|
||||
"Referer": f"{APPLE_HOMEPAGE_URL}/",
|
||||
"User-Agent": APPLE_UA,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
|
||||
if cfg["mediaUserToken"]:
|
||||
headers["Cookie"] = f"media-user-token={cfg['mediaUserToken']}"
|
||||
return headers
|
||||
|
||||
|
||||
def build_apple_amp_url(uri: str, params: dict[str, Any] | None = None) -> str:
|
||||
"""拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL。"""
|
||||
raw = str(uri or "")
|
||||
if re.match(r"^https?://", raw, re.IGNORECASE):
|
||||
base = raw
|
||||
elif raw.startswith("/"):
|
||||
base = APPLE_AMP_API_URL + raw
|
||||
else:
|
||||
base = f"{APPLE_AMP_API_URL}/{raw}"
|
||||
pairs = [(key, str(value)) for key, value in (params or {}).items() if value is not None and value != ""]
|
||||
if not pairs:
|
||||
return base
|
||||
query = urlencode(pairs)
|
||||
return base + ("&" if "?" in base else "?") + query
|
||||
|
||||
|
||||
def _amp_error_detail(response: HttpResponse) -> str:
|
||||
body = response.body if isinstance(response.body, dict) else {}
|
||||
errors = as_list(body.get("errors"))
|
||||
first = as_dict(errors[0]) if errors else {}
|
||||
return str(first.get("detail") or first.get("title") or "")
|
||||
|
||||
|
||||
async def apple_amp_get_tracked(uri: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)。"""
|
||||
url = build_apple_amp_url(uri, params)
|
||||
|
||||
async def attempt() -> HttpResponse:
|
||||
token = await get_apple_token()
|
||||
return await http_fetch(url, headers=apple_amp_headers(token), timeout=20_000)
|
||||
|
||||
response = await attempt()
|
||||
if response.status_code in (401, 403):
|
||||
invalidate_apple_token()
|
||||
response = await attempt()
|
||||
if not (200 <= response.status_code < 300):
|
||||
detail = _amp_error_detail(response)
|
||||
raise RuntimeError(f"apple amp-api HTTP {response.status_code}{f': {detail}' if detail else ''}")
|
||||
return response.body
|
||||
|
||||
|
||||
async def apple_amp_get(uri: str, params: dict[str, Any] | None = None) -> Any:
|
||||
return await apple_amp_get_tracked(uri, params)
|
||||
|
||||
|
||||
#: 拉取 relationships.tracks 全部分页时的页数上限(防死循环)
|
||||
MAX_TRACK_PAGES = 20
|
||||
|
||||
|
||||
async def fetch_all_track_resources(tracks: Any) -> list[Any]:
|
||||
"""拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources)。"""
|
||||
tracks = as_dict(tracks)
|
||||
all_tracks: list[Any] = list(as_list(tracks.get("data")))
|
||||
next_uri = str(tracks.get("next") or "").strip()
|
||||
page = 0
|
||||
while next_uri and page < MAX_TRACK_PAGES:
|
||||
body = as_dict(await apple_amp_get_tracked(next_uri))
|
||||
all_tracks.extend(as_list(body.get("data")))
|
||||
next_uri = str(body.get("next") or "").strip()
|
||||
page += 1
|
||||
return all_tracks
|
||||
|
||||
|
||||
def apple_artwork_url(artwork: Any, size: int = 600) -> str:
|
||||
"""上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位。"""
|
||||
artwork = as_dict(artwork)
|
||||
url = str(artwork.get("url") or "")
|
||||
if not url:
|
||||
return ""
|
||||
return url.replace("{w}", str(size)).replace("{h}", str(size))
|
||||
|
||||
|
||||
def _round_number(value: Any) -> int:
|
||||
"""JS Math.round 的等价实现(.5 向上取整)。"""
|
||||
import math
|
||||
|
||||
try:
|
||||
number = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
number = 0.0
|
||||
return math.floor(number + 0.5)
|
||||
|
||||
|
||||
def apple_song_from_catalog(res: Any, default_album_id: str = "") -> dict[str, Any]:
|
||||
"""上游 appleSongFromCatalog → 契约 Song 形状。"""
|
||||
res = as_dict(res)
|
||||
attr = as_dict(res.get("attributes"))
|
||||
previews = as_list(attr.get("previews"))
|
||||
preview_url = str(as_dict(previews[0]).get("url") or "") if previews else ""
|
||||
duration_sec = _round_number(float(attr.get("durationInMillis") or 0) / 1000)
|
||||
return {
|
||||
"songmid": str(res.get("id") or ""),
|
||||
"name": attr.get("name") or "",
|
||||
"singer": attr.get("artistName") or "",
|
||||
"albumId": str(attr.get("albumId") or default_album_id or ""),
|
||||
"albumName": attr.get("albumName") or "",
|
||||
"interval": format_play_time(duration_sec),
|
||||
"img": apple_artwork_url(attr.get("artwork"), 600) or None,
|
||||
"lrc": None,
|
||||
"types": [],
|
||||
"_types": {},
|
||||
"source": "apple",
|
||||
"link": attr.get("url") or "", # music.apple.com 网页地址
|
||||
"previewUrl": preview_url, # 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
|
||||
"duration": duration_sec,
|
||||
"isrc": attr.get("isrc") or "",
|
||||
}
|
||||
|
||||
|
||||
def apple_album_to_playlist(res: Any) -> dict[str, Any]:
|
||||
"""上游 appleAlbumToPlaylist → 契约歌单/专辑摘要。"""
|
||||
res = as_dict(res)
|
||||
attr = as_dict(res.get("attributes"))
|
||||
notes = as_dict(attr.get("editorialNotes"))
|
||||
return {
|
||||
"id": str(res.get("id") or ""),
|
||||
"name": attr.get("name") or "",
|
||||
"cover": apple_artwork_url(attr.get("artwork"), 300),
|
||||
"trackCount": int(attr.get("trackCount") or 0),
|
||||
"creator": attr.get("artistName") or "",
|
||||
"description": notes.get("short") or "",
|
||||
"link": attr.get("url") or "",
|
||||
"source": "apple",
|
||||
"extra": {
|
||||
"release_date": attr.get("releaseDate") or "",
|
||||
"record_label": attr.get("recordLabel") or "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apple_playlist_to_playlist(res: Any) -> dict[str, Any]:
|
||||
"""上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游)。"""
|
||||
res = as_dict(res)
|
||||
attr = as_dict(res.get("attributes"))
|
||||
description = as_dict(attr.get("description"))
|
||||
notes = as_dict(attr.get("editorialNotes"))
|
||||
desc = description.get("short") or description.get("standard") or notes.get("short") or notes.get("standard") or ""
|
||||
return {
|
||||
"id": str(res.get("id") or ""),
|
||||
"name": attr.get("name") or "",
|
||||
"cover": apple_artwork_url(attr.get("artwork"), 300),
|
||||
"trackCount": int(attr.get("trackCount") or 0),
|
||||
"creator": attr.get("curatorName") or "",
|
||||
"description": desc,
|
||||
"link": attr.get("url") or "",
|
||||
"source": "apple",
|
||||
}
|
||||
|
||||
|
||||
def apple_web_url(kind: str, media_id: str, storefront: str = DEFAULT_STOREFRONT) -> str:
|
||||
sf = str(storefront or DEFAULT_STOREFRONT)
|
||||
return f"{APPLE_HOMEPAGE_URL}/{sf}/{kind}/{media_id}"
|
||||
|
||||
|
||||
def is_apple_raw_id(value: str, media_type: str) -> bool:
|
||||
"""上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字)。"""
|
||||
if re.search(r"[/?#]", value) or "://" in value:
|
||||
return False
|
||||
if media_type == "playlist":
|
||||
if value.startswith("pl."):
|
||||
return re.fullmatch(r"[a-zA-Z0-9._-]+", value) is not None
|
||||
return re.fullmatch(r"[a-zA-Z0-9_-]+", value) is not None
|
||||
return re.fullmatch(r"\d+", value) is not None
|
||||
|
||||
|
||||
def extract_apple_id_from_path(path: str, media_type: str) -> str:
|
||||
"""上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id>。"""
|
||||
trimmed = re.sub(r"^/+|/+$", "", str(path or ""))
|
||||
if not trimmed:
|
||||
return ""
|
||||
segments = trimmed.split("/")
|
||||
for index, segment in enumerate(segments):
|
||||
if segment != media_type and segment != f"{media_type}s":
|
||||
continue
|
||||
if index + 2 < len(segments):
|
||||
return segments[index + 2]
|
||||
if index + 1 < len(segments):
|
||||
return segments[index + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def extract_apple_id(link: str, media_type: str) -> str:
|
||||
"""上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底。"""
|
||||
raw = str(link or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if is_apple_raw_id(raw, media_type):
|
||||
return raw
|
||||
|
||||
path = ""
|
||||
query = ""
|
||||
try:
|
||||
parts = urlsplit(raw)
|
||||
if parts.scheme or parts.netloc:
|
||||
path = parts.path or ""
|
||||
query = f"?{parts.query}" if parts.query else ""
|
||||
except ValueError:
|
||||
path, query = "", ""
|
||||
if not path:
|
||||
idx = raw.find("?")
|
||||
path = raw[:idx] if idx >= 0 else raw
|
||||
query = raw[idx:] if idx >= 0 else ""
|
||||
|
||||
if path:
|
||||
if media_type == "song":
|
||||
values = parse_qs(query.lstrip("?"), keep_blank_values=True).get("i")
|
||||
song_id = values[0].strip() if values else ""
|
||||
if song_id:
|
||||
return song_id
|
||||
from_path = extract_apple_id_from_path(path, media_type)
|
||||
if from_path:
|
||||
return from_path
|
||||
|
||||
patterns = [
|
||||
rf"{media_type}/[^/]+/(\d+)",
|
||||
rf"{media_type}s?/[^/]+/[^/]+/(\d+)",
|
||||
r"/(\d+)$",
|
||||
r"[?&]i=(\d+)",
|
||||
]
|
||||
if media_type == "playlist":
|
||||
patterns.extend(
|
||||
[
|
||||
r"playlists?/[^/?#]+/([a-zA-Z0-9._-]+)",
|
||||
r"playlists?/([a-zA-Z0-9._-]+)(?:[?#]|$)",
|
||||
]
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, raw)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
async def fetch_apple_song_resource(song_id: str, include: str = "") -> Any:
|
||||
"""歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>)。"""
|
||||
cfg = get_apple_config()
|
||||
params: dict[str, Any] = {"extend": "extendedAssetUrls"}
|
||||
if include:
|
||||
params["include"] = include
|
||||
body = as_dict(await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/songs/{song_id}", params))
|
||||
data = as_list(body.get("data"))
|
||||
return data[0] if data else None
|
||||
|
||||
|
||||
def quote_path_segment(value: str) -> str:
|
||||
"""路径段转义(ID 基本是字母数字,保留以免未来出现特殊字符)。"""
|
||||
return quote(str(value), safe="._-")
|
||||
Reference in new issue
Block a user