mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(lyrics): add Netease Cloud Music as opt-in fallback source
Netease is queried only when server and LRCLIB both return nothing, preserving the existing lyrics chain completely. Off by default. - Rust command `fetch_netease_lyrics` proxies Netease API (CORS bypass) - `src/api/netease.ts` TypeScript wrapper via invoke - `authStore.enableNeteaselyrics` toggle (default: false) - `useLyrics`: Netease fires last in fallback chain when enabled - Settings toggle in the Lyrics section - `lyricsSourceNetease` label in LyricsPane - i18n: all 7 languages (en, de, fr, nl, nb, ru, zh) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -855,6 +855,53 @@ async fn download_update(url: String, filename: String, app: tauri::AppHandle) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches synced lyrics from Netease Cloud Music for a given artist + title.
|
||||
/// Performs a track search, then fetches the LRC string for the best match.
|
||||
/// Returns `None` if no match or no lyrics are found.
|
||||
#[tauri::command]
|
||||
async fn fetch_netease_lyrics(artist: String, title: String) -> Result<Option<String>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let query = format!("{} {}", artist, title);
|
||||
let params = [("s", query.as_str()), ("type", "1"), ("limit", "5")];
|
||||
let search: serde_json::Value = client
|
||||
.post("https://music.163.com/api/search/get")
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", "https://music.163.com")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let song_id = match search["result"]["songs"][0]["id"].as_i64() {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let lyrics: serde_json::Value = client
|
||||
.get(format!(
|
||||
"https://music.163.com/api/song/lyric?id={}&lv=1&kv=1&tv=-1",
|
||||
song_id
|
||||
))
|
||||
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||
.header("Referer", "https://music.163.com")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let lrc = lyrics["lrc"]["lyric"].as_str().unwrap_or("").trim().to_string();
|
||||
Ok(if lrc.is_empty() { None } else { Some(lrc) })
|
||||
}
|
||||
|
||||
/// Reads embedded synced / unsynced lyrics from a local audio file.
|
||||
///
|
||||
/// Priority order:
|
||||
@@ -1607,6 +1654,7 @@ pub fn run() {
|
||||
download_update,
|
||||
open_folder,
|
||||
get_embedded_lyrics,
|
||||
fetch_netease_lyrics,
|
||||
#[cfg(target_os = "windows")]
|
||||
taskbar_win::update_taskbar_icon,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/** Fetches a synced LRC string from Netease Cloud Music via Rust proxy. Returns null if not found. */
|
||||
export async function fetchNeteaselyrics(artist: string, title: string): Promise<string | null> {
|
||||
try {
|
||||
return await invoke<string | null>('fetch_netease_lyrics', { artist, title });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,9 @@ export default function LyricsPane({ currentTrack }: Props) {
|
||||
? t('player.lyricsSourceServer')
|
||||
: source === 'lrclib'
|
||||
? t('player.lyricsSourceLrclib')
|
||||
: null;
|
||||
: source === 'netease'
|
||||
? t('player.lyricsSourceNetease')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="lyrics-pane">
|
||||
|
||||
+24
-3
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
|
||||
import { fetchNeteaselyrics } from '../api/netease';
|
||||
import { getLyricsBySongId, SubsonicStructuredLyrics } from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import type { Track } from '../store/playerStore';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'embedded';
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded';
|
||||
|
||||
export interface CachedLyrics {
|
||||
syncedLines: LrcLine[] | null;
|
||||
@@ -46,7 +47,8 @@ export interface UseLyricsResult {
|
||||
|
||||
export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
const cached = currentTrack ? lyricsCache.get(currentTrack.id) : undefined;
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
const lyricsServerFirst = useAuthStore(s => s.lyricsServerFirst);
|
||||
const enableNeteaselyrics = useAuthStore(s => s.enableNeteaselyrics);
|
||||
|
||||
const [loading, setLoading] = useState(!cached && !!currentTrack);
|
||||
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(cached?.syncedLines ?? null);
|
||||
@@ -142,6 +144,20 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNetease = async (): Promise<boolean> => {
|
||||
try {
|
||||
const lrc = await fetchNeteaselyrics(currentTrack.artist ?? '', currentTrack.title);
|
||||
if (!lrc) return false;
|
||||
const lines = parseLrc(lrc);
|
||||
const synced = lines.length > 0 ? lines : null;
|
||||
if (!synced) return false;
|
||||
store({ syncedLines: synced, plainLyrics: null, source: 'netease', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
// Embedded lyrics from local file always win (most accurate SYLT data).
|
||||
if (cancelled) return;
|
||||
@@ -155,11 +171,16 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
if (await first()) return;
|
||||
if (cancelled) return;
|
||||
if (await second()) return;
|
||||
// Netease as last fallback — only when explicitly enabled
|
||||
if (enableNeteaselyrics) {
|
||||
if (cancelled) return;
|
||||
if (await fetchNetease()) return;
|
||||
}
|
||||
if (!cancelled) store({ syncedLines: null, plainLyrics: null, source: null, notFound: true });
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [currentTrack?.id, enableNeteaselyrics]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { syncedLines, plainLyrics, source, loading, notFound };
|
||||
}
|
||||
|
||||
@@ -486,6 +486,8 @@ export const deTranslation = {
|
||||
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
|
||||
lyricsServerFirst: 'Server-Lyrics bevorzugen',
|
||||
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music Lyrics',
|
||||
enableNeteaselyricsDesc: 'Netease Cloud Music als letzte Lyrics-Quelle verwenden, wenn Server und LRCLIB nichts liefern. Besonders gut für asiatische und internationale Musik.',
|
||||
downloadsTitle: 'ZIP-Export & Archivierung',
|
||||
downloadsFolderDesc: 'Zielverzeichnis für Alben, die du als ZIP-Datei auf deinen Computer herunterlädst.',
|
||||
downloadsDefault: 'Standard-Downloads-Ordner',
|
||||
@@ -817,6 +819,7 @@ export const deTranslation = {
|
||||
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
|
||||
lyricsSourceServer: 'Quelle: Server',
|
||||
lyricsSourceLrclib: 'Quelle: LRCLIB',
|
||||
lyricsSourceNetease: 'Quelle: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song-Infos',
|
||||
|
||||
@@ -487,6 +487,8 @@ export const enTranslation = {
|
||||
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
|
||||
lyricsServerFirst: 'Prefer server lyrics',
|
||||
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music lyrics',
|
||||
enableNeteaselyricsDesc: 'Use Netease Cloud Music as a last-resort lyrics source when server and LRCLIB both return nothing. Best coverage for Asian and international music.',
|
||||
downloadsTitle: 'ZIP Export & Archiving',
|
||||
downloadsFolderDesc: 'Destination folder for albums you download as a ZIP file to your computer.',
|
||||
downloadsDefault: 'Default Downloads Folder',
|
||||
@@ -818,6 +820,7 @@ export const enTranslation = {
|
||||
lyricsNotFound: 'No lyrics found for this track',
|
||||
lyricsSourceServer: 'Source: Server',
|
||||
lyricsSourceLrclib: 'Source: LRCLIB',
|
||||
lyricsSourceNetease: 'Source: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Song Info',
|
||||
|
||||
@@ -484,6 +484,8 @@ export const frTranslation = {
|
||||
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
|
||||
lyricsServerFirst: 'Préférer les paroles du serveur',
|
||||
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
|
||||
enableNeteaselyrics: 'Paroles Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Utiliser Netease Cloud Music en dernier recours lorsque le serveur et LRCLIB ne retournent rien. Meilleure couverture pour la musique asiatique et internationale.',
|
||||
downloadsTitle: 'Export ZIP & Archivage',
|
||||
downloadsFolderDesc: 'Dossier de destination pour les albums téléchargés en tant que fichier ZIP sur votre ordinateur.',
|
||||
downloadsDefault: 'Dossier de téléchargement par défaut',
|
||||
@@ -815,6 +817,7 @@ export const frTranslation = {
|
||||
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
|
||||
lyricsSourceServer: 'Source : Serveur',
|
||||
lyricsSourceLrclib: 'Source : LRCLIB',
|
||||
lyricsSourceNetease: 'Source : Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Infos du morceau',
|
||||
|
||||
@@ -483,6 +483,8 @@ export const nbTranslation = {
|
||||
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
|
||||
lyricsServerFirst: 'Foretrekk server-sangtekst',
|
||||
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
|
||||
enableNeteaselyricsDesc: 'Bruk Netease Cloud Music som siste utvei når server og LRCLIB ikke finner noe. Best dekning for asiatisk og internasjonal musikk.',
|
||||
downloadsTitle: 'ZIP Eksport & Arkivering',
|
||||
downloadsFolderDesc: 'Målmappe for album du laster ned som en ZIP-fil til datamaskinen din.',
|
||||
downloadsDefault: 'Standard nedlastingsmappe',
|
||||
@@ -814,6 +816,7 @@ export const nbTranslation = {
|
||||
lyricsNotFound: 'Ingen sangtekst funnet for dette sporet',
|
||||
lyricsSourceServer: 'Kilde: Server',
|
||||
lyricsSourceLrclib: 'Kilde: LRCLIB',
|
||||
lyricsSourceNetease: 'Kilde: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Sanginfo',
|
||||
|
||||
@@ -484,6 +484,8 @@ export const nlTranslation = {
|
||||
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
|
||||
lyricsServerFirst: 'Server-songtekst voorrang geven',
|
||||
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
|
||||
enableNeteaselyrics: 'Netease Cloud Music songteksten',
|
||||
enableNeteaselyricsDesc: 'Gebruik Netease Cloud Music als laatste bron wanneer server en LRCLIB niets opleveren. Beste dekking voor Aziatische en internationale muziek.',
|
||||
downloadsTitle: 'ZIP-export & Archivering',
|
||||
downloadsFolderDesc: 'Doelmap voor albums die je als ZIP-bestand naar je computer downloadt.',
|
||||
downloadsDefault: 'Standaard downloadmap',
|
||||
@@ -815,6 +817,7 @@ export const nlTranslation = {
|
||||
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
|
||||
lyricsSourceServer: 'Bron: Server',
|
||||
lyricsSourceLrclib: 'Bron: LRCLIB',
|
||||
lyricsSourceNetease: 'Bron: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'Nummerinfo',
|
||||
|
||||
@@ -507,6 +507,8 @@ export const ruTranslation = {
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
lyricsServerFirst: 'Предпочитать серверные тексты',
|
||||
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы использовать LRCLIB сначала.',
|
||||
enableNeteaselyrics: 'Тексты Netease Cloud Music',
|
||||
enableNeteaselyricsDesc: 'Использовать Netease Cloud Music как последний источник текстов, когда сервер и LRCLIB не находят ничего. Лучшее покрытие для азиатской и международной музыки.',
|
||||
downloadsTitle: 'Экспорт ZIP и архивы',
|
||||
downloadsFolderDesc: 'Куда сохранять альбомы в ZIP архиве на диск.',
|
||||
downloadsDefault: 'Папка «Загрузки» по умолчанию',
|
||||
@@ -874,6 +876,7 @@ export const ruTranslation = {
|
||||
lyrics: 'Текст',
|
||||
lyricsLoading: 'Загрузка текста…',
|
||||
lyricsNotFound: 'Текст не найден',
|
||||
lyricsSourceNetease: 'Источник: Netease',
|
||||
},
|
||||
songInfo: {
|
||||
title: 'О треке',
|
||||
|
||||
@@ -480,6 +480,8 @@ export const zhTranslation = {
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
|
||||
enableNeteaselyrics: '网易云音乐歌词',
|
||||
enableNeteaselyricsDesc: '当服务器和 LRCLIB 均无结果时,使用网易云音乐作为最终歌词来源。对亚洲及国际音乐覆盖最佳。',
|
||||
downloadsTitle: 'ZIP 导出与归档',
|
||||
downloadsFolderDesc: '将专辑以 ZIP 文件下载到电脑时的目标文件夹。',
|
||||
downloadsDefault: '默认下载文件夹',
|
||||
@@ -811,6 +813,7 @@ export const zhTranslation = {
|
||||
lyricsNotFound: '未找到此曲目的歌词',
|
||||
lyricsSourceServer: '来源:服务器',
|
||||
lyricsSourceLrclib: '来源:LRCLIB',
|
||||
lyricsSourceNetease: '来源:网易云',
|
||||
},
|
||||
songInfo: {
|
||||
title: '歌曲信息',
|
||||
|
||||
@@ -873,6 +873,17 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.enableNeteaselyrics')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableNeteaselyricsDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.enableNeteaselyrics')}>
|
||||
<input type="checkbox" checked={auth.enableNeteaselyrics} onChange={e => auth.setEnableNeteaselyrics(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ interface AuthState {
|
||||
useCustomTitlebar: boolean;
|
||||
nowPlayingEnabled: boolean;
|
||||
lyricsServerFirst: boolean;
|
||||
enableNeteaselyrics: boolean;
|
||||
showFullscreenLyrics: boolean;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
@@ -170,6 +171,7 @@ interface AuthState {
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setNowPlayingEnabled: (v: boolean) => void;
|
||||
setLyricsServerFirst: (v: boolean) => void;
|
||||
setEnableNeteaselyrics: (v: boolean) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
@@ -256,6 +258,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
useCustomTitlebar: false,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
enableNeteaselyrics: false,
|
||||
showFullscreenLyrics: true,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
@@ -364,6 +367,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
|
||||
setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }),
|
||||
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
||||
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
Reference in New Issue
Block a user