This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/lyrics_service.dart
T
Dustin 4226612065 v2.49 — Lyrics-Service (LRC + Netease)
## LyricsService (neu: lib/services/lyrics_service.dart)
- LyricsZeile-Modell (zeit + text)
- LRC-Parser: [mm:ss], [mm:ss.xx], [mm:ss.xxx], mehrere Zeitstempel/Zeile, [offset:±ms], Meta-Tags ignoriert, Karaoke-Tags <...> entfernt, sortiert
- Quellen: lokale .lrc neben dem Song (song.dateiPfad → .lrc) → Netease-API (Suche + Lyric, public, kein Key) → null
- Keine neuen Packages (nur http)

## Tests (test/lyrics_service_test.dart, 14 neu)
- parseLrc: Formate, Multi-Timestamps, Offset, Sortierung, Karaoke-Tags
- indexFuerPosition: Grenzfälle
- ladeLyrics: lokale .lrc-Datei (Temp-Datei), ohne Datei → null (HTTP im Test geblockt)
- flutter analyze 0 Issues, Tests 35/35
2026-08-04 16:35:19 +02:00

189 lines
6.5 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../models/song.dart';
/// Eine synchronisierte Lyrik-Zeile mit Zeitstempel.
class LyricsZeile {
final Duration zeit;
final String text;
const LyricsZeile(this.zeit, this.text);
}
/// Lädt Songtexte für einen Song.
///
/// Quellen (in dieser Reihenfolge):
/// 1. Lokale `.lrc`-Datei neben dem Song (`song.dateiPfad` mit `.lrc`-Endung)
/// 2. Netease-API (public, kein Key) — Suche nach Titel+Künstler, dann Lyrics
/// 3. Kein Treffer → `null` (UI zeigt freundlichen Hinweis)
class LyricsService {
static final LyricsService _instanz = LyricsService._();
factory LyricsService() => _instanz;
LyricsService._();
static const _neteaseSearchUrl = 'https://music.163.com/api/search/get/web';
static const _neteaseLyricUrl = 'https://music.163.com/api/song/lyric';
static const _timeout = Duration(seconds: 8);
/// Netease akzeptiert Requests nur mit Browser-ähnlichen Headern.
static const _header = <String, String>{
'User-Agent':
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36 Melo/2.49',
'Referer': 'https://music.163.com/',
'Accept': 'application/json, text/plain, */*',
};
/// Lädt Lyrics für [song]: erst lokale `.lrc`-Datei, dann Netease.
/// Gibt `null` zurück, wenn keine Quelle liefert.
Future<List<LyricsZeile>?> ladeLyrics(Song song) async {
final lokal = await _ladeLokaleLrc(song);
if (lokal != null && lokal.isNotEmpty) return lokal;
try {
final netease = await _ladeVonNetease(song);
if (netease != null && netease.isNotEmpty) return netease;
} catch (_) {
// Netzwerk-/Parse-Fehler → still kein Lyrics (Fallback-Hinweis in der UI)
}
return null;
}
/// a) Lokale `.lrc`-Datei neben der Audiodatei (z.B. `song.mp3` → `song.lrc`).
Future<List<LyricsZeile>?> _ladeLokaleLrc(Song song) async {
if (song.dateiPfad.isEmpty) return null;
try {
final lrcPfad = song.dateiPfad.replaceFirst(
RegExp(r'\.[^./\\]+$'),
'.lrc',
);
if (lrcPfad == song.dateiPfad) return null;
final file = File(lrcPfad);
if (!await file.exists()) return null;
final inhalt = await file.readAsString();
final zeilen = parseLrc(inhalt);
return zeilen.isEmpty ? null : zeilen;
} catch (_) {
return null;
}
}
/// b) Netease-API: Sucht den Song und lädt dessen LRC-Text.
Future<List<LyricsZeile>?> _ladeVonNetease(Song song) async {
final query = '${song.titel} ${song.kuenstler}'.trim();
if (query.isEmpty) return null;
final client = http.Client();
try {
// 1) Song-ID über die Websuche finden
final searchUri = Uri.parse(_neteaseSearchUrl).replace(
queryParameters: {
'csrf_token': '',
's': query,
'type': '1',
'offset': '0',
'total': 'true',
'limit': '5',
},
);
final searchRes =
await client.get(searchUri, headers: _header).timeout(_timeout);
if (searchRes.statusCode != 200) return null;
final searchData =
jsonDecode(utf8.decode(searchRes.bodyBytes)) as Map<String, dynamic>;
final songs = (searchData['result']?['songs']) as List? ?? const [];
if (songs.isEmpty) return null;
final id = (songs.first as Map<String, dynamic>)['id'];
if (id == null) return null;
// 2) LRC-Text zur Song-ID laden
final lyricUri = Uri.parse(_neteaseLyricUrl).replace(
queryParameters: {
'id': '$id',
'lv': '-1',
'kv': '-1',
'tv': '-1',
},
);
final lyricRes =
await client.get(lyricUri, headers: _header).timeout(_timeout);
if (lyricRes.statusCode != 200) return null;
final lyricData =
jsonDecode(utf8.decode(lyricRes.bodyBytes)) as Map<String, dynamic>;
final lrcText =
(lyricData['lrc'] as Map<String, dynamic>?)?['lyric'] as String?;
if (lrcText == null || lrcText.trim().isEmpty) return null;
final zeilen = parseLrc(lrcText);
return zeilen.isEmpty ? null : zeilen;
} catch (_) {
return null;
} finally {
client.close();
}
}
// ─── LRC-Parser ───
/// Parst LRC-Text in synchronisierte Zeilen.
///
/// Unterstützt `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, mehrere
/// Zeitstempel pro Zeile sowie den `[offset:±ms]`-Meta-Tag.
/// Meta-Tags (`[ti:]`, `[ar:]`, `[al:]`, `[by:]`, …) werden ignoriert.
static List<LyricsZeile> parseLrc(String inhalt) {
final zeilen = <LyricsZeile>[];
var offsetMs = 0;
final zeitRegex = RegExp(r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]');
for (final rohZeile in inhalt.split(RegExp(r'\r?\n'))) {
final zeile = rohZeile.trim();
if (zeile.isEmpty) continue;
// Meta-Tag-Zeilen überspringen, aber [offset:…] auswerten
if (zeile.startsWith('[') && !zeitRegex.hasMatch(zeile)) {
final offsetMatch =
RegExp(r'^\[offset:([+-]?\d+)\]').firstMatch(zeile);
if (offsetMatch != null) {
offsetMs = int.tryParse(offsetMatch.group(1)!) ?? 0;
}
continue;
}
final matches = zeitRegex.allMatches(zeile).toList();
if (matches.isEmpty) continue;
// Text = Rest nach dem letzten Zeitstempel; Karaoke-Tags `<…>` entfernen
var text = zeile.substring(matches.last.end).trim();
text = text.replaceAll(RegExp(r'<[^>]*>'), '').trim();
for (final m in matches) {
final min = int.parse(m.group(1)!);
final sek = int.parse(m.group(2)!);
final msStr = m.group(3);
var ms = 0;
if (msStr != null && msStr.isNotEmpty) {
ms = int.parse(msStr.padRight(3, '0').substring(0, 3));
}
var zeit = Duration(minutes: min, seconds: sek, milliseconds: ms);
if (offsetMs != 0) {
zeit += Duration(milliseconds: offsetMs);
if (zeit.isNegative) zeit = Duration.zero;
}
zeilen.add(LyricsZeile(zeit, text));
}
}
zeilen.sort((a, b) => a.zeit.compareTo(b.zeit));
return zeilen;
}
/// Index der aktiven Zeile für [position]:
/// die letzte Zeile, deren Zeitstempel ≤ [position] ist.
/// Gibt 0 zurück, wenn [position] vor der ersten Zeile liegt.
static int indexFuerPosition(Duration position, List<LyricsZeile> zeilen) {
if (zeilen.isEmpty) return -1;
for (var i = zeilen.length - 1; i >= 0; i--) {
if (zeilen[i].zeit <= position) return i;
}
return 0;
}
}