|
|
import { serve } from "https://deno.land/std@0.208.0/http/server.ts"; |
|
|
import { crypto } from "jsr:@std/crypto"; |
|
|
import dayjs from "npm:dayjs"; |
|
|
|
|
|
import { encode as hexEncode } from "https://deno.land/std@0.208.0/encoding/hex.ts"; |
|
|
|
|
|
const pageSize = 30; |
|
|
|
|
|
|
|
|
const qualityMap = { |
|
|
"low": "standard", |
|
|
"standard": "exhigh", |
|
|
"high": "lossless", |
|
|
"super": "hires", |
|
|
}; |
|
|
|
|
|
|
|
|
const corsHeaders = { |
|
|
"Access-Control-Allow-Origin": "*", |
|
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", |
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With", |
|
|
"Access-Control-Max-Age": "86400", |
|
|
}; |
|
|
|
|
|
|
|
|
async function MD5(data: string): Promise<string> { |
|
|
const encoder = new TextEncoder(); |
|
|
const dataBuffer = encoder.encode(data); |
|
|
const hashBuffer = await crypto.subtle.digest("MD5", dataBuffer); |
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer)); |
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); |
|
|
} |
|
|
|
|
|
|
|
|
async function AES(data: string): Promise<string> { |
|
|
|
|
|
|
|
|
|
|
|
const key = "e82ckenh8dichen8"; |
|
|
const encoder = new TextEncoder(); |
|
|
const keyBuffer = encoder.encode(key); |
|
|
|
|
|
|
|
|
const cryptoKey = await crypto.subtle.importKey( |
|
|
"raw", |
|
|
keyBuffer, |
|
|
{ name: "AES-CBC" }, |
|
|
false, |
|
|
["encrypt"] |
|
|
); |
|
|
|
|
|
|
|
|
const dataBuffer = encoder.encode(data); |
|
|
|
|
|
|
|
|
const blockSize = 16; |
|
|
const paddingSize = blockSize - (dataBuffer.length % blockSize); |
|
|
const paddedBuffer = new Uint8Array(dataBuffer.length + paddingSize); |
|
|
paddedBuffer.set(dataBuffer); |
|
|
|
|
|
paddedBuffer.fill(paddingSize, dataBuffer.length); |
|
|
|
|
|
|
|
|
const blocks = []; |
|
|
for (let i = 0; i < paddedBuffer.length; i += blockSize) { |
|
|
blocks.push(paddedBuffer.slice(i, i + blockSize)); |
|
|
} |
|
|
|
|
|
|
|
|
const encryptedBlocks = await Promise.all(blocks.map(async (block) => { |
|
|
|
|
|
const iv = new Uint8Array(16); |
|
|
const encrypted = await crypto.subtle.encrypt( |
|
|
{ name: "AES-CBC", iv }, |
|
|
cryptoKey, |
|
|
block |
|
|
); |
|
|
|
|
|
return new Uint8Array(encrypted).slice(0, blockSize); |
|
|
})); |
|
|
|
|
|
|
|
|
const encryptedArray = new Uint8Array(encryptedBlocks.reduce((acc, block) => { |
|
|
const combined = new Uint8Array(acc.length + block.length); |
|
|
combined.set(acc); |
|
|
combined.set(block, acc.length); |
|
|
return combined; |
|
|
}, new Uint8Array(0))); |
|
|
|
|
|
|
|
|
return Array.from(encryptedArray) |
|
|
.map(b => b.toString(16).padStart(2, '0')) |
|
|
.join('') |
|
|
.toUpperCase(); |
|
|
} |
|
|
|
|
|
|
|
|
async function EAPI(path: string, json: any = {}, music_u?: string): Promise<any> { |
|
|
try { |
|
|
const params = [path, JSON.stringify(json)]; |
|
|
const md5Hash = await MD5("nobody" + params.join("use") + "md5forencrypt"); |
|
|
params.push(md5Hash); |
|
|
|
|
|
const encryptedParams = await AES(params.join("-36cd479b6b5-")); |
|
|
|
|
|
|
|
|
let cookieValue = "os=pc;"; |
|
|
if (music_u) { |
|
|
|
|
|
const musicMatch = music_u.match(/MUSIC_[UA]=([^;]+)/i); |
|
|
if (musicMatch) { |
|
|
cookieValue = `os=pc; appver=9.0.25; MUSIC_U=${musicMatch[1]}`; |
|
|
} else { |
|
|
cookieValue = `os=pc; appver=9.0.25; MUSIC_U=${music_u}`; |
|
|
} |
|
|
} |
|
|
|
|
|
const response = await fetch(path.replace("/", "https://interface.music.163.com/e"), { |
|
|
method: "POST", |
|
|
body: "params=" + encryptedParams, |
|
|
headers: { |
|
|
"Content-Type": "application/x-www-form-urlencoded", |
|
|
"Cookie": cookieValue, |
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" |
|
|
} |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`API错误: ${response.status} ${response.statusText}`); |
|
|
} |
|
|
|
|
|
const text = await response.text(); |
|
|
try { |
|
|
return JSON.parse(text); |
|
|
} catch (e) { |
|
|
console.error("JSON解析失败:", text); |
|
|
throw new Error(`JSON解析失败: ${e.message}`); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("EAPI请求失败:", error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function formatMusicItem(item: any): any { |
|
|
const _ = item.baseInfo || item.song || item; |
|
|
const name = _.name || _.songname; |
|
|
const singer = _.ar && _.ar.map((a: any) => a.name).join('&') || ""; |
|
|
const albumName = _.al && _.al.name; |
|
|
const albumId = (_.al && _.al.id) || ""; |
|
|
const picUrl = (_.al && _.al.picUrl) || ""; |
|
|
|
|
|
const qualities: any = {}; |
|
|
for (const k of ['l', 'h', 'sq', 'hr']) { |
|
|
if (_[k] || (k === 'l' && _['m'])) { |
|
|
const quality = { |
|
|
'm': "low", |
|
|
'l': "low", |
|
|
'h': "standard", |
|
|
'sq': "high", |
|
|
'hr': "super" |
|
|
}[k === 'l' && _['m'] ? 'm' : k]; |
|
|
|
|
|
if (quality) { |
|
|
qualities[quality] = { |
|
|
size: _[k === 'l' && _['m'] ? 'm' : k].size |
|
|
}; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const content = ((_.fee === 0 || _.fee === 8) && (_.privilege ? (_.privilege.st > -1) : 1)) ? 0 : 1; |
|
|
|
|
|
return { |
|
|
id: _.id, |
|
|
artist: singer, |
|
|
title: name, |
|
|
duration: _.dt, |
|
|
album: albumName, |
|
|
artwork: picUrl, |
|
|
qualities, |
|
|
albumId, |
|
|
content, |
|
|
rawLrc: _.lyrics, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
function formatAlbumItem(item: any): any { |
|
|
return { |
|
|
id: item.id, |
|
|
title: item.name, |
|
|
artist: item.artist.name, |
|
|
artwork: item.picUrl, |
|
|
description: item.description, |
|
|
date: dayjs.unix(item.publishTime / 1000).format("YYYY-MM-DD"), |
|
|
worksNum: item.artist.musicSize, |
|
|
content: 4 |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
function formatArtistItem(item: any): any { |
|
|
return { |
|
|
id: item.id, |
|
|
name: item.name, |
|
|
avatar: item.img1v1Url, |
|
|
description: item.briefDesc || item.description, |
|
|
worksNum: item.musicSize || item.albumSize, |
|
|
content: 5 |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
function formatSheetItem(item: any): any { |
|
|
const _ = item.baseInfo || item; |
|
|
return { |
|
|
id: _.id || _.resourceId, |
|
|
title: _.name || _.title, |
|
|
artist: (_.creator && _.creator.nickname), |
|
|
artwork: _.coverImgUrl || _.picUrl || _.coverImg, |
|
|
description: _.description, |
|
|
worksNum: _.trackCount, |
|
|
playCount: _.playCount, |
|
|
date: _.updateTime, |
|
|
createUserId: _.userId, |
|
|
createTime: _.createTime, |
|
|
content: 2 |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
async function getTopListDetail(topListItem: any, music_u?: string): Promise<any> { |
|
|
const res = await getMusicSheetInfo(topListItem, 1, music_u); |
|
|
res.topListItem = res.sheetItem; |
|
|
res.topListItem.content = 3; |
|
|
return res; |
|
|
} |
|
|
|
|
|
|
|
|
async function handleRequest(req: Request): Promise<Response> { |
|
|
|
|
|
if (req.method === "OPTIONS") { |
|
|
return new Response(null, { |
|
|
status: 200, |
|
|
headers: corsHeaders |
|
|
}); |
|
|
} |
|
|
|
|
|
const url = new URL(req.url); |
|
|
const path = url.pathname; |
|
|
const params = url.searchParams; |
|
|
|
|
|
try { |
|
|
let result: any = {}; |
|
|
|
|
|
|
|
|
let body: any = {}; |
|
|
if (req.method === "POST") { |
|
|
try { |
|
|
body = await req.json(); |
|
|
} catch { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
const music_u = params.get("music_u") || body.music_u; |
|
|
|
|
|
switch (path) { |
|
|
case "/search": |
|
|
const query = params.get("query") || body.query; |
|
|
const page = parseInt(params.get("page") || body.page || "1"); |
|
|
const type = params.get("type") || body.type || "music"; |
|
|
result = await search(query, page, type, music_u); |
|
|
break; |
|
|
|
|
|
case "/music/info": |
|
|
const musicId = params.get("id") || body.id; |
|
|
result = await getMusicInfo({ id: musicId }, music_u); |
|
|
break; |
|
|
|
|
|
case "/music/url": |
|
|
const songId = params.get("id") || body.id; |
|
|
const quality = params.get("quality") || body.quality || "standard"; |
|
|
const musicItem = { id: songId, qualities: { [quality]: {} } }; |
|
|
result = await getMediaSource(musicItem, quality, music_u); |
|
|
break; |
|
|
|
|
|
case "/lyric": |
|
|
const lyricId = params.get("id") || body.id; |
|
|
result = await getLyric({ id: lyricId }, music_u); |
|
|
break; |
|
|
|
|
|
case "/playlist/detail": |
|
|
const playlistId = params.get("id") || body.id; |
|
|
const playlistPage = parseInt(params.get("page") || body.page || "1"); |
|
|
result = await getMusicSheetInfo({ id: playlistId }, playlistPage, music_u); |
|
|
break; |
|
|
|
|
|
case "/album/detail": |
|
|
const albumId = params.get("id") || body.id; |
|
|
result = await getAlbumInfo({ id: albumId }, music_u); |
|
|
break; |
|
|
|
|
|
case "/artist/works": |
|
|
const artistId = params.get("id") || body.id; |
|
|
const artistPage = parseInt(params.get("page") || body.page || "1"); |
|
|
const artistType = params.get("type") || body.type || "music"; |
|
|
result = await getArtistWorks({ id: artistId }, artistPage, artistType, music_u); |
|
|
break; |
|
|
|
|
|
case "/toplists": |
|
|
result = await getTopLists(music_u); |
|
|
break; |
|
|
|
|
|
case "/toplistdetail": |
|
|
result = await getTopListDetail( |
|
|
{ id: params.get("id") || body.id }, |
|
|
music_u |
|
|
); |
|
|
break; |
|
|
|
|
|
case "/recommend/tags": |
|
|
result = await getRecommendSheetTags(music_u); |
|
|
break; |
|
|
|
|
|
case "/recommend/sheets": |
|
|
const tag = params.get("tag") || body.tag; |
|
|
const sheetPage = parseInt(params.get("page") || body.page || "1"); |
|
|
result = await getRecommendSheetsByTag(tag, sheetPage, music_u); |
|
|
break; |
|
|
|
|
|
case "/import/sheet": |
|
|
const urlLike = params.get("url") || body.url; |
|
|
result = await importMusicSheet(urlLike, music_u); |
|
|
break; |
|
|
|
|
|
case "/import/music": |
|
|
const musicUrl = params.get("url") || body.url; |
|
|
result = await importMusicItem(musicUrl, music_u); |
|
|
break; |
|
|
|
|
|
case "/comments": |
|
|
const commentId = params.get("id") || body.id; |
|
|
const commentPage = parseInt(params.get("page") || body.page || "1"); |
|
|
result = await getMusicComments({ id: commentId }, commentPage, music_u); |
|
|
break; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
case "/": |
|
|
case "/health": |
|
|
result = { |
|
|
status: "ok", |
|
|
message: "云音乐 API 服务正在运行", |
|
|
version: "2025.07.22", |
|
|
description: "基于 Deno 的云音乐 API 服务,支持搜索、播放、歌词等功能", |
|
|
baseUrl: `${url.protocol}//${url.host}`, |
|
|
authentication: { |
|
|
description: "部分接口需要登录态,可通过 music_u 参数传递", |
|
|
parameter: "music_u", |
|
|
usage: "在查询参数或请求体中添加 music_u 字段" |
|
|
}, |
|
|
endpoints: [ |
|
|
{ |
|
|
path: "/search", |
|
|
method: "GET/POST", |
|
|
description: "搜索音乐、专辑、歌手、歌单", |
|
|
parameters: { |
|
|
query: "搜索关键词(必需)", |
|
|
page: "页码,默认为1", |
|
|
type: "搜索类型:music(歌曲)、album(专辑)、artist(歌手)、sheet(歌单)、lyric(歌词),默认music", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/search?query=周杰伦&page=1&type=music" |
|
|
}, |
|
|
{ |
|
|
path: "/music/info", |
|
|
method: "GET/POST", |
|
|
description: "获取歌曲详细信息", |
|
|
parameters: { |
|
|
id: "歌曲ID(必需)", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/music/info?id=123456" |
|
|
}, |
|
|
{ |
|
|
path: "/music/url", |
|
|
method: "GET/POST", |
|
|
description: "获取歌曲播放链接", |
|
|
parameters: { |
|
|
id: "歌曲ID(必需)", |
|
|
quality: "音质:low(标准)、standard(较高)、high(无损)、super(Hi-Res),默认standard", |
|
|
music_u: "登录凭证(推荐,提高成功率)" |
|
|
}, |
|
|
example: "/music/url?id=123456&quality=high" |
|
|
}, |
|
|
{ |
|
|
path: "/lyric", |
|
|
method: "GET/POST", |
|
|
description: "获取歌曲歌词", |
|
|
parameters: { |
|
|
id: "歌曲ID(必需)", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/lyric?id=123456" |
|
|
}, |
|
|
{ |
|
|
path: "/playlist/detail", |
|
|
method: "GET/POST", |
|
|
description: "获取歌单详情和歌曲列表", |
|
|
parameters: { |
|
|
id: "歌单ID(必需)", |
|
|
page: "页码,默认为1", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/playlist/detail?id=123456" |
|
|
}, |
|
|
{ |
|
|
path: "/album/detail", |
|
|
method: "GET/POST", |
|
|
description: "获取专辑详情和歌曲列表", |
|
|
parameters: { |
|
|
id: "专辑ID(必需)", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/album/detail?id=123456" |
|
|
}, |
|
|
{ |
|
|
path: "/artist/works", |
|
|
method: "GET/POST", |
|
|
description: "获取歌手作品(热门歌曲或专辑)", |
|
|
parameters: { |
|
|
id: "歌手ID(必需)", |
|
|
page: "页码,默认为1", |
|
|
type: "作品类型:music(歌曲)、album(专辑),默认music", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/artist/works?id=123456&type=music" |
|
|
}, |
|
|
{ |
|
|
path: "/toplists", |
|
|
method: "GET/POST", |
|
|
description: "获取所有排行榜列表", |
|
|
parameters: { |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/toplists" |
|
|
}, |
|
|
{ |
|
|
path: "/recommend/tags", |
|
|
method: "GET/POST", |
|
|
description: "获取歌单推荐标签", |
|
|
parameters: { |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/recommend/tags" |
|
|
}, |
|
|
{ |
|
|
path: "/recommend/sheets", |
|
|
method: "GET/POST", |
|
|
description: "根据标签获取推荐歌单", |
|
|
parameters: { |
|
|
tag: "标签名称(可选,不传则返回推荐歌单)", |
|
|
page: "页码,默认为1", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/recommend/sheets?tag=流行&page=1" |
|
|
}, |
|
|
{ |
|
|
path: "/import/sheet", |
|
|
method: "GET/POST", |
|
|
description: "通过URL或ID导入歌单", |
|
|
parameters: { |
|
|
url: "歌单URL或歌单ID(必需)", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/import/sheet?url=123456 或 /import/sheet?url=https://music.163.com/playlist?id=123456" |
|
|
}, |
|
|
{ |
|
|
path: "/import/music", |
|
|
method: "GET/POST", |
|
|
description: "通过URL或ID导入单曲", |
|
|
parameters: { |
|
|
url: "歌曲URL或歌曲ID(必需)", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/import/music?url=123456 或 /import/music?url=https://music.163.com/song?id=123456" |
|
|
}, |
|
|
{ |
|
|
path: "/comments", |
|
|
method: "GET/POST", |
|
|
description: "获取歌曲评论", |
|
|
parameters: { |
|
|
id: "歌曲ID(必需)", |
|
|
page: "页码,默认为1", |
|
|
music_u: "登录凭证(可选)" |
|
|
}, |
|
|
example: "/comments?id=123456&page=1" |
|
|
} |
|
|
], |
|
|
responseFormat: { |
|
|
success: { |
|
|
description: "成功响应格式", |
|
|
example: { |
|
|
isEnd: "boolean - 是否为最后一页(分页接口)", |
|
|
data: "array - 数据列表", |
|
|
total: "number - 总数(部分接口)" |
|
|
} |
|
|
}, |
|
|
error: { |
|
|
description: "错误响应格式", |
|
|
example: { |
|
|
error: "string - 错误描述", |
|
|
message: "string - 详细错误信息" |
|
|
} |
|
|
} |
|
|
}, |
|
|
notes: [ |
|
|
"所有接口均支持 GET 和 POST 请求方式", |
|
|
"GET 请求参数通过查询字符串传递", |
|
|
"POST 请求参数通过 JSON 格式的请求体传递", |
|
|
"推荐使用 music_u 参数以获得更好的接口响应", |
|
|
"部分高音质音频需要会员权限", |
|
|
"请遵守相关法律法规,合理使用 API" |
|
|
] |
|
|
}; |
|
|
break; |
|
|
|
|
|
default: |
|
|
return new Response(JSON.stringify({ |
|
|
error: "API 路径不存在", |
|
|
path: path |
|
|
}), { |
|
|
status: 404, |
|
|
headers: { |
|
|
"Content-Type": "application/json", |
|
|
...corsHeaders |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
return new Response(JSON.stringify(result), { |
|
|
status: 200, |
|
|
headers: { |
|
|
"Content-Type": "application/json", |
|
|
...corsHeaders |
|
|
} |
|
|
}); |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
console.error("API错误:", error); |
|
|
console.error("错误堆栈:", error.stack); |
|
|
|
|
|
|
|
|
try { |
|
|
const url = new URL(req.url); |
|
|
console.error("路径:", url.pathname); |
|
|
console.error("查询参数:", Object.fromEntries(url.searchParams.entries())); |
|
|
|
|
|
if (req.method === "POST") { |
|
|
try { |
|
|
const cloned = req.clone(); |
|
|
const body = await cloned.text(); |
|
|
console.error("请求体:", body); |
|
|
} catch (e) { |
|
|
console.error("无法读取请求体:", e); |
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
console.error("无法获取请求信息:", e); |
|
|
} |
|
|
|
|
|
return new Response(JSON.stringify({ |
|
|
error: "服务器内部错误", |
|
|
message: error.message, |
|
|
stack: Deno.env.get("DEBUG") === "true" ? error.stack : undefined |
|
|
}), { |
|
|
status: 500, |
|
|
headers: { |
|
|
"Content-Type": "application/json", |
|
|
...corsHeaders |
|
|
} |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function search(query: string, page: number, type: string, music_u?: string): Promise<any> { |
|
|
const stype: any = { |
|
|
music: { t: "song", m: formatMusicItem }, |
|
|
album: { t: "album", v: "/v1", m: formatAlbumItem }, |
|
|
artist: { t: "artist", v: "/v1", m: formatArtistItem }, |
|
|
sheet: { t: "playlist", m: formatSheetItem }, |
|
|
lyric: { t: "resource/lyric", m: formatMusicItem } |
|
|
}[type]; |
|
|
|
|
|
const path = "/api" + (stype.v || "") + "/search/" + stype.t + (stype.t.includes("/") ? "" : "/get"); |
|
|
const data = { |
|
|
"filterCode": "-1", |
|
|
"offset": ((page - 1) * pageSize).toString(), |
|
|
"limit": pageSize.toString(), |
|
|
"channel": "typing", |
|
|
"keyword": query, |
|
|
"scene": "normal", |
|
|
"s": query, |
|
|
}; |
|
|
|
|
|
const res = await EAPI(path, data, music_u); |
|
|
const result = res.data || res.result; |
|
|
const list = result.resources || result.albums || result.artists || result.songs || []; |
|
|
const total1 = page * pageSize; |
|
|
const total2 = result.songCount || result.playlistCount || result.albumCount || result.totalCount || (total1 - pageSize + list.length); |
|
|
|
|
|
return { |
|
|
isEnd: total2 <= total1, |
|
|
data: list.map(stype.m) |
|
|
}; |
|
|
} |
|
|
|
|
|
async function getMusicInfo(musicItem: any, music_u?: string): Promise<any> { |
|
|
const res = await EAPI("/api/v3/song/detail", { |
|
|
c: `[{"id":"${musicItem.id}"}]` |
|
|
}, music_u); |
|
|
const song = res.songs[0] || res.privileges[0]; |
|
|
song.privilege = res.privileges[0]; |
|
|
return formatMusicItem(song); |
|
|
} |
|
|
|
|
|
async function getMediaSource(musicItem: any, quality: string, music_u?: string): Promise<any> { |
|
|
if (!musicItem.qualities[quality]) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
const res = await EAPI("/api/song/enhance/player/url/v1", { |
|
|
ids: `["${musicItem.id}"]`, |
|
|
encodeType: "flac", |
|
|
immerseType: "c51", |
|
|
trialMode: "23", |
|
|
level: qualityMap[quality as keyof typeof qualityMap] |
|
|
}, music_u); |
|
|
|
|
|
if (res.data && res.data[0] && res.data[0].url) { |
|
|
return { |
|
|
url: String(res.data[0].url).split("?")[0], |
|
|
size: res.data[0].size, |
|
|
quality, |
|
|
}; |
|
|
} |
|
|
|
|
|
return false; |
|
|
} |
|
|
|
|
|
async function getLyric(musicItem: any, music_u?: string): Promise<any> { |
|
|
const res = await EAPI("/api/song/lyric", { |
|
|
id: musicItem.id, |
|
|
lv: -1, |
|
|
kv: -1, |
|
|
tv: -1 |
|
|
}, music_u); |
|
|
|
|
|
return { |
|
|
rawLrc: res.lrc?.lyric || "", |
|
|
}; |
|
|
} |
|
|
|
|
|
async function getMusicSheetInfo(sheet: any, page: number = 1, music_u?: string): Promise<any> { |
|
|
const res = await EAPI("/api/v6/playlist/detail", { |
|
|
n: 99999, |
|
|
id: sheet.id |
|
|
}, music_u); |
|
|
|
|
|
const playlist = res.playlist; |
|
|
const list = playlist.tracks || []; |
|
|
|
|
|
return { |
|
|
isEnd: 99999 >= playlist.trackCount, |
|
|
sheetItem: formatSheetItem(playlist), |
|
|
musicList: list.map(formatMusicItem) |
|
|
}; |
|
|
} |
|
|
|
|
|
async function getAlbumInfo(albumItem: any, music_u?: string): Promise<any> { |
|
|
const res = await EAPI("/api/v1/album/" + albumItem.id, {}, music_u); |
|
|
return { |
|
|
isEnd: true, |
|
|
albumItem: formatAlbumItem(res.album), |
|
|
musicList: (res.songs || []).map(formatMusicItem), |
|
|
}; |
|
|
} |
|
|
|
|
|
async function getArtistWorks(artistItem: any, page: number, type: string, music_u?: string): Promise<any> { |
|
|
const typeConfig: any = { |
|
|
"music": { |
|
|
"path1": "/api/v1/artist/", |
|
|
"path2": "hotSongs", |
|
|
"mapJs": formatMusicItem |
|
|
}, |
|
|
"album": { |
|
|
"path1": "/api/artist/albums/", |
|
|
"path2": "hotAlbums", |
|
|
"mapJs": formatAlbumItem |
|
|
}, |
|
|
}[type]; |
|
|
|
|
|
const res = await EAPI(typeConfig.path1 + artistItem.id, {}, music_u); |
|
|
return { |
|
|
isEnd: true, |
|
|
artistItem: formatArtistItem(res.artist), |
|
|
data: res[typeConfig.path2].map(typeConfig.mapJs), |
|
|
}; |
|
|
} |
|
|
|
|
|
async function getTopLists(music_u?: string): Promise<any> { |
|
|
const group1: any[] = []; |
|
|
const group2 = await EAPI("/api/toplist/detail/v2", {}, music_u); |
|
|
|
|
|
group2.data.map((item: any) => { |
|
|
if (item.list && item.list.length) { |
|
|
const group3: any[] = []; |
|
|
item.list.map((listItem: any) => { |
|
|
if (listItem.id !== 0) { |
|
|
group3.push({ |
|
|
title: listItem.name, |
|
|
coverImg: listItem.coverUrl, |
|
|
content: 3, |
|
|
id: listItem.id, |
|
|
description: listItem.updateFrequency |
|
|
}); |
|
|
} |
|
|
}); |
|
|
group1.push({ |
|
|
title: item.name, |
|
|
data: group3 |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
return group1; |
|
|
} |
|
|
|
|
|
async function getRecommendSheetTags(music_u?: string): Promise<any> { |
|
|
const pinned = [ |
|
|
{ title: "推荐", id: "_SPECIAL_CLOUD_VILLAGE_PLAYLIST" }, |
|
|
{ title: "官方", id: "官方" }, |
|
|
{ title: "雷达", id: "_RADAR" }, |
|
|
{ title: "原创", id: "_SPECIAL_ORIGIN_SONG_LOCATION" }, |
|
|
{ title: "心情", id: "_FEELING_PLAYLIST_LOCATION" }, |
|
|
{ title: "场景", id: "_SCENE_PLAYLIST_LOCATION" }, |
|
|
{ title: "专属", id: "_COMBINATION" }, |
|
|
{ title: "全部", id: "全部歌单" }, |
|
|
{ title: "新热", id: "_NEW_SONG_AND_ALBUM" }, |
|
|
{ title: "影视", id: "_FIRM_PLAYLIST" }, |
|
|
{ title: "奖项", id: "_AWARDS_PLAYLIST" }, |
|
|
]; |
|
|
|
|
|
const data = ["语种", "风格", "场景", "情感", "主题"].map(name => ({ |
|
|
title: name, |
|
|
data: [] |
|
|
})); |
|
|
|
|
|
const res = await EAPI("/api/playlist/catalogue/v1", {}, music_u); |
|
|
res.sub.map((item: any) => { |
|
|
data[item.category].data.push({ |
|
|
title: item.name, |
|
|
id: item.name |
|
|
}); |
|
|
}); |
|
|
|
|
|
return { pinned, data }; |
|
|
} |
|
|
|
|
|
async function getRecommendSheetsByTag(tag: string | null, page: number, music_u?: string): Promise<any> { |
|
|
let res: any; |
|
|
const tagId = tag; |
|
|
|
|
|
if (!tagId || tagId === "" || tagId === "true") { |
|
|
res = await EAPI("/api/personalized/playlist", { limit: 30 }, music_u); |
|
|
} else if (/^_[A-Z]+/.test(tagId)) { |
|
|
res = await EAPI("/api/link/page/rcmd/resource/show", { |
|
|
"pageCode": "HOME_RECOMMEND_PAGE", |
|
|
"isFirstScreen": "false", |
|
|
"cursor": "6", |
|
|
"refresh": "true", |
|
|
"blockCodeOrderList": `["PAGE_RECOMMEND${tagId}"]` |
|
|
}, music_u); |
|
|
res = res.data.blocks[0].dslData.blockResource; |
|
|
} else { |
|
|
res = await EAPI("/api/playlist/list", { |
|
|
cat: tagId || "全部", |
|
|
order: "hot", |
|
|
limit: pageSize, |
|
|
offset: (page - 1) * pageSize, |
|
|
total: true, |
|
|
csrf_token: "", |
|
|
}, music_u); |
|
|
} |
|
|
|
|
|
const list = res.result || res.playlists || res.resources || []; |
|
|
const total1 = page * pageSize; |
|
|
const total2 = res.total || (total1 - pageSize + list.length); |
|
|
|
|
|
return { |
|
|
isEnd: (res.more !== true) || (total2 <= total1), |
|
|
data: list.map(formatSheetItem) |
|
|
}; |
|
|
} |
|
|
|
|
|
async function importMusicSheet(urlLike: string, music_u?: string): Promise<any> { |
|
|
let id = (urlLike.match(/^(\d+)$/) || [])[1]; |
|
|
if (!id && !urlLike.match(/music\.163\.com/i)) { |
|
|
return false; |
|
|
} |
|
|
if (!id) { |
|
|
id = (urlLike.match(/playlist(\/|.*?[\?\&]id=)(\d+)/i) || [])[2]; |
|
|
} |
|
|
if (!id) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
const result = await getMusicSheetInfo({ id }, 1, music_u); |
|
|
return result.musicList; |
|
|
} |
|
|
|
|
|
async function importMusicItem(urlLike: string, music_u?: string): Promise<any> { |
|
|
let id = (urlLike.match(/^(\d+)$/) || [])[1]; |
|
|
if (!id && !urlLike.match(/music\.163\.com/i)) { |
|
|
return false; |
|
|
} |
|
|
if (!id) { |
|
|
id = (urlLike.match(/song(.*?[\?\&]id=|\/)(\d+)/i) || [])[2]; |
|
|
} |
|
|
if (!id) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
return await getMusicInfo({ id }, music_u); |
|
|
} |
|
|
|
|
|
async function getMusicComments(musicItem: any, page: number = 1, music_u?: string): Promise<any> { |
|
|
const res = await EAPI("/api/v2/resource/comments", { |
|
|
"threadId": "R_SO_4_" + musicItem.id, |
|
|
"cursor": "20", |
|
|
"sortType": "1", |
|
|
"pageNo": page, |
|
|
"pageSize": pageSize.toString(), |
|
|
"parentCommentld": "0", |
|
|
"showlnner": false |
|
|
}, music_u); |
|
|
|
|
|
const formatComment = (comment: any): any => ({ |
|
|
id: comment.commentId, |
|
|
nickName: comment.user && comment.user.nickname, |
|
|
avatar: comment.user && comment.user.avatarUrl, |
|
|
comment: comment.content, |
|
|
like: comment.likedCount, |
|
|
createAt: comment.time, |
|
|
location: comment.ipLocation && comment.ipLocation.location, |
|
|
replies: (comment.beReplied || []).map(formatComment), |
|
|
content: 6 |
|
|
}); |
|
|
|
|
|
return { |
|
|
isEnd: res.data.hasMore !== true, |
|
|
data: res.data.comments.map(formatComment) |
|
|
}; |
|
|
} |
|
|
|
|
|
let hbCount = 0; |
|
|
setInterval(()=>{ |
|
|
hbCount++; |
|
|
if (hbCount % 6 === 0) { |
|
|
console.log('[diagnostic] heartbeat 60s elapsed, process alive'); |
|
|
} |
|
|
}, 10_000); |
|
|
|
|
|
|
|
|
const port = parseInt(Deno.env.get("PORT") || "8080"); |
|
|
|
|
|
console.log(`🎵 Deno 网易云音乐 API 服务启动成功!`); |
|
|
console.log(`🚀 服务运行在: http://localhost:${port}`); |
|
|
console.log(`📖 API 文档: http://localhost:${port}/`); |
|
|
|
|
|
serve(handleRequest, { port }); |