Skip to content

Commit 2165b6a

Browse files
authored
feat: add music card to LinkCard (netease/tencent) (#470)
* feat: add QQMusicSong to LinkCard * feat: add NeteaseMusicSong to LinkCard * fix: music card style * chore: remove unnecessary code
1 parent 1cff613 commit 2165b6a

File tree

7 files changed

+259
-0
lines changed

7 files changed

+259
-0
lines changed

src/app/api/music/netease/crypto.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
constants,
3+
createCipheriv,
4+
createHash,
5+
publicEncrypt,
6+
randomBytes,
7+
} from 'node:crypto'
8+
9+
const iv = Buffer.from('0102030405060708')
10+
const presetKey = Buffer.from('0CoJUm6Qyw8W8jud')
11+
const base62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
12+
const publicKey =
13+
'-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----'
14+
const eapiKey = 'e82ckenh8dichen8'
15+
16+
const aesEncrypt = (
17+
buffer: Buffer,
18+
mode: string,
19+
key: Uint8Array | Buffer | string,
20+
iv: Buffer | string,
21+
) => {
22+
const cipher = createCipheriv(`aes-128-${mode}`, key, iv)
23+
return Buffer.concat([cipher.update(buffer), cipher.final()])
24+
}
25+
26+
const rsaEncrypt = (buffer: Uint8Array) =>
27+
publicEncrypt(
28+
{ key: publicKey, padding: constants.RSA_NO_PADDING },
29+
Buffer.concat([Buffer.alloc(128 - buffer.length), buffer]),
30+
)
31+
32+
export const weapi = (
33+
object: Record<string, number | string | boolean>,
34+
): { params: string; encSecKey: string } => {
35+
const text = JSON.stringify(object)
36+
const secretKey = randomBytes(16).map((n) =>
37+
base62.charAt(n % 62).charCodeAt(0),
38+
)
39+
return {
40+
params: aesEncrypt(
41+
Buffer.from(
42+
aesEncrypt(Buffer.from(text), 'cbc', presetKey, iv).toString('base64'),
43+
),
44+
'cbc',
45+
secretKey,
46+
iv,
47+
).toString('base64'),
48+
encSecKey: rsaEncrypt(secretKey.reverse()).toString('hex'),
49+
}
50+
}
51+
52+
export const eapi = (
53+
url: string,
54+
object: Record<string, unknown>,
55+
): { params: string } => {
56+
const text = JSON.stringify(object)
57+
const message = `nobody${url}use${text}md5forencrypt`
58+
const digest = createHash('md5').update(message).digest('hex')
59+
const data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`
60+
return {
61+
params: aesEncrypt(Buffer.from(data), 'ecb', eapiKey, '')
62+
.toString('hex')
63+
.toUpperCase(),
64+
}
65+
}

src/app/api/music/netease/route.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
4+
import { weapi } from './crypto'
5+
6+
export const POST = async (req: NextRequest) => {
7+
const requestBody = await req.json()
8+
const { songId } = requestBody
9+
const data = {
10+
c: JSON.stringify([{ id: songId, v: 0 }]),
11+
}
12+
const body: any = weapi(data)
13+
const bodyString = `params=${encodeURIComponent(body.params)}&encSecKey=${encodeURIComponent(body.encSecKey)}`
14+
15+
const response = await fetch('http://music.163.com/weapi/v3/song/detail', {
16+
method: 'POST',
17+
headers: {
18+
'Content-Type': 'application/x-www-form-urlencoded',
19+
},
20+
body: bodyString,
21+
})
22+
23+
return NextResponse.json(await response.json())
24+
}

src/app/api/music/tencent/route.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
4+
export const POST = async (req: NextRequest) => {
5+
const requestBody = await req.json()
6+
const { songId } = requestBody
7+
8+
const response = await fetch(
9+
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songmid=${songId}&platform=yqq&format=json`,
10+
{
11+
method: 'GET',
12+
headers: {
13+
'Content-Type': 'application/json',
14+
},
15+
},
16+
)
17+
18+
return NextResponse.json(await response.json())
19+
}

src/components/ui/link-card/LinkCard.tsx

+113
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ const LinkCardImpl: FC<LinkCardProps> = (props) => {
9595
[LinkCardSource.GHPr]: fetchGitHubPRData,
9696
[LinkCardSource.Self]: fetchMxSpaceData,
9797
[LinkCardSource.LEETCODE]: fetchLeetCodeQuestionData,
98+
[LinkCardSource.QQMusicSong]: fetchQQMusicSongData,
99+
[LinkCardSource.NeteaseMusicSong]: fetchNeteaseMusicSongData,
98100
} as Record<LinkCardSource, FetchObject>
99101
if (tmdbEnabled)
100102
fetchDataFunctions[LinkCardSource.TMDB] = fetchTheMovieDBData
@@ -615,3 +617,114 @@ const fetchLeetCodeQuestionData: FetchObject = {
615617
}
616618
},
617619
}
620+
621+
const fetchQQMusicSongData: FetchObject = {
622+
isValid: (id) => {
623+
return typeof id === 'string' && id.length > 0
624+
},
625+
fetch: async (id, setCardInfo, _setFullUrl) => {
626+
try {
627+
const songData = await fetch(`/api/music/tencent`, {
628+
method: 'POST',
629+
headers: {
630+
'Content-Type': 'application/json',
631+
},
632+
body: JSON.stringify({ songId: id }),
633+
}).then(async (res) => {
634+
if (!res.ok) {
635+
throw new Error('Failed to fetch QQMusic song title')
636+
}
637+
return res.json()
638+
})
639+
const songInfo = songData.data[0]
640+
const albumId = songInfo.album.mid
641+
setCardInfo({
642+
title: (
643+
<>
644+
<span>{songInfo.title}</span>
645+
{songInfo.subtitle && (
646+
<span className="ml-2 text-sm text-gray-400">
647+
{songInfo.subtitle}
648+
</span>
649+
)}
650+
</>
651+
),
652+
desc: (
653+
<>
654+
<span className="block">
655+
<span className="font-bold">歌手:</span>
656+
<span>
657+
{songInfo.singer.map((person: any) => person.name).join(' / ')}
658+
</span>
659+
</span>
660+
<span className="block">
661+
<span className="font-bold">专辑:</span>
662+
<span>{songInfo.album.name}</span>
663+
</span>
664+
</>
665+
),
666+
image: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumId}.jpg?max_age=2592000`,
667+
color: '#31c27c',
668+
})
669+
} catch (err) {
670+
console.error('Error fetching QQMusic song data:', err)
671+
throw err
672+
}
673+
},
674+
}
675+
676+
const fetchNeteaseMusicSongData: FetchObject = {
677+
isValid: (id) => {
678+
return id.length > 0
679+
},
680+
fetch: async (id, setCardInfo, _setFullUrl) => {
681+
try {
682+
const songData = await fetch(`/api/music/netease`, {
683+
method: 'POST',
684+
headers: {
685+
'Content-Type': 'application/json',
686+
},
687+
body: JSON.stringify({ songId: id }),
688+
}).then(async (res) => {
689+
if (!res.ok) {
690+
throw new Error('Failed to fetch NeteaseMusic song title')
691+
}
692+
return res.json()
693+
})
694+
const songInfo = songData.songs[0]
695+
const albumInfo = songInfo.al
696+
const singerInfo = songInfo.ar
697+
setCardInfo({
698+
title: (
699+
<>
700+
<span>{songInfo.name}</span>
701+
{songInfo.tns && (
702+
<span className="ml-2 text-sm text-gray-400">
703+
{songInfo.tns[0]}
704+
</span>
705+
)}
706+
</>
707+
),
708+
desc: (
709+
<>
710+
<span className="block">
711+
<span className="font-bold">歌手:</span>
712+
<span>
713+
{singerInfo.map((person: any) => person.name).join(' / ')}
714+
</span>
715+
</span>
716+
<span className="block">
717+
<span className="font-bold">专辑:</span>
718+
<span>{albumInfo.name}</span>
719+
</span>
720+
</>
721+
),
722+
image: albumInfo.picUrl,
723+
color: '#e72d2c',
724+
})
725+
} catch (err) {
726+
console.error('Error fetching NeteaseMusic song data:', err)
727+
throw err
728+
}
729+
},
730+
}

src/components/ui/link-card/enums.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ export enum LinkCardSource {
88
GHPr = 'gh-pr',
99
TMDB = 'tmdb',
1010
LEETCODE = 'leetcode',
11+
QQMusicSong = 'qq-music-song',
12+
NeteaseMusicSong = 'netease-music-song',
1113
}

src/components/ui/markdown/renderers/LinkRenderer.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
isGithubRepoUrl,
1818
isGithubUrl,
1919
isLeetCodeUrl,
20+
isNeteaseMusicSongUrl,
21+
isQQMusicSongUrl,
2022
isSelfArticleUrl,
2123
isTMDBUrl,
2224
isTweetUrl,
@@ -152,6 +154,29 @@ export const BlockLinkRenderer = ({
152154
)
153155
}
154156

157+
case isNeteaseMusicSongUrl(url): {
158+
const urlString = url.toString().replaceAll('/#/', '/')
159+
const _url = new URL(urlString)
160+
const id = _url.searchParams.get('id') ?? ''
161+
return (
162+
<LinkCard
163+
fallbackUrl={url.toString()}
164+
source={LinkCardSource.NeteaseMusicSong}
165+
id={id}
166+
/>
167+
)
168+
}
169+
170+
case isQQMusicSongUrl(url): {
171+
return (
172+
<LinkCard
173+
fallbackUrl={url.toString()}
174+
source={LinkCardSource.QQMusicSong}
175+
id={url.pathname.split('/')[4]}
176+
/>
177+
)
178+
}
179+
155180
case isBilibiliVideoUrl(url): {
156181
const { id } = parseBilibiliVideoUrl(url)
157182

src/lib/link-parser.ts

+11
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ export const isLeetCodeUrl = (url: URL) => {
1212
return url.hostname === 'leetcode.cn' || url.hostname === 'leetcode.com'
1313
}
1414

15+
export const isQQMusicSongUrl = (url: URL) => {
16+
return url.hostname === 'y.qq.com' && url.pathname.includes('/songDetail/')
17+
}
18+
19+
export const isNeteaseMusicSongUrl = (url: URL) => {
20+
return (
21+
url.hostname === 'music.163.com' &&
22+
(url.pathname.includes('/song') || url.hash.includes('/song'))
23+
)
24+
}
25+
1526
export const isGithubRepoUrl = (url: URL) => {
1627
return (
1728
url.hostname === GITHUB_HOST &&

0 commit comments

Comments
 (0)