/
/
/
1"""Test Plex provider helper functions."""
2
3from unittest.mock import Mock
4
5import pytest
6
7from music_assistant.providers.plex.helpers import (
8 get_explicit,
9 get_musicbrainz_id,
10 parse_plex_lyrics_payload,
11)
12
13SYNCED_JSON = (
14 '{"MediaContainer": {"Lyrics": [{"Line": ['
15 '{"Span": [{"text": "Hello ", "startOffset": 12000},'
16 ' {"text": "world", "startOffset": 12000}]},'
17 '{"Span": [{"text": "second line", "startOffset": 15500}]}'
18 "]}]}}"
19)
20
21LRC_TEXT = "[00:12.00]Hello world\n[00:15.50]second line\n"
22
23PLAIN_TEXT = "Hello world\nsecond line"
24
25VALID_MBID = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"
26
27
28def test_parse_lyrics_structured_json_synced() -> None:
29 """Structured Plex JSON with offsets parses to a synced LRC string."""
30 result = parse_plex_lyrics_payload(SYNCED_JSON)
31 assert result == ("[00:12.00]Hello world\n[00:15.50]second line", True)
32
33
34def test_parse_lyrics_raw_lrc() -> None:
35 """Raw LRC text is detected as synced and returned verbatim (stripped)."""
36 assert parse_plex_lyrics_payload(LRC_TEXT) == (LRC_TEXT.strip(), True)
37
38
39def test_parse_lyrics_plain_text() -> None:
40 """Plain text without timestamps is returned as unsynced lyrics."""
41 assert parse_plex_lyrics_payload(PLAIN_TEXT) == (PLAIN_TEXT, False)
42
43
44@pytest.mark.parametrize("content", ["", " ", "\n\n"])
45def test_parse_lyrics_empty(content: str) -> None:
46 """Empty payloads yield no lyrics."""
47 assert parse_plex_lyrics_payload(content) is None
48
49
50def test_parse_lyrics_json_unsynced() -> None:
51 """Structured JSON without offsets falls back to plain unsynced text."""
52 payload = '{"MediaContainer": {"Lyrics": [{"Line": [{"Span": [{"text": "no timing"}]}]}]}}'
53 assert parse_plex_lyrics_payload(payload) == ("no timing", False)
54
55
56def test_parse_lyrics_malformed_json_as_plain() -> None:
57 """Malformed JSON that is not LRC is treated as plain text."""
58 assert parse_plex_lyrics_payload("{not valid json") == ("{not valid json", False)
59
60
61def test_parse_lyrics_long_offset_no_minute_wrap() -> None:
62 """Offsets beyond one hour keep counting minutes instead of wrapping at 60."""
63 payload = (
64 '{"MediaContainer": {"Lyrics": [{"Line": ['
65 '{"Span": [{"text": "late line", "startOffset": 3661230}]}'
66 "]}]}}"
67 )
68 assert parse_plex_lyrics_payload(payload) == ("[61:01.23]late line", True)
69
70
71def _guid_elem(guid_id: str) -> Mock:
72 elem = Mock()
73 elem.attrib = {"id": guid_id}
74 return elem
75
76
77def _plex_obj(
78 *,
79 guids: list[str] | None = None,
80 attrib: dict[str, str] | None = None,
81) -> Mock:
82 obj = Mock()
83 obj._data.findall.return_value = [_guid_elem(guid_id) for guid_id in (guids or [])]
84 obj._data.attrib = attrib or {}
85 return obj
86
87
88def test_get_musicbrainz_id_from_guid() -> None:
89 """A mbid:// guid yields the bare MusicBrainz identifier."""
90 obj = _plex_obj(guids=["plex://album/abc", f"mbid://{VALID_MBID}"])
91 assert get_musicbrainz_id(obj) == VALID_MBID
92
93
94def test_get_musicbrainz_id_no_mbid() -> None:
95 """Objects without a mbid:// guid return None."""
96 obj = _plex_obj(guids=["plex://album/abc"])
97 assert get_musicbrainz_id(obj) is None
98
99
100@pytest.mark.parametrize(
101 ("content_rating", "expected"),
102 [("explicit", True), ("Explicit", True), ("clean", False), ("", None), (None, None)],
103)
104def test_get_explicit(content_rating: str | None, expected: bool | None) -> None:
105 """Content rating maps to explicit only for the 'explicit' value."""
106 attrib = {"contentRating": content_rating} if content_rating is not None else {}
107 assert get_explicit(_plex_obj(attrib=attrib)) is expected
108