From 6ffcd6f6fae9622c11faa23add707f4c3e3647bb Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 10 Apr 2026 15:11:06 +0200 Subject: [PATCH] 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 --- src-tauri/src/lib.rs | 48 +++++++++++++++++++++++++++++++++++ src/api/netease.ts | 10 ++++++++ src/components/LyricsPane.tsx | 4 ++- src/hooks/useLyrics.ts | 27 +++++++++++++++++--- src/locales/de.ts | 3 +++ src/locales/en.ts | 3 +++ src/locales/fr.ts | 3 +++ src/locales/nb.ts | 3 +++ src/locales/nl.ts | 3 +++ src/locales/ru.ts | 3 +++ src/locales/zh.ts | 3 +++ src/pages/Settings.tsx | 11 ++++++++ src/store/authStore.ts | 4 +++ 13 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/api/netease.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a137668..be04e7b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, 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, ]) diff --git a/src/api/netease.ts b/src/api/netease.ts new file mode 100644 index 00000000..a018ef36 --- /dev/null +++ b/src/api/netease.ts @@ -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 { + try { + return await invoke('fetch_netease_lyrics', { artist, title }); + } catch { + return null; + } +} diff --git a/src/components/LyricsPane.tsx b/src/components/LyricsPane.tsx index c0b84da0..1b17e24b 100644 --- a/src/components/LyricsPane.tsx +++ b/src/components/LyricsPane.tsx @@ -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 (
diff --git a/src/hooks/useLyrics.ts b/src/hooks/useLyrics.ts index e93b6b23..702984b5 100644 --- a/src/hooks/useLyrics.ts +++ b/src/hooks/useLyrics.ts @@ -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(cached?.syncedLines ?? null); @@ -142,6 +144,20 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { } }; + const fetchNetease = async (): Promise => { + 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 }; } diff --git a/src/locales/de.ts b/src/locales/de.ts index 4f2cdd2c..b908004a 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index d09f7412..3ed79e39 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 99add0e6..0ebeb31a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 9e3f8e05..0f051135 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 75bf54ff..acdb2dd3 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index f4527c94..bbc17bd2 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'О треке', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 4cb11103..eeed2453 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '歌曲信息', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 9840141f..f06b8c2c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -873,6 +873,17 @@ export default function Settings() {
+
+
+
+
{t('settings.enableNeteaselyrics')}
+
{t('settings.enableNeteaselyricsDesc')}
+
+ +
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 50efb850..69e78791 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( useCustomTitlebar: false, nowPlayingEnabled: false, lyricsServerFirst: true, + enableNeteaselyrics: false, showFullscreenLyrics: true, showChangelogOnUpdate: true, lastSeenChangelogVersion: '', @@ -364,6 +367,7 @@ export const useAuthStore = create()( 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 }),