228 lines
4.8 KiB
TypeScript
228 lines
4.8 KiB
TypeScript
|
"use server";
|
||
|
|
||
|
import Fuse, { type IFuseOptions } from "fuse.js";
|
||
|
import axios, { type AxiosResponse } from "axios";
|
||
|
|
||
|
const SEARCH_URL = "https://music.163.com/api/search/get";
|
||
|
const LYRICS_URL = "https://music.163.com/api/song/lyric";
|
||
|
|
||
|
enum LyricSource {
|
||
|
GENIUS = "Genius",
|
||
|
LRCLIB = "lrclib.net",
|
||
|
NETEASE = "NetEase",
|
||
|
}
|
||
|
|
||
|
interface InternetProviderLyricSearchResponse {
|
||
|
artist: string;
|
||
|
id: string;
|
||
|
name: string;
|
||
|
score?: number;
|
||
|
source: LyricSource;
|
||
|
}
|
||
|
|
||
|
interface InternetProviderLyricResponse {
|
||
|
artist: string;
|
||
|
id: string;
|
||
|
lyrics: string;
|
||
|
name: string;
|
||
|
source: LyricSource;
|
||
|
}
|
||
|
|
||
|
interface LyricSearchQuery {
|
||
|
album?: string;
|
||
|
artist?: string;
|
||
|
duration?: number;
|
||
|
name?: string;
|
||
|
}
|
||
|
|
||
|
interface NetEaseResponse {
|
||
|
code: number;
|
||
|
result: Result;
|
||
|
}
|
||
|
|
||
|
interface Result {
|
||
|
hasMore: boolean;
|
||
|
songCount: number;
|
||
|
songs: Song[];
|
||
|
}
|
||
|
|
||
|
interface Song {
|
||
|
album: Album;
|
||
|
alias: string[];
|
||
|
artists: Artist[];
|
||
|
copyrightId: number;
|
||
|
duration: number;
|
||
|
fee: number;
|
||
|
ftype: number;
|
||
|
id: number;
|
||
|
mark: number;
|
||
|
mvid: number;
|
||
|
name: string;
|
||
|
rUrl: null;
|
||
|
rtype: number;
|
||
|
status: number;
|
||
|
transNames?: string[];
|
||
|
}
|
||
|
|
||
|
interface Album {
|
||
|
artist: Artist;
|
||
|
copyrightId: number;
|
||
|
id: number;
|
||
|
mark: number;
|
||
|
name: string;
|
||
|
picId: number;
|
||
|
publishTime: number;
|
||
|
size: number;
|
||
|
status: number;
|
||
|
transNames?: string[];
|
||
|
}
|
||
|
|
||
|
interface Artist {
|
||
|
albumSize: number;
|
||
|
alias: any[];
|
||
|
fansGroup: null;
|
||
|
id: number;
|
||
|
img1v1: number;
|
||
|
img1v1Url: string;
|
||
|
name: string;
|
||
|
picId: number;
|
||
|
picUrl: null;
|
||
|
trans: null;
|
||
|
}
|
||
|
|
||
|
export async function getSearchResults(
|
||
|
params: LyricSearchQuery
|
||
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||
|
let result: AxiosResponse<NetEaseResponse>;
|
||
|
|
||
|
const searchQuery = [params.artist, params.name].join(" ");
|
||
|
|
||
|
if (!searchQuery) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
result = await axios.get(SEARCH_URL, {
|
||
|
params: {
|
||
|
limit: 5,
|
||
|
offset: 0,
|
||
|
s: searchQuery,
|
||
|
type: "1",
|
||
|
},
|
||
|
});
|
||
|
} catch (e) {
|
||
|
console.error("NetEase search request got an error!", e);
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
const rawSongsResult = result?.data.result?.songs;
|
||
|
|
||
|
if (!rawSongsResult) return null;
|
||
|
|
||
|
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map(
|
||
|
(song) => {
|
||
|
const artist = song.artists
|
||
|
? song.artists.map((artist) => artist.name).join(", ")
|
||
|
: "";
|
||
|
|
||
|
return {
|
||
|
artist,
|
||
|
id: String(song.id),
|
||
|
name: song.name,
|
||
|
source: LyricSource.NETEASE,
|
||
|
};
|
||
|
}
|
||
|
);
|
||
|
|
||
|
return orderSearchResults({ params, results: songResults });
|
||
|
}
|
||
|
|
||
|
async function getMatchedLyrics(
|
||
|
params: LyricSearchQuery
|
||
|
): Promise<Omit<InternetProviderLyricResponse, "lyrics"> | null> {
|
||
|
const results = await getSearchResults(params);
|
||
|
|
||
|
const firstMatch = results?.[0];
|
||
|
|
||
|
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return firstMatch;
|
||
|
}
|
||
|
|
||
|
export async function getLyricsBySongId(
|
||
|
songId: string
|
||
|
): Promise<string | null> {
|
||
|
let result: AxiosResponse<any, any>;
|
||
|
try {
|
||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- debug
|
||
|
result = await axios.get(LYRICS_URL, {
|
||
|
params: {
|
||
|
id: songId,
|
||
|
kv: "-1",
|
||
|
lv: "-1",
|
||
|
},
|
||
|
});
|
||
|
} catch (e) {
|
||
|
console.error("NetEase lyrics request got an error!", e);
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- ddd
|
||
|
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||
|
}
|
||
|
|
||
|
export async function query(
|
||
|
params: LyricSearchQuery
|
||
|
): Promise<InternetProviderLyricResponse | null> {
|
||
|
const lyricsMatch = await getMatchedLyrics(params);
|
||
|
if (!lyricsMatch) {
|
||
|
console.error("Could not find the song on NetEase!");
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
const lyrics = await getLyricsBySongId(lyricsMatch.id);
|
||
|
if (!lyrics) {
|
||
|
console.error("Could not get lyrics on NetEase!");
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
artist: lyricsMatch.artist,
|
||
|
id: lyricsMatch.id,
|
||
|
lyrics,
|
||
|
name: lyricsMatch.name,
|
||
|
source: LyricSource.NETEASE,
|
||
|
};
|
||
|
}
|
||
|
|
||
|
const orderSearchResults = (args: {
|
||
|
params: LyricSearchQuery;
|
||
|
results: InternetProviderLyricSearchResponse[];
|
||
|
}): InternetProviderLyricSearchResponse[] => {
|
||
|
const { params, results } = args;
|
||
|
|
||
|
const options: IFuseOptions<InternetProviderLyricSearchResponse> = {
|
||
|
fieldNormWeight: 1,
|
||
|
includeScore: true,
|
||
|
keys: [
|
||
|
{ getFn: (song) => song.name, name: "name", weight: 3 },
|
||
|
{ getFn: (song) => song.artist, name: "artist" },
|
||
|
],
|
||
|
threshold: 1.0,
|
||
|
};
|
||
|
|
||
|
const fuse = new Fuse(results, options);
|
||
|
|
||
|
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
|
||
|
...(params.artist && { artist: params.artist }),
|
||
|
...(params.name && { name: params.name }),
|
||
|
});
|
||
|
|
||
|
return searchResults.map((result) => ({
|
||
|
...result.item,
|
||
|
score: result.score,
|
||
|
}));
|
||
|
};
|