// 5sing 歌单(上游 fivesing/fivesing.go + playlist.go 移植)
// 搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/pictureUrl/playCount/userName/songCnt/content/userId
// 上游对 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 解析
块(上游 parseSongsFromHTML)
// ③ 数字 userId 页会 302 到个性域名页(如 /yeluoluo/dj/...),httpFetch 不跟跳 → util.fetch5sing 手动跟跳。
// 分类/个人歌单:上游显式返回不支持(model.ErrPlaylistCategoriesUnsupported / ErrUserPlaylistsUnsupported),本移植保留同样语义(抛错)。
// 链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名(/name/dj/xxx.html)与省略 user 段,ID 以 API 反查为准。
import { MOBILE_API, SEARCH_API, fetch5sing, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, buildPlaylistLink, extractPlaylistLink, kindName } from './util.ts'
/** 上游 GetPlaylistCategories / GetCategoryPlaylists:显式不支持 */
export const getCategories = async() => {
throw new Error('playlist categories not supported')
}
export const getCategoryPlaylists = async(_categoryId?: any, _page = 1, _limit = 30) => {
throw new Error('playlist categories not supported')
}
/** 歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状 */
const mapPlaylistSong = (songID: string, kind: string, name: string, artist: string) => ({
songmid: `${songID}|${kind}`,
name,
singer: artist,
albumId: '',
albumName: '',
interval: 0,
img: null,
lrc: null,
types: [],
_types: {},
songType: kind,
typeName: kindName(kind),
link: buildSongLink(kind, songID),
source: '5sing' as const,
})
/**
* 上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
* 块正则 `([\s\S]*?)`;块内 href 提取 yc/fc/bz 与 id,s_soner 提取歌手(上游的拼写错误 soner 保留)。
*/
export const parseSongsFromHTML = (htmlContent: string) => {
const blocks = [...String(htmlContent ?? '').matchAll(/([\s\S]*?)<\/li>/g)]
if (!blocks.length) throw new Error('no songs found in playlist html (structure mismatch)')
// 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
const songRe = /href="(?:(?:https?:)?\/\/)?5sing\.kugou\.com\/(yc|fc|bz)\/(\d+)\.html"[^>]*>([^<]+)<\/a>/
const artistRe = /class="s_soner[^"]*".*?>([^<]+)<\/a>/
const songs: any[] = []
const seen = new Set()
for (const match of blocks) {
const blockHTML = match[1]
const songMatch = songRe.exec(blockHTML)
if (!songMatch) continue
const kind = songMatch[1]
const songID = songMatch[2]
const rawName = songMatch[3]
let artist = 'Unknown'
const artistMatch = artistRe.exec(blockHTML)
if (artistMatch) artist = artistMatch[1]
const uniqueKey = `${kind}|${songID}`
if (seen.has(uniqueKey)) continue
seen.add(uniqueKey)
const name = removeEmTags(unescapeHtml(rawName)).trim()
artist = removeEmTags(unescapeHtml(artist)).trim()
songs.push(mapPlaylistSong(songID, kind, name, artist))
}
return songs
}
/** 上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名(data 必须是对象) */
export const fetchCreatorName = async(playlistId: string): Promise => {
if (!playlistId) return ''
try {
const { statusCode, body } = await fetch5singJson(`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=user`)
if (statusCode != 200 || !body || typeof body !== 'object') return ''
const data = body.data
if (!data || typeof data !== 'object' || Array.isArray(data)) return ''
return String(data?.user?.NN ?? '')
} catch {
return ''
}
}
/** 歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐) */
const mapPlaylistSummary = (item: any) => {
const id = String(item?.songListId ?? '')
const userId = String(item?.userId ?? '')
const desc = removeEmTags(unescapeHtml(item?.content))
const creator = String(item?.userName ?? '') || (userId ? `ID: ${userId}` : '')
return {
id,
name: removeEmTags(unescapeHtml(item?.title)),
cover: String(item?.pictureUrl ?? ''),
trackCount: Number(item?.songCnt) || 0,
playCount: Number(item?.playCount) || 0,
creator,
creatorId: userId,
// 上游怪癖:content 为字符串 "0" 时视为空简介
description: desc === '0' ? '' : desc,
link: buildPlaylistLink(userId, id),
source: '5sing' as const,
}
}
/** 上游 SearchPlaylist(type=1),补创建者名(并发 5) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const }
if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1`
const { statusCode, body } = await fetch5singJson(url)
if (statusCode != 200) throw new Error(`5sing 歌单搜索失败(HTTP ${statusCode})`)
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
const list = rawList.slice(0, pageSize).map(mapPlaylistSummary)
// 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
const targets = list.filter((pl) => pl.creatorId && pl.creator === `ID: ${pl.creatorId}`)
for (let i = 0; i < targets.length; i += 5) {
await Promise.all(targets.slice(i, i + 5).map(async(pl) => {
const name = await fetchCreatorName(pl.id)
if (name) pl.creator = name
}))
}
const total = Number(body?.pageInfo?.totalCount) || list.length
return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const }
}
/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */
export const fetchPlaylistAll = async(playlistIdRaw: string) => {
const playlistId = String(playlistIdRaw ?? '').trim()
if (!playlistId) throw new Error('invalid 5sing playlist id')
// 1) API 元数据(标题/封面/统计/UserID)
const { statusCode, body } = await fetch5singJson(
`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=ID,user`,
)
if (statusCode != 200) throw new Error(`fetch info failed: HTTP ${statusCode}`)
// 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
const data = body?.data
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('playlist info not found or invalid (api returned empty list)')
}
const userId = String(data?.user?.ID ?? '')
if (!userId) throw new Error('playlist user not found or invalid id')
const playlist = {
id: playlistId,
name: String(data?.T ?? ''),
cover: String(data?.P ?? ''),
trackCount: Number(data?.E) || 0,
playCount: Number(data?.H) || 0,
creator: String(data?.user?.NN ?? ''),
description: String(data?.C ?? ''),
link: buildPlaylistLink(userId, playlistId),
userId,
source: '5sing' as const,
}
// 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳)
const page = await fetch5sing(playlist.link)
if (page.statusCode != 200) throw new Error(`fetch html failed: HTTP ${page.statusCode}`)
// 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
let songs: any[] = []
try {
songs = parseSongsFromHTML(String(page.body ?? ''))
} catch {
songs = []
}
return { playlist, songs }
}
/** 歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const { playlist, songs } = await fetchPlaylistAll(id)
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const start = (pageNo - 1) * pageSize
const list = songs.slice(start, start + pageSize)
const total = songs.length
return {
source: '5sing' as const,
info: {
id: playlist.id,
name: playlist.name,
img: playlist.cover,
desc: playlist.description,
author: playlist.creator,
link: playlist.link,
trackCount: playlist.trackCount,
playCount: playlist.playCount,
},
list,
page: pageNo,
limit: pageSize,
total,
allPage: Math.ceil(total / pageSize) || 1,
}
}
/** 上游 GetPlaylistSongs:仅返回曲目列表(全量) */
export const getPlaylistSongs = async(id: string) => {
const { songs } = await fetchPlaylistAll(id)
return songs
}
/** 上游 ParsePlaylist:歌单链接 → 详情(全量曲目,匹配上游返回全部歌曲的行为) */
export const parsePlaylistLink = async(link: string) => {
const parsed = extractPlaylistLink(link)
if (!parsed) throw new Error('invalid 5sing playlist link')
const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId)
return {
source: '5sing' as const,
info: {
id: playlist.id,
name: playlist.name,
img: playlist.cover,
desc: playlist.description,
author: playlist.creator,
link: playlist.link,
trackCount: playlist.trackCount,
playCount: playlist.playCount,
},
list: songs,
page: 1,
limit: songs.length || 1,
total: songs.length,
allPage: 1,
}
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
parsePlaylistLink,
}