Files
plezy/lib/services/plex_lyrics_parser.dart
T
2026-07-16 12:56:16 +02:00

111 lines
3.8 KiB
Dart

import 'dart:convert';
import 'package:xml/xml.dart';
import '../media/lyrics.dart';
import '../utils/json_utils.dart';
import '../utils/lrc_parser.dart';
/// Parse the response from Plex's `/library/streams/{id}?format=xml` lyrics
/// endpoint.
///
/// Plex normally serializes its `MediaContainer > Lyrics > Line > Span`
/// document as JSON when the client sends `Accept: application/json`. Raw XML
/// is accepted for older servers/proxies, and raw LRC/TXT remains a fallback
/// for implementations that expose the sidecar file directly.
Lyrics? parsePlexLyricsResponse(Object? raw) {
if (raw is Map) return _parseJsonLyrics(raw);
if (raw is! String) return null;
final trimmed = raw.trimLeft();
if (trimmed.isEmpty) return null;
if (trimmed.startsWith('<')) {
try {
return _parseXmlLyrics(trimmed);
} on XmlParserException {
return null;
}
}
if (trimmed.startsWith('{')) {
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map) return _parseJsonLyrics(decoded);
} on FormatException {
return null;
}
}
return parseLrc(raw);
}
Lyrics? _parseJsonLyrics(Map response) {
final container = response['MediaContainer'];
if (container is! Map) return null;
for (final rawLyrics in flexibleList(container['Lyrics']) ?? const <dynamic>[]) {
if (rawLyrics is! Map) continue;
final parsed = <({String text, int? startMs})>[];
for (final rawLine in flexibleList(rawLyrics['Line']) ?? const <dynamic>[]) {
if (rawLine is String) {
final text = rawLine.trim();
if (text.isNotEmpty) parsed.add((text: text, startMs: null));
continue;
}
if (rawLine is! Map) continue;
final spans = flexibleList(rawLine['Span']) ?? const <dynamic>[];
final text = spans.isEmpty
? (rawLine['text'] ?? rawLine['Text'])?.toString().trim() ?? ''
: spans
.map((span) => span is Map ? (span['text'] ?? span['Text'])?.toString() ?? '' : span.toString())
.join()
.trim();
if (text.isEmpty) continue;
Map? firstSpan;
for (final span in spans) {
if (span is Map) {
firstSpan = span;
break;
}
}
parsed.add((text: text, startMs: flexibleInt(rawLine['startOffset']) ?? flexibleInt(firstSpan?['startOffset'])));
}
final lyrics = _buildLyrics(parsed, timed: flexibleBoolNullable(rawLyrics['timed']));
if (lyrics != null) return lyrics;
}
return null;
}
Lyrics? _parseXmlLyrics(String raw) {
final document = XmlDocument.parse(raw);
for (final lyricsElement in document.findAllElements('Lyrics')) {
final parsed = <({String text, int? startMs})>[];
for (final lineElement in lyricsElement.findElements('Line')) {
final spans = lineElement.findElements('Span').toList(growable: false);
final text = spans.isEmpty
? (lineElement.getAttribute('text') ?? lineElement.innerText).trim()
: spans.map((span) => span.getAttribute('text') ?? span.innerText).join().trim();
if (text.isEmpty) continue;
parsed.add((
text: text,
startMs:
flexibleInt(lineElement.getAttribute('startOffset')) ??
(spans.isEmpty ? null : flexibleInt(spans.first.getAttribute('startOffset'))),
));
}
final lyrics = _buildLyrics(parsed, timed: flexibleBoolNullable(lyricsElement.getAttribute('timed')));
if (lyrics != null) return lyrics;
}
return null;
}
Lyrics? _buildLyrics(List<({String text, int? startMs})> parsed, {required bool? timed}) {
if (parsed.isEmpty) return null;
final synced = timed != false && parsed.any((line) => line.startMs != null);
return Lyrics(
synced: synced,
lines: [for (final line in parsed) LyricLine(text: line.text, startMs: synced ? line.startMs : null)],
);
}