309 lines
8.5 KiB
TypeScript
309 lines
8.5 KiB
TypeScript
import { getConfig } from '@/lib/config';
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
export type MusicSource = 'wy' | 'tx' | 'kw' | 'kg' | 'mg';
|
|
export type MusicQuality = '128k' | '192k' | '320k' | 'flac' | 'flac24bit';
|
|
|
|
export function normalizeMusicSource(source?: string): MusicSource | '' {
|
|
switch ((source || '').trim()) {
|
|
case 'wy':
|
|
case 'tx':
|
|
case 'kw':
|
|
case 'kg':
|
|
case 'mg':
|
|
return source as MusicSource;
|
|
case 'netease':
|
|
return 'wy';
|
|
case 'qq':
|
|
return 'tx';
|
|
case 'kuwo':
|
|
return 'kw';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function normalizeMusicQuality(quality?: string): Exclude<MusicQuality, 'flac24bit'> {
|
|
switch (quality) {
|
|
case '128k':
|
|
case '192k':
|
|
case '320k':
|
|
case 'flac':
|
|
return quality;
|
|
case 'flac24bit':
|
|
return 'flac';
|
|
default:
|
|
return '320k';
|
|
}
|
|
}
|
|
|
|
export interface MusicV2Song {
|
|
songId: string;
|
|
source: MusicSource;
|
|
songmid?: string;
|
|
name: string;
|
|
artist: string;
|
|
album?: string;
|
|
cover?: string;
|
|
durationText?: string;
|
|
durationSec?: number;
|
|
hash?: string;
|
|
copyrightId?: string;
|
|
albumId?: string;
|
|
lrcUrl?: string;
|
|
mrcUrl?: string;
|
|
trcUrl?: string;
|
|
}
|
|
|
|
export interface MusicV2HistoryRecord extends MusicV2Song {
|
|
playProgressSec: number;
|
|
lastPlayedAt: number;
|
|
playCount: number;
|
|
lastQuality?: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface MusicV2PlaylistRecord {
|
|
id: string;
|
|
username: string;
|
|
name: string;
|
|
description?: string;
|
|
cover?: string;
|
|
song_count: number;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}
|
|
|
|
export interface MusicV2PlaylistItem extends MusicV2Song {
|
|
playlistId: string;
|
|
sortOrder: number;
|
|
addedAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface LxServerSong {
|
|
id: string;
|
|
name: string;
|
|
singer: string;
|
|
source: string;
|
|
interval?: string;
|
|
albumName?: string;
|
|
img?: string;
|
|
songmid?: string;
|
|
}
|
|
|
|
export function isMusicSource(source: string | null | undefined): source is MusicSource {
|
|
return !!source && ['wy', 'tx', 'kw', 'kg', 'mg'].includes(source);
|
|
}
|
|
|
|
export function parseDurationTextToSec(durationText?: string): number | undefined {
|
|
if (!durationText) return undefined;
|
|
const parts = durationText.split(':').map(part => Number(part));
|
|
if (parts.length !== 2 || parts.some(num => Number.isNaN(num))) {
|
|
return undefined;
|
|
}
|
|
return parts[0] * 60 + parts[1];
|
|
}
|
|
|
|
export function normalizeSong(input: Partial<MusicV2Song> & {
|
|
songId?: string;
|
|
id?: string;
|
|
source?: string;
|
|
name?: string;
|
|
artist?: string;
|
|
singer?: string;
|
|
songmid?: string;
|
|
album?: string;
|
|
albumName?: string;
|
|
cover?: string;
|
|
pic?: string;
|
|
img?: string;
|
|
durationText?: string;
|
|
interval?: string;
|
|
durationSec?: number;
|
|
hash?: string;
|
|
copyrightId?: string;
|
|
albumId?: string;
|
|
lrcUrl?: string;
|
|
mrcUrl?: string;
|
|
trcUrl?: string;
|
|
}): MusicV2Song {
|
|
const source = normalizeMusicSource(input.source) as MusicSource;
|
|
const rawSongId = (input.songId || input.id || '').trim();
|
|
const derivedSongmid = String(input.songmid || '').trim();
|
|
const songId = rawSongId || (source && derivedSongmid ? `${source}_${derivedSongmid}` : '');
|
|
const durationText = input.durationText || input.interval;
|
|
const durationSec = input.durationSec ?? parseDurationTextToSec(durationText);
|
|
|
|
return {
|
|
songId,
|
|
source,
|
|
songmid: derivedSongmid || songId.split('_').slice(1).join('_') || undefined,
|
|
name: (input.name || '').trim(),
|
|
artist: (input.artist || input.singer || '').trim(),
|
|
album: input.album || input.albumName || undefined,
|
|
cover: input.cover || input.pic || input.img || undefined,
|
|
durationText: durationText || undefined,
|
|
durationSec,
|
|
hash: input.hash || undefined,
|
|
copyrightId: input.copyrightId || undefined,
|
|
albumId: input.albumId || undefined,
|
|
lrcUrl: input.lrcUrl || undefined,
|
|
mrcUrl: input.mrcUrl || undefined,
|
|
trcUrl: input.trcUrl || undefined,
|
|
};
|
|
}
|
|
|
|
export function normalizeLxSong(song: LxServerSong): MusicV2Song {
|
|
return normalizeSong({
|
|
songId: song.id,
|
|
source: song.source as MusicSource,
|
|
songmid: song.songmid,
|
|
name: song.name,
|
|
artist: song.singer,
|
|
album: song.albumName,
|
|
cover: song.img,
|
|
durationText: song.interval,
|
|
});
|
|
}
|
|
|
|
export function unwrapLxArray<T>(payload: any): T[] {
|
|
if (Array.isArray(payload)) return payload as T[];
|
|
if (Array.isArray(payload?.list)) return payload.list as T[];
|
|
if (Array.isArray(payload?.data)) return payload.data as T[];
|
|
if (Array.isArray(payload?.data?.list)) return payload.data.list as T[];
|
|
if (Array.isArray(payload?.data?.data)) return payload.data.data as T[];
|
|
return [];
|
|
}
|
|
|
|
export async function getMusicV2Config() {
|
|
const config = await getConfig();
|
|
const musicConfig = config?.MusicConfig;
|
|
|
|
const enabled = musicConfig?.Enabled ?? false;
|
|
const baseUrl = (musicConfig?.BaseUrl || process.env.MUSIC_V2_BASE_URL || '').replace(/\/$/, '');
|
|
const token = musicConfig?.Token || process.env.MUSIC_V2_TOKEN || '';
|
|
|
|
return { enabled, baseUrl, token };
|
|
}
|
|
|
|
type LxFetchAuthMode = 'auto' | 'required' | 'none';
|
|
|
|
async function lxFetch(path: string, init: RequestInit = {}, authMode: LxFetchAuthMode = 'auto') {
|
|
const { enabled, baseUrl, token } = await getMusicV2Config();
|
|
|
|
if (!enabled) {
|
|
throw new Error('音乐功能未开启');
|
|
}
|
|
if (!baseUrl) {
|
|
throw new Error('未配置音乐服务地址');
|
|
}
|
|
|
|
const headers = new Headers(init.headers || {});
|
|
if (!headers.has('Content-Type') && init.body) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
headers.set('Accept', 'application/json');
|
|
if (authMode !== 'none' && token) {
|
|
headers.set('x-user-token', token);
|
|
} else if (authMode === 'required' && !token) {
|
|
throw new Error('未配置音乐服务访问 Token');
|
|
}
|
|
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
...init,
|
|
headers,
|
|
signal: AbortSignal.timeout(15000),
|
|
cache: 'no-store',
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
export async function lxGetJson<T>(path: string, authMode: LxFetchAuthMode = 'auto'): Promise<T> {
|
|
const response = await lxFetch(path, {}, authMode);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || `请求失败(${response.status})`);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export async function lxPostJson<T>(path: string, body: any, authMode: LxFetchAuthMode = 'auto'): Promise<T> {
|
|
const response = await lxFetch(path, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}, authMode);
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || `请求失败(${response.status})`);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export function extractSongmid(song: Pick<MusicV2Song, 'songId' | 'songmid'>) {
|
|
return song.songmid || song.songId.split('_').slice(1).join('_');
|
|
}
|
|
|
|
function normalizeLyricPayload(payload: any) {
|
|
return {
|
|
lyric: typeof payload?.lyric === 'string'
|
|
? payload.lyric
|
|
: typeof payload?.lrc === 'string'
|
|
? payload.lrc
|
|
: '',
|
|
tlyric: typeof payload?.tlyric === 'string'
|
|
? payload.tlyric
|
|
: typeof payload?.trc === 'string'
|
|
? payload.trc
|
|
: '',
|
|
};
|
|
}
|
|
|
|
export async function fetchLxLyric(song: MusicV2Song) {
|
|
const songmid = extractSongmid(song);
|
|
const query = new URLSearchParams({
|
|
source: song.source,
|
|
songmid,
|
|
});
|
|
|
|
if (song.songId) query.set('id', song.songId);
|
|
if (song.name) query.set('name', song.name);
|
|
if (song.artist) query.set('singer', song.artist);
|
|
if (song.hash) query.set('hash', song.hash);
|
|
if (song.durationText) query.set('interval', song.durationText);
|
|
if (song.copyrightId) query.set('copyrightId', song.copyrightId);
|
|
if (song.albumId) query.set('albumId', song.albumId);
|
|
if (song.lrcUrl) query.set('lrcUrl', song.lrcUrl);
|
|
if (song.mrcUrl) query.set('mrcUrl', song.mrcUrl);
|
|
if (song.trcUrl) query.set('trcUrl', song.trcUrl);
|
|
|
|
try {
|
|
const payload = await lxGetJson<any>(`/api/music/lyric?${query.toString()}`, 'none');
|
|
return normalizeLyricPayload(payload);
|
|
} catch {
|
|
const payload = await lxPostJson<any>('/api/music/lyric', {
|
|
songInfo: {
|
|
source: song.source,
|
|
id: song.songId,
|
|
songId: songmid,
|
|
songmid,
|
|
name: song.name,
|
|
singer: song.artist,
|
|
artist: song.artist,
|
|
hash: song.hash,
|
|
interval: song.durationText,
|
|
copyrightId: song.copyrightId,
|
|
albumId: song.albumId,
|
|
lrcUrl: song.lrcUrl,
|
|
mrcUrl: song.mrcUrl,
|
|
trcUrl: song.trcUrl,
|
|
},
|
|
}, 'none');
|
|
|
|
return normalizeLyricPayload(payload);
|
|
}
|
|
}
|