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
This commit is contained in:
@@ -0,0 +1,188 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo_app/models/song.dart';
|
||||||
|
import 'package:melo_app/services/lyrics_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Blockiert echte HTTP-Requests im Test (Netease-Fallback → 400 → null)
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('parseLrc', () {
|
||||||
|
test('parst einfache [mm:ss.xx]-Zeilen', () {
|
||||||
|
const lrc = '[00:12.34]Erste Zeile\n[00:45.67]Zweite Zeile\n';
|
||||||
|
final zeilen = LyricsService.parseLrc(lrc);
|
||||||
|
expect(zeilen.length, 2);
|
||||||
|
expect(zeilen[0].zeit, const Duration(milliseconds: 12340));
|
||||||
|
expect(zeilen[0].text, 'Erste Zeile');
|
||||||
|
expect(zeilen[1].zeit, const Duration(milliseconds: 45670));
|
||||||
|
expect(zeilen[1].text, 'Zweite Zeile');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unterstützt [mm:ss] ohne Millisekunden', () {
|
||||||
|
const lrc = '[01:05]Nur Sekunden\n';
|
||||||
|
final zeilen = LyricsService.parseLrc(lrc);
|
||||||
|
expect(zeilen.length, 1);
|
||||||
|
expect(zeilen[0].zeit, const Duration(minutes: 1, seconds: 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unterstützt mehrere Zeitstempel pro Zeile', () {
|
||||||
|
const lrc = '[00:10.00][00:20.00][00:30.00]Refrain\n';
|
||||||
|
final zeilen = LyricsService.parseLrc(lrc);
|
||||||
|
expect(zeilen.length, 3);
|
||||||
|
expect(zeilen.map((z) => z.text).toSet(), {'Refrain'});
|
||||||
|
expect(zeilen[0].zeit, const Duration(seconds: 10));
|
||||||
|
expect(zeilen[1].zeit, const Duration(seconds: 20));
|
||||||
|
expect(zeilen[2].zeit, const Duration(seconds: 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignoriert Meta-Tags und wertet [offset:] aus', () {
|
||||||
|
const lrc = '[ti:Testtitel]\n[ar:Testkünstler]\n[offset:500]\n'
|
||||||
|
'[00:10.00]Zeile mit Offset\n';
|
||||||
|
final zeilen = LyricsService.parseLrc(lrc);
|
||||||
|
expect(zeilen.length, 1);
|
||||||
|
expect(zeilen[0].zeit, const Duration(milliseconds: 10500));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortiert Zeilen nach Zeitstempel', () {
|
||||||
|
const lrc = '[00:30.00]Spät\n[00:10.00]Früh\n[00:20.00]Mitte\n';
|
||||||
|
final zeilen = LyricsService.parseLrc(lrc);
|
||||||
|
expect(zeilen.map((z) => z.text).toList(), ['Früh', 'Mitte', 'Spät']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entfernt Karaoke-Tags <...>', () {
|
||||||
|
const lrc = '[00:10.00]<00:10.00>Hal<00:11.00>lo\n';
|
||||||
|
final zeilen = LyricsService.parseLrc(lrc);
|
||||||
|
expect(zeilen.length, 1);
|
||||||
|
expect(zeilen[0].text, 'Hallo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leere Eingabe liefert leere Liste', () {
|
||||||
|
expect(LyricsService.parseLrc(''), isEmpty);
|
||||||
|
expect(LyricsService.parseLrc('nur Text ohne Zeitstempel'), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('indexFuerPosition', () {
|
||||||
|
final zeilen = LyricsService.parseLrc(
|
||||||
|
'[00:10.00]Eins\n[00:20.00]Zwei\n[00:30.00]Drei\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('vor der ersten Zeile → 0', () {
|
||||||
|
expect(
|
||||||
|
LyricsService.indexFuerPosition(const Duration(seconds: 5), zeilen),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genau auf einer Zeile → deren Index', () {
|
||||||
|
expect(
|
||||||
|
LyricsService.indexFuerPosition(const Duration(seconds: 20), zeilen),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zwischen zwei Zeilen → vorherige', () {
|
||||||
|
expect(
|
||||||
|
LyricsService.indexFuerPosition(const Duration(seconds: 25), zeilen),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nach der letzten Zeile → letzter Index', () {
|
||||||
|
expect(
|
||||||
|
LyricsService.indexFuerPosition(const Duration(seconds: 99), zeilen),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leere Liste → -1', () {
|
||||||
|
expect(LyricsService.indexFuerPosition(Duration.zero, []), -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ladeLyrics (lokale .lrc)', () {
|
||||||
|
test('liest .lrc-Datei neben dem Song', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('melo_lrc_test');
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
final songDatei = '${dir.path}/test.mp3';
|
||||||
|
final lrcDatei = '${dir.path}/test.lrc';
|
||||||
|
File(songDatei).writeAsStringSync('dummy');
|
||||||
|
File(lrcDatei).writeAsStringSync('[00:05.00]Lokal\n[00:10.00]Zeile\n');
|
||||||
|
|
||||||
|
final song = Song(
|
||||||
|
titel: 'Test',
|
||||||
|
kuenstler: 'K',
|
||||||
|
dauerSekunden: 60,
|
||||||
|
dateiPfad: songDatei,
|
||||||
|
);
|
||||||
|
final zeilen = await LyricsService().ladeLyrics(song);
|
||||||
|
expect(zeilen, isNotNull);
|
||||||
|
expect(zeilen!.length, 2);
|
||||||
|
expect(zeilen.first.text, 'Lokal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne .lrc-Datei → null', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('melo_lrc_test2');
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
final song = Song(
|
||||||
|
titel: 'Test',
|
||||||
|
kuenstler: 'K',
|
||||||
|
dauerSekunden: 60,
|
||||||
|
dateiPfad: '${dir.path}/ohne.mp3',
|
||||||
|
);
|
||||||
|
// Keine .lrc-Datei anlegen → Netease wird nicht getroffen (kein Netz im Test)
|
||||||
|
final zeilen = await LyricsService().ladeLyrics(song);
|
||||||
|
expect(zeilen, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user