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:
Dustin
2026-08-04 16:35:19 +02:00
parent b97914699e
commit 4226612065
2 changed files with 326 additions and 0 deletions
+138
View File
@@ -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);
});
});
}