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:
Psychotoxical
2026-04-10 15:11:06 +02:00
parent 33a15fd17a
commit 6ffcd6f6fa
13 changed files with 121 additions and 4 deletions
+48
View File
@@ -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(&params)
.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,
])
+10
View File
@@ -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;
}
}
+3 -1
View File
@@ -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
View File
@@ -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 };
}
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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: 'О треке',
+3
View File
@@ -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: '歌曲信息',
+11
View File
@@ -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>
+4
View File
@@ -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 }),