@@ -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
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
}
|
||||
@@ -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 = <http.Request>[];
|
||||
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(
|
||||
'''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MediaContainer size="1">
|
||||
<Lyrics timed="1">
|
||||
<Line startOffset="500">
|
||||
<Span text="First line" />
|
||||
</Line>
|
||||
<Line startOffset="4000">
|
||||
<Span text="Second " />
|
||||
<Span text="line" />
|
||||
</Line>
|
||||
</Lyrics>
|
||||
</MediaContainer>
|
||||
''',
|
||||
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 = <String>[];
|
||||
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';
|
||||
|
||||
@@ -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('''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MediaContainer size="1">
|
||||
<Lyrics provider="com.plexapp.agents.localmedia" timed="1">
|
||||
<Line startOffset="500">
|
||||
<Span text="First line" />
|
||||
</Line>
|
||||
<Line startOffset="4000">
|
||||
<Span text="Second " />
|
||||
<Span text="line" />
|
||||
</Line>
|
||||
</Lyrics>
|
||||
</MediaContainer>
|
||||
''');
|
||||
|
||||
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']);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user