feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力
四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse (tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。 - kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`), `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声; 统一还原成 `=` 再对外返回 - wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐 - kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单 为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化, 与新增平台的注册表改动分开,出问题时便于单独回滚。
This commit is contained in:
1 parent
33b96f92c4
commit
68e071b166
20 files changed
+2218
-33
No files matched your search
+152
@@ -0,0 +1,152 @@
|
||||
// 网易云专辑(上游 music-lib/netease/album.go + netease.go 的 fetchAlbumDetail 移植)
|
||||
// 搜索:cloudsearch pc type=10(eapi 通道,与现有 songList.search 同一通路)
|
||||
// 详情/曲目:weapi/v1/album/<id>(匿名可用;登录 cookie 可选)
|
||||
// 链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
import { eapiRequest } from './utils/index.ts'
|
||||
import { parseNeteaseLink } from './link.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
|
||||
const albumLink = (id: string) => `https://music.163.com/#/album?id=${id}`
|
||||
|
||||
/** 网易云歌曲条目 → MusicServer Song 形状(与 wy/songList.filterListDetail 对齐) */
|
||||
export const convertSong = (item: any) => {
|
||||
const types: Array<{ type: string, size: string | null }> = []
|
||||
const _types: Record<string, { size: string | null }> = {}
|
||||
const push = (type: string, sizeRaw: any) => {
|
||||
const size = typeof sizeRaw === 'number' && sizeRaw > 0 ? sizeFormate(sizeRaw) : null
|
||||
if (!size) return
|
||||
types.push({ type, size })
|
||||
_types[type] = { size }
|
||||
}
|
||||
push('128k', item?.l?.size)
|
||||
push('320k', item?.h?.size)
|
||||
push('flac', item?.sq?.size)
|
||||
push('flac24bit', item?.hr?.size)
|
||||
|
||||
return {
|
||||
singer: formatSingerName(item?.ar, 'name'),
|
||||
name: item?.name ?? '',
|
||||
albumName: item?.al?.name ?? '',
|
||||
albumId: item?.al?.id ?? item?.album?.id,
|
||||
source: 'wy' as const,
|
||||
interval: formatPlayTime((item?.dt ?? 0) / 1000),
|
||||
songmid: item?.id,
|
||||
img: item?.al?.picUrl ?? item?.album?.picUrl ?? '',
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑详情 + 曲目(上游 fetchAlbumDetail:weapi/v1/album/<id>) */
|
||||
export const fetchAlbumDetail = async(albumId: string) => {
|
||||
const albumID = String(albumId ?? '').trim()
|
||||
if (!albumID) throw new Error('wy 专辑 id 为空')
|
||||
const cookie = getWyCookie()
|
||||
const { statusCode, body } = await httpFetch(`https://music.163.com/weapi/v1/album/${encodeURIComponent(albumID)}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi({ csrf_token: '' }),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.code != 200) {
|
||||
throw new Error(`wy 专辑详情获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
|
||||
const album = body.album ?? {}
|
||||
const artistName = album.artist?.name
|
||||
|| (album.artists ?? []).map((a: any) => a.name).filter(Boolean).join('、')
|
||||
const songs = (body.songs ?? []).map(convertSong)
|
||||
return {
|
||||
source: 'wy' as const,
|
||||
info: {
|
||||
name: album.name ?? '',
|
||||
img: album.picUrl ?? '',
|
||||
desc: album.description || album.briefDesc || '',
|
||||
author: artistName ?? '',
|
||||
albumId: String(album.id ?? albumID),
|
||||
publishTime: album.publishTime ?? null,
|
||||
company: album.company ?? '',
|
||||
},
|
||||
list: songs,
|
||||
total: Number(album.size ?? 0) || songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑详情(分页切片,对齐 spec { source, info, list, page, limit, total }) */
|
||||
export const getAlbumDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const detail = await fetchAlbumDetail(id)
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return {
|
||||
source: 'wy' as const,
|
||||
info: detail.info,
|
||||
list: detail.list.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: detail.total || detail.list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑曲目全量(上游 GetAlbumSongs) */
|
||||
export const getAlbumSongs = async(id: string) => (await fetchAlbumDetail(id)).list
|
||||
|
||||
/** 专辑搜索(上游 SearchAlbum:cloudSearch type=10) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const req = eapiRequest('/api/cloudsearch/pc', {
|
||||
s: String(keyword ?? ''),
|
||||
type: 10,
|
||||
limit,
|
||||
total: page === 1,
|
||||
offset: limit * (page - 1),
|
||||
})
|
||||
const { statusCode, body } = await req.promise
|
||||
if (statusCode !== 200 || body?.code != 200) throw new Error(`wy 专辑搜索失败 (code ${body?.code ?? statusCode})`)
|
||||
|
||||
const albums: any[] = body?.result?.albums ?? []
|
||||
const list = albums.map(item => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const artistName = item?.artist?.name
|
||||
|| (item?.artists ?? []).map((a: any) => a?.name).filter(Boolean).join('、')
|
||||
return {
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.picUrl ?? '',
|
||||
trackCount: Number(item?.size ?? 0) || 0,
|
||||
creator: artistName ?? '',
|
||||
description: item?.description || item?.briefDesc || '',
|
||||
link: albumLink(id),
|
||||
type: 'album',
|
||||
company: item?.company ?? '',
|
||||
publishTime: item?.publishTime ?? null,
|
||||
source: 'wy' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
const total = Number(body?.result?.albumCount ?? list.length) || list.length
|
||||
return { list, page, limit, total, source: 'wy' as const }
|
||||
}
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
// 裸数字 id 兜底(上游 ParseAlbum 只接受链接)
|
||||
if (/^\d+$/.test(raw)) return getAlbumDetail(raw)
|
||||
const { kind, id } = parseNeteaseLink(raw)
|
||||
if (kind !== 'album') throw new Error('无效的网易云专辑链接')
|
||||
return getAlbumDetail(id)
|
||||
}
|
||||
|
||||
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink, fetchAlbumDetail }
|
||||
@@ -7,6 +7,10 @@ import songList from './songList.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import userPlaylist from './userPlaylist.ts'
|
||||
|
||||
const wy = {
|
||||
leaderboard,
|
||||
@@ -15,6 +19,10 @@ const wy = {
|
||||
hotSearch,
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
playlist,
|
||||
parse,
|
||||
userPlaylist,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return wyApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 网易云链接解析(上游 music-lib/netease/netease.go 的 parseNeteaseLink 1:1 移植)
|
||||
// 支持:
|
||||
// 歌曲 https://music.163.com/#/song?id=29732995 /song?id= / /song/29732995
|
||||
// 专辑 https://music.163.com/#/album?id=32311 /album?id= / /album/32311
|
||||
// 歌单 https://music.163.com/#/playlist?id=7507256008 /playlist?id= / /playlist/<id>/<n>/...
|
||||
// 裸数字 id 由各模块自行兜底(上游 Parse 不接受裸 id)
|
||||
|
||||
export type NeteaseLinkKind = 'song' | 'album' | 'playlist'
|
||||
|
||||
const isDigits = (value: string): boolean => value !== '' && /^\d+$/.test(value)
|
||||
|
||||
const BASE = 'https://music.163.com'
|
||||
|
||||
/** 上游 parseNeteaseLinkCandidate(JS 需给相对候选补 base;Go 的 url.Parse 直接接受相对路径) */
|
||||
const parseCandidate = (candidate: string): { kind: NeteaseLinkKind | '', id: string } => {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(candidate, BASE)
|
||||
} catch {
|
||||
return { kind: '', id: '' }
|
||||
}
|
||||
|
||||
let kind: NeteaseLinkKind | '' = ''
|
||||
const segments = parsed.pathname.toLowerCase().split('/').filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (segment === 'song' || segment === 'album' || segment === 'playlist') kind = segment
|
||||
}
|
||||
|
||||
let id = parsed.searchParams.get('id') ?? ''
|
||||
if (!isDigits(id)) id = ''
|
||||
|
||||
if (!kind && segments.length >= 2) {
|
||||
const last = segments[segments.length - 1]!
|
||||
const prev = segments[segments.length - 2]!
|
||||
if (isDigits(last) && (prev === 'song' || prev === 'album' || prev === 'playlist')) {
|
||||
kind = prev as NeteaseLinkKind
|
||||
id = last
|
||||
}
|
||||
}
|
||||
|
||||
if (!kind || !id) return { kind: '', id: '' }
|
||||
return { kind, id }
|
||||
}
|
||||
|
||||
/** 上游 parseNeteaseLink:依次尝试原链接、path?query、hash 片段 */
|
||||
export const parseNeteaseLink = (link: string): { kind: NeteaseLinkKind, id: string } => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const candidates: string[] = [raw]
|
||||
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return new URL(raw, BASE)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (parsed) {
|
||||
if (parsed.pathname && parsed.pathname !== '/') {
|
||||
candidates.push(parsed.pathname + parsed.search)
|
||||
}
|
||||
const fragment = parsed.hash.replace(/^#!?/, '').trim()
|
||||
if (fragment) candidates.push(fragment)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { kind, id } = parseCandidate(candidate)
|
||||
if (kind && id) return { kind, id }
|
||||
}
|
||||
throw new Error('无效的网易云链接')
|
||||
}
|
||||
|
||||
/** 裸 id 兜底:非 URL 且全数字时视为对应类型的 id */
|
||||
export const bareId = (link: string): string => {
|
||||
const raw = String(link ?? '').trim()
|
||||
return /^\d+$/.test(raw) ? raw : ''
|
||||
}
|
||||
|
||||
export default { parseNeteaseLink, bareId }
|
||||
@@ -0,0 +1,80 @@
|
||||
// 网易云链接解析(上游 music-lib/netease/song.go 的 Parse 移植)
|
||||
// 歌曲链接形态:
|
||||
// https://music.163.com/#/song?id=29732995
|
||||
// https://music.163.com/song?id=29732995
|
||||
// https://music.163.com/song/29732995
|
||||
// 裸数字 id 也接受(补充便利;上游仅接受链接)
|
||||
// 直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
|
||||
// 歌单/专辑链接抛清晰错误,提示使用对应 parsePlaylist/parseAlbum。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
import wyApi from '../apis/wy.ts'
|
||||
import { convertSong, parseAlbumLink } from './album.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
import { parseNeteaseLink } from './link.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
|
||||
/** 上游 fetchSongsBatch 的单曲版本(weapi/v3/song/detail) */
|
||||
const fetchSongById = async(songId: string) => {
|
||||
const cookie = getWyCookie()
|
||||
const { statusCode, body } = await httpFetch('https://music.163.com/weapi/v3/song/detail', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi({
|
||||
c: `[{"id":${songId}}]`,
|
||||
ids: `[${songId}]`,
|
||||
}),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.code != 200) {
|
||||
throw new Error(`wy 歌曲详情获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
const item = body?.songs?.[0]
|
||||
if (!item?.id) throw new Error('netease song not found')
|
||||
return convertSong(item)
|
||||
}
|
||||
|
||||
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) throw new Error('无效的网易云歌曲链接')
|
||||
|
||||
let songId = ''
|
||||
if (/^\d+$/.test(raw)) {
|
||||
songId = raw
|
||||
} else {
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return parseNeteaseLink(raw)
|
||||
} catch {
|
||||
throw new Error('无效的网易云歌曲链接')
|
||||
}
|
||||
})()
|
||||
if (parsed.kind === 'playlist') throw new Error('检测到网易云歌单链接,请使用 parsePlaylist')
|
||||
if (parsed.kind === 'album') throw new Error('检测到网易云专辑链接,请使用 parseAlbum')
|
||||
if (parsed.kind !== 'song') throw new Error('无效的网易云歌曲链接')
|
||||
songId = parsed.id
|
||||
}
|
||||
|
||||
const song: any = await fetchSongById(songId)
|
||||
try {
|
||||
const hit = await wyApi.getMusicUrl(song, '320k').promise
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
} catch {
|
||||
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,168 @@
|
||||
// 网易云歌单(上游 music-lib/netease/playlist.go 移植)
|
||||
// 搜索/分类:复用现有 wy/songList(search=cloudsearch type1000、getTag=catalogue、getList=playlist/list)
|
||||
// 推荐:weapi/personalized/playlist(上游 GetRecommendedPlaylists,匿名可用)
|
||||
// 详情/曲目:复用 songList.getListDetail(linuxapi forward),按 page/limit 切片
|
||||
// 链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id(上游 parseNeteaseLink)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
import songList from './songList.ts'
|
||||
import { parseNeteaseLink } from './link.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
// catalogue/list 接口固定页大小(songList.limit_list = 30,运行时读取以避免模块环上的 TDZ)
|
||||
const apiLimit = () => Number(songList.limit_list) || 30
|
||||
|
||||
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
|
||||
|
||||
/** 歌单摘要:兼容既有 songList 字段并补 spec 形状(id/name/cover/trackCount/creator/...) */
|
||||
const toSummary = (item: any, categoryId?: string) => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const trackCount = Number(item?.trackCount ?? item?.total ?? 0) || 0
|
||||
const playCount = Number(item?.playCount ?? 0) || 0
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.cover ?? item?.img ?? '',
|
||||
trackCount,
|
||||
playCount,
|
||||
play_count: item?.play_count ?? formatPlayCount(playCount),
|
||||
creator: item?.creator ?? item?.author ?? '',
|
||||
description: item?.description ?? item?.desc ?? '',
|
||||
link: playlistLink(id),
|
||||
source: 'wy' as const,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const result = await songList.search(String(keyword ?? ''), page, limit)
|
||||
const list = (result?.list ?? []).map(item => toSummary(item)).filter(Boolean)
|
||||
return { list, page, limit, total: result?.total ?? list.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
/** 歌单详情(上游 fetchPlaylistDetail;复用 songList.getListDetail,按 page/limit 切片) */
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const detail = await songList.getListDetail(String(id ?? ''), 1)
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return {
|
||||
source: 'wy' as const,
|
||||
info: detail.info,
|
||||
list: detail.list.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: detail.total ?? detail.list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单曲目全量(上游 GetPlaylistSongs) */
|
||||
export const getPlaylistSongs = async(id: string) => (await songList.getListDetail(String(id ?? ''), 1)).list
|
||||
|
||||
/** 歌单分类(上游 GetPlaylistCategories;复用 songList.getTag/getHotTag 并摊平) */
|
||||
export const getCategories = async() => {
|
||||
const [groups, hotTags] = await Promise.all([
|
||||
songList.getTag().catch(() => []),
|
||||
songList.getHotTag().catch(() => []),
|
||||
])
|
||||
const hotSet = new Set((hotTags ?? []).map((item: any) => String(item.id)))
|
||||
const list: any[] = [{ id: '', name: '全部', group: '全部' }]
|
||||
const seen = new Set<string>([''])
|
||||
for (const group of groups ?? []) {
|
||||
for (const item of group?.list ?? []) {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
const name = String(item?.name ?? '').trim()
|
||||
if (!id || !name || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
list.push({ id, name, group: group.name ?? '', hot: hotSet.has(id) })
|
||||
}
|
||||
}
|
||||
return { source: 'wy' as const, list }
|
||||
}
|
||||
|
||||
/** 分类歌单(上游 GetCategoryPlaylists;接口每页 30 条,按入参 limit 重新切片) */
|
||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||
const cat = String(categoryId ?? '').trim() || '全部'
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
const apiPages = Math.max(1, Math.ceil((start + limit) / apiLimit()))
|
||||
const items: any[] = []
|
||||
let total = 0
|
||||
for (let apiPage = 1; apiPage <= apiPages; apiPage++) {
|
||||
const result = await songList.getList('hot', cat, apiPage)
|
||||
total = Number(result?.total ?? total) || total
|
||||
const batch = result?.list ?? []
|
||||
items.push(...batch)
|
||||
if (batch.length < apiLimit()) break
|
||||
}
|
||||
const list = items.slice(start, start + limit).map(item => toSummary(item, cat)).filter(Boolean)
|
||||
return { list, page, limit, total: total || items.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
/** 推荐歌单(上游 GetRecommendedPlaylists:weapi/personalized/playlist) */
|
||||
export const getRecommended = async(page = 1, limit = 20) => {
|
||||
const cookie = getWyCookie()
|
||||
const { statusCode, body } = await httpFetch('https://music.163.com/weapi/personalized/playlist', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi({ limit: 30, total: true, n: 1000 }),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.code != 200) {
|
||||
throw new Error(`wy 推荐歌单获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
|
||||
const rawList: any[] = body?.result ?? []
|
||||
const list = rawList.map(item => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const playCount = Number(item?.playCount ?? 0) || 0
|
||||
return {
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.picUrl ?? '',
|
||||
trackCount: Number(item?.trackCount ?? 0) || 0,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator: item?.copywriter || '网易云推荐',
|
||||
description: item?.copywriter ?? '',
|
||||
link: playlistLink(id),
|
||||
alg: item?.alg ?? '',
|
||||
source: 'wy' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
if (!list.length) throw new Error('no recommended playlists found')
|
||||
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return { list: list.slice(start, start + limit), page, limit, total: list.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
export const getRecommendedPlaylists = getRecommended
|
||||
|
||||
/** 歌单链接解析(上游 ParsePlaylist) */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) throw new Error('无效的网易云歌单链接')
|
||||
if (/^\d+$/.test(raw)) return getPlaylistDetail(raw)
|
||||
const { kind, id } = parseNeteaseLink(raw)
|
||||
if (kind !== 'playlist') throw new Error('无效的网易云歌单链接')
|
||||
return getPlaylistDetail(id)
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
getRecommended,
|
||||
getRecommendedPlaylists,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 网易云个人歌单(上游 music-lib/netease/user_playlist.go 移植)
|
||||
// 需登录 cookie MUSIC_U(sdk/auth/store.ts 的 getCredentials('wy').cookie / musicU):
|
||||
// 未登录抛出清晰错误,不返回空列表
|
||||
// 1) weapi/nuser/account/get → 当前用户 userId
|
||||
// 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import { getCredentials, getWyCookie } from '../auth/store.ts'
|
||||
import type { WyCredentials } from '../auth/store.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
|
||||
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
|
||||
|
||||
const readCookie = (): string => {
|
||||
const cred = getCredentials('wy') as WyCredentials | null
|
||||
const musicU = String(cred?.musicU ?? '').trim()
|
||||
if (musicU) return `MUSIC_U=${musicU}`
|
||||
return String(cred?.cookie ?? getWyCookie()).trim()
|
||||
}
|
||||
|
||||
const weapiPost = async(url: string, data: object, cookie: string) => {
|
||||
const { statusCode, body } = await httpFetch(url, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: cookie,
|
||||
},
|
||||
form: weapi(data),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error('wy 个人歌单接口请求失败')
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人歌单列表(上游 GetUserPlaylists)
|
||||
* 未登录(无 cookie/MUSIC_U)时抛出清晰错误,不返回空列表
|
||||
*/
|
||||
export const getList = async(page = 1, limit = 30) => {
|
||||
const cookie = readCookie()
|
||||
if (!cookie) throw new Error('wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云')
|
||||
|
||||
const safePage = Math.max(1, Number(page) || 1)
|
||||
let safeLimit = Number(limit) || 30
|
||||
if (safeLimit <= 0) safeLimit = 30
|
||||
if (safeLimit > 100) safeLimit = 100
|
||||
|
||||
const account = await weapiPost('https://music.163.com/weapi/nuser/account/get', { csrf_token: '' }, cookie)
|
||||
if (account.code !== 200 || !account?.profile?.userId) {
|
||||
throw new Error(`wy 登录态无效或已过期 (code ${account?.code ?? 'unknown'})`)
|
||||
}
|
||||
const uid = account.profile.userId
|
||||
const nickname = account.profile.nickname ?? ''
|
||||
|
||||
const body = await weapiPost('https://music.163.com/weapi/user/playlist', {
|
||||
uid,
|
||||
limit: safeLimit,
|
||||
offset: (safePage - 1) * safeLimit,
|
||||
includeVideo: true,
|
||||
csrf_token: '',
|
||||
}, cookie)
|
||||
if (body.code !== 200) throw new Error(`wy 个人歌单获取失败 (code ${body.code})`)
|
||||
|
||||
const list = (body.playlist ?? []).map(item => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const trackCount = Number(item?.trackCount ?? 0) || 0
|
||||
const playCount = Number(item?.playCount ?? 0) || 0
|
||||
const creator = String(item?.creator?.nickname ?? '').trim() || nickname
|
||||
return {
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.coverImgUrl ?? '',
|
||||
trackCount,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator,
|
||||
description: item?.description ?? '',
|
||||
subscribed: Boolean(item?.subscribed),
|
||||
link: playlistLink(id),
|
||||
source: 'wy' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return { list, page: safePage, limit: safeLimit, total: list.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
export const getUserPlaylists = getList
|
||||
|
||||
export default { getList, getUserPlaylists }
|
||||
Reference in new issue
Block a user