From ff602f14564d639b3981cfc599419debff9745ea Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:56:04 +0200 Subject: [PATCH] fix(plex): load local lyrics Closing #1585 --- lib/services/plex_client.dart | 63 +++++---- lib/services/plex_lyrics_parser.dart | 110 ++++++++++++++++ .../plex_client_http_contract_test.dart | 122 ++++++++++++++++++ test/services/plex_lyrics_parser_test.dart | 63 +++++++++ 4 files changed, 334 insertions(+), 24 deletions(-) create mode 100644 lib/services/plex_lyrics_parser.dart create mode 100644 test/services/plex_lyrics_parser_test.dart diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index b039dc45..3d753515 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 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? 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 []) { 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 []) { 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 []) { 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? metadataJson) { + for (final media in flexibleList(metadataJson?['Media']) ?? const []) { + if (media is! Map) continue; + for (final part in flexibleList(media['Part']) ?? const []) { + 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 diff --git a/lib/services/plex_lyrics_parser.dart b/lib/services/plex_lyrics_parser.dart new file mode 100644 index 00000000..ed4c4bab --- /dev/null +++ b/lib/services/plex_lyrics_parser.dart @@ -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 []) { + if (rawLyrics is! Map) continue; + final parsed = <({String text, int? startMs})>[]; + for (final rawLine in flexibleList(rawLyrics['Line']) ?? const []) { + 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 []; + 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)], + ); +} diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index 555c3587..bb41518a 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -145,6 +145,128 @@ void main() { expect(captured?.url.queryParameters, containsPair('genre.locked', '1')); }); + test('lyrics parse Plex structured responses with singleton media shapes', () async { + final requests = []; + final client = makeClient((request) async { + requests.add(request); + if (request.url.path == '/library/metadata/track-1') { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'track-1', + 'type': 'track', + 'Media': { + 'Part': { + 'Stream': { + 'id': '501', + 'key': '/library/streams/501', + 'streamType': '4', + 'codec': 'lrc', + 'format': 'lrc', + }, + }, + }, + }, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/library/streams/501') { + return http.Response( + ''' + + + + + + + + + + + + +''', + 200, + headers: const {'content-type': 'application/xml'}, + ); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final lyrics = await client.fetchLyrics(testMediaItem(id: 'track-1', kind: MediaKind.track)); + + expect(lyrics, isNotNull); + expect(lyrics!.synced, isTrue); + expect(lyrics.lines.map((line) => line.text), ['First line', 'Second line']); + expect(lyrics.lines.map((line) => line.startMs), [500, 4000]); + expect(requests.map((request) => request.url.path), ['/library/metadata/track-1', '/library/streams/501']); + expect(requests.last.url.queryParameters, containsPair('format', 'xml')); + expect(requests.last.url.queryParameters, isNot(contains('includeInlineAttribution'))); + expect(requests.last.headers['accept'], 'application/xml'); + }); + + test('lyrics refresh incomplete cached metadata and prefer LRC streams', () async { + const metadataEndpoint = '/library/metadata/track-1'; + await PlexApiCache.instance.put(ServerId('server-id'), metadataEndpoint, { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'track-1', 'type': 'track'}, + ], + }, + }); + final requestedPaths = []; + final client = makeClient((request) async { + requestedPaths.add(request.url.path); + if (request.url.path == metadataEndpoint) { + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'track-1', + 'type': 'track', + 'Media': [ + { + 'Part': [ + { + 'Stream': [ + {'id': 501, 'key': '/library/streams/501', 'streamType': 4, 'codec': 'txt'}, + {'id': 502, 'key': '/library/streams/502', 'streamType': 4, 'codec': 'lrc'}, + ], + }, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: const {'content-type': 'application/json'}, + ); + } + if (request.url.path == '/library/streams/502') { + return http.Response('[00:01.00]Preferred timed line', 200, headers: const {'content-type': 'text/plain'}); + } + return http.Response('not found', 404); + }); + addTearDown(client.close); + + final lyrics = await client.fetchLyrics(testMediaItem(id: 'track-1', kind: MediaKind.track)); + + expect(lyrics, isNotNull); + expect(lyrics!.synced, isTrue); + expect(lyrics.lines.single.text, 'Preferred timed line'); + expect(requestedPaths, [metadataEndpoint, '/library/streams/502']); + }); + test('cached child fetch rejects decodable HTTP error responses before caching', () async { for (final statusCode in [404, 500]) { final parentId = 'parent-$statusCode'; diff --git a/test/services/plex_lyrics_parser_test.dart b/test/services/plex_lyrics_parser_test.dart new file mode 100644 index 00000000..4c740ff9 --- /dev/null +++ b/test/services/plex_lyrics_parser_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/plex_lyrics_parser.dart'; + +void main() { + test('parses timed Plex XML and joins inline spans', () { + final lyrics = parsePlexLyricsResponse(''' + + + + + + + + + + + + +'''); + + expect(lyrics, isNotNull); + expect(lyrics!.synced, isTrue); + expect(lyrics.lines.map((line) => line.text), ['First line', 'Second line']); + expect(lyrics.lines.map((line) => line.startMs), [500, 4000]); + }); + + test('explicitly untimed Plex lyrics discard synthetic offsets', () { + final lyrics = parsePlexLyricsResponse({ + 'MediaContainer': { + 'Lyrics': { + 'timed': '0', + 'Line': [ + { + 'startOffset': 0, + 'Span': {'text': 'Plain first line'}, + }, + { + 'startOffset': 1000, + 'Span': {'text': 'Plain second line'}, + }, + ], + }, + }, + }); + + expect(lyrics, isNotNull); + expect(lyrics!.synced, isFalse); + expect(lyrics.lines.map((line) => line.text), ['Plain first line', 'Plain second line']); + expect(lyrics.lines.map((line) => line.startMs), everyElement(isNull)); + }); + + test('retains raw LRC and text compatibility', () { + final synced = parsePlexLyricsResponse('[00:01.00]Timed line'); + final plain = parsePlexLyricsResponse('Plain first line\nPlain second line'); + + expect(synced, isNotNull); + expect(synced!.synced, isTrue); + expect(synced.lines.single.startMs, 1000); + expect(plain, isNotNull); + expect(plain!.synced, isFalse); + expect(plain.lines.map((line) => line.text), ['Plain first line', 'Plain second line']); + }); +}