Files
MusicServer/sdk/wy/album.ts
T
snowy-yang 68e071b166 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 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
2026-10-03 00:26:51 +08:00

153 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 网易云专辑(上游 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 }