fix(plex): load local lyrics

Closing #1585
This commit is contained in:
edde746
2026-07-16 12:56:16 +02:00
parent 517c3f57ce
commit ff602f1456
4 changed files with 334 additions and 24 deletions
+39 -24
View File
@@ -56,7 +56,6 @@ import '../models/audio_quality_preset.dart';
import '../models/plex/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/device_identity.dart';
import '../utils/lrc_parser.dart';
import '../utils/failover_http_client.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_retry.dart';
@@ -71,6 +70,7 @@ import '../mpv/mpv.dart';
import 'api_cache.dart';
import 'plex_api_cache.dart';
import 'plex_constants.dart';
import 'plex_lyrics_parser.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
@@ -2900,22 +2900,33 @@ class PlexClient
}
/// Plex lyrics: sidecar `.lrc`/`.txt` files surface as track Part streams
/// with `streamType 4`; the raw text lives at the stream's `key`
/// (`/library/streams/{id}`). Returns `null` when the track has no lyric
/// stream (or it can't be fetched) — lyrics are decorative, so errors
/// degrade to "none" rather than failing the caller.
/// with `streamType 4`. Plex normalizes the selected stream to a structured
/// `Lyrics > Line > Span` response at `/library/streams/{id}?format=xml`.
/// Returns `null` when the track has no lyric stream (or it can't be
/// fetched) — lyrics are decorative, so errors degrade to "none" rather
/// than failing the caller.
@override
Future<Lyrics?> fetchLyrics(MediaItem track) async {
try {
final metadataJson = await _fetchRawMetadataJsonCacheFirst(track.id);
final streamKey = _findLyricStreamKey(metadataJson);
var metadataJson = await _fetchRawMetadataJsonCacheFirst(track.id);
var streamKey = _findLyricStreamKey(metadataJson);
if (streamKey == null && !_hasStreamMetadata(metadataJson) && !isOfflineMode) {
final response = await _getWithFailover(
'/library/metadata/${track.id}',
queryParameters: {'checkFiles': 1, 'includeStreams': 1},
);
metadataJson = _getFirstMetadataJson(response);
streamKey = _findLyricStreamKey(metadataJson);
}
if (streamKey == null) return null;
final response = await _getWithFailover(streamKey);
final raw = response.data;
if (raw is! String) return null;
return parseLrc(raw);
} catch (e) {
appLogger.w('Failed to fetch lyrics for ${track.id}', error: e);
final response = await _getWithFailover(
streamKey,
queryParameters: {'format': 'xml'},
headers: const {'Accept': 'application/xml'},
);
return parsePlexLyricsResponse(response.data);
} catch (e, stackTrace) {
appLogger.w('Failed to fetch lyrics for ${track.id}', error: e, stackTrace: stackTrace);
return null;
}
}
@@ -2924,25 +2935,19 @@ class PlexClient
/// ([PlexStreamType.lyrics]), preferring `lrc` (synced) over other
/// formats (`txt`).
String? _findLyricStreamKey(Map<String, dynamic>? metadataJson) {
final mediaList = metadataJson?['Media'];
if (mediaList is! List) return null;
String? fallbackKey;
for (final media in mediaList) {
for (final media in flexibleList(metadataJson?['Media']) ?? const <dynamic>[]) {
if (media is! Map) continue;
final parts = media['Part'];
if (parts is! List) continue;
for (final part in parts) {
for (final part in flexibleList(media['Part']) ?? const <dynamic>[]) {
if (part is! Map) continue;
final streams = part['Stream'];
if (streams is! List) continue;
for (final stream in streams) {
for (final stream in flexibleList(part['Stream']) ?? const <dynamic>[]) {
if (stream is! Map) continue;
if (flexibleInt(stream['streamType']) != PlexStreamType.lyrics) {
continue;
}
final key = stream['key'] as String?;
final key = stream['key']?.toString();
if (key == null || key.isEmpty) continue;
final format = ((stream['format'] ?? stream['codec']) as String?)?.toLowerCase();
final format = (stream['format'] ?? stream['codec'])?.toString().toLowerCase();
if (format == 'lrc') return key;
fallbackKey ??= key;
}
@@ -2951,6 +2956,16 @@ class PlexClient
return fallbackKey;
}
bool _hasStreamMetadata(Map<String, dynamic>? metadataJson) {
for (final media in flexibleList(metadataJson?['Media']) ?? const <dynamic>[]) {
if (media is! Map) continue;
for (final part in flexibleList(media['Part']) ?? const <dynamic>[]) {
if (part is Map && part.containsKey('Stream')) return true;
}
}
return false;
}
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
/// then either runs the transcode-decision flow or returns the direct-play
/// URL. Keyed subtitle tracks remain external sidecars; selected internal
+110
View File
@@ -0,0 +1,110 @@
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)],
);
}