/
/
/
1"""Tests for the lyrics helpers."""
2
3from music_assistant.helpers.lyrics import (
4 convert_to_lrc_lyrics,
5 extract_lrc_lyrics,
6 normalize_lrc_lyrics,
7)
8
9LRC_WITH_ID_TAGS = """[ar:Chubby Checker oppure Beatles, The]
10[al:Hits Of The 60's - Vol. 2 - Oldies]
11[ti:Let's Twist Again]
12[au:Written by Kal Mann / Dave Appell, 1961]
13[length: 2:23]
14[offset:+500]
15[by:lrc-author]
16[re:some editor]
17[ve:1.0]
18[#some comment]
19
20[00:12.00]Naku Penda Piya-Naku Taka Piya-Mpenziwe
21[00:15.30]Some more lyrics ...
22"""
23
24LRC_CLEAN = """[00:12.00]Naku Penda Piya-Naku Taka Piya-Mpenziwe
25[00:15.30]Some more lyrics ..."""
26
27
28def test_normalize_strips_id_tags() -> None:
29 """Test that LRC ID tag lines are stripped from LRC lyrics."""
30 assert normalize_lrc_lyrics(LRC_WITH_ID_TAGS) == LRC_CLEAN
31
32
33def test_normalize_no_changes_needed() -> None:
34 """Test that already normalized LRC lyrics are returned unmodified."""
35 assert normalize_lrc_lyrics(LRC_CLEAN) == LRC_CLEAN
36
37
38def test_normalize_preserves_untimed_and_blank_lines() -> None:
39 """Test that untimed lines and blank lines between timed lines are preserved."""
40 lrc = "[00:01.00]First line\n\n[00:05.00]Second line\nuntimed line"
41 assert normalize_lrc_lyrics(lrc) == lrc
42
43
44def test_normalize_empty_input() -> None:
45 """Test that None and empty strings pass through unmodified."""
46 assert normalize_lrc_lyrics(None) is None
47 assert normalize_lrc_lyrics("") == ""
48
49
50def test_normalize_only_id_tags() -> None:
51 """Test that lyrics consisting of only ID tags result in None."""
52 assert normalize_lrc_lyrics("[ar:Some Artist]\n[ti:Some Title]") is None
53
54
55def test_normalize_expands_repeated_lines() -> None:
56 """Test that lines with multiple timestamps are expanded into one line per timestamp."""
57 lrc = "[00:21.10][00:45.10]Repeating lyrics (e.g. chorus)"
58 assert normalize_lrc_lyrics(lrc) == (
59 "[00:21.10]Repeating lyrics (e.g. chorus)\n[00:45.10]Repeating lyrics (e.g. chorus)"
60 )
61
62
63def test_normalize_expands_and_sorts_within_line() -> None:
64 """Test that expanded repeating lines sort chronologically with surrounding lines."""
65 lrc = "[01:45][00:21.10] [00:33:99]Chorus\n[01:00]Second line"
66 assert normalize_lrc_lyrics(lrc) == (
67 "[00:21.10]Chorus\n[00:33:99]Chorus\n[01:00]Second line\n[01:45]Chorus"
68 )
69
70
71def test_normalize_strips_word_timing_tags() -> None:
72 """Test that (unsupported) enhanced LRC word timing tags are stripped."""
73 lrc = "[00:12.00]<00:12.10>Lyrics <00:12.50>with <00:13.00>word timing"
74 assert normalize_lrc_lyrics(lrc) == "[00:12.00]Lyrics with word timing"
75
76
77def test_normalize_full_file_with_repeats() -> None:
78 """Test a full LRC file with ID tags, repeated lines and enhanced word timing tags."""
79 lrc = (
80 "[ti:Some Title]\n"
81 "[offset:-200]\n"
82 "\n"
83 "[00:12.00]First verse line\n"
84 "[00:21.10][01:45.00]The chorus <00:22.00>with <00:23.00>word timing\n"
85 "[00:30.00]Second verse line"
86 )
87 assert normalize_lrc_lyrics(lrc) == (
88 "[00:12.00]First verse line\n"
89 "[00:21.10]The chorus with word timing\n"
90 "[00:30.00]Second verse line\n"
91 "[01:45.00]The chorus with word timing"
92 )
93
94
95def test_normalize_single_timestamp_line_untouched() -> None:
96 """Test that a single-timestamp line keeps its original formatting."""
97 lrc = "[00:01.00] spaced text "
98 assert normalize_lrc_lyrics(lrc) == lrc
99
100
101def test_convert_single_line() -> None:
102 """Test conversion of single line."""
103 assert convert_to_lrc_lyrics([("Lyrics", 0)]) == "[00:00.00]Lyrics"
104 assert convert_to_lrc_lyrics([("Lyrics", 1234)]) == "[00:01.23]Lyrics"
105 assert convert_to_lrc_lyrics([("My lyrics", 1234)]) == "[00:01.23]My lyrics"
106 assert convert_to_lrc_lyrics([(" My lyrics ", 1000)]) == ("[00:01.00]My lyrics")
107 assert convert_to_lrc_lyrics([(" ", 1000)]) == "[00:01.00]"
108 assert convert_to_lrc_lyrics([]) == ""
109
110
111def test_convert_single_line_special_characters() -> None:
112 """Test conversion of single line with special characters."""
113 assert convert_to_lrc_lyrics([("My\nlyrics", 1000)]) == ("[00:01.00]My lyrics")
114 assert convert_to_lrc_lyrics([("My\rlyrics", 1000)]) == ("[00:01.00]My lyrics")
115 assert convert_to_lrc_lyrics([("My\r\nlyrics", 1000)]) == ("[00:01.00]My lyrics")
116 assert convert_to_lrc_lyrics([("\nMy lyrics", 1000)]) == ("[00:01.00]My lyrics")
117 assert convert_to_lrc_lyrics([("My lyrics\r\n", 1000)]) == ("[00:01.00]My lyrics")
118
119
120def test_convert_single_line_special_timestamps() -> None:
121 """Test conversion of single line with special timestamps."""
122 assert convert_to_lrc_lyrics([("One centisecond", 10)]) == ("[00:00.01]One centisecond")
123 assert convert_to_lrc_lyrics([("One second", 1000)]) == ("[00:01.00]One second")
124 assert convert_to_lrc_lyrics([("One minute", 60_000)]) == ("[01:00.00]One minute")
125 assert convert_to_lrc_lyrics([("One hour", 3_600_000)]) == ("[60:00.00]One hour")
126 assert convert_to_lrc_lyrics([("Too small", 1)]) == ("[00:00.00]Too small")
127 assert convert_to_lrc_lyrics([("Truncate", 1239)]) == ("[00:01.23]Truncate")
128 assert convert_to_lrc_lyrics([("Maximum", 99 * 60_000 + 59_999)]) == "[99:59.99]Maximum"
129 assert convert_to_lrc_lyrics([("Too large", 100 * 60_000)]) == ""
130 assert convert_to_lrc_lyrics([("Too large", 999_999_999)]) == ""
131 assert convert_to_lrc_lyrics([("Invalid", -1)]) == ""
132
133
134def test_convert_multiple_lines() -> None:
135 """Test conversion of multiple lines."""
136 lyrics = [
137 ("First line", 0),
138 ("Second line", 1000),
139 ("Third line", 2000),
140 ]
141
142 assert convert_to_lrc_lyrics(lyrics) == (
143 "[00:00.00]First line\n[00:01.00]Second line\n[00:02.00]Third line"
144 )
145
146
147def test_convert_multiple_lines_all_invalid() -> None:
148 """Test conversion of multiple lines that are all invalid."""
149 lyrics = [
150 ("Negative", -100),
151 ("Too large", 100 * 60_000),
152 ]
153
154 assert convert_to_lrc_lyrics(lyrics) == ""
155
156
157def test_convert_multiple_lines_partially_invalid() -> None:
158 """Test conversion of multiple lines that are partially invalid."""
159 lyrics = [
160 ("First line", 0),
161 ("Negative", -1),
162 ("Second line", 1000),
163 ("Negative", -2),
164 ("Third line", 2000),
165 ("Too large", 100 * 60_000),
166 ]
167
168 assert convert_to_lrc_lyrics(lyrics) == (
169 "[00:00.00]First line\n[00:01.00]Second line\n[00:02.00]Third line"
170 )
171
172
173def test_extract_detects_lrc_content() -> None:
174 """Test that LRC formatted text in the plain lyrics tag is detected as-is."""
175 assert extract_lrc_lyrics(LRC_CLEAN) == LRC_CLEAN
176 assert extract_lrc_lyrics(LRC_WITH_ID_TAGS) == LRC_WITH_ID_TAGS
177 assert extract_lrc_lyrics("[2:30]Short timestamp style") == "[2:30]Short timestamp style"
178
179
180def test_extract_rejects_plain_lyrics() -> None:
181 """Test that regular unsynced lyrics text is not detected as LRC."""
182 assert extract_lrc_lyrics("Just some lyrics\nspread over\nmultiple lines") is None
183 assert extract_lrc_lyrics(None) is None
184 assert extract_lrc_lyrics("") is None
185 assert extract_lrc_lyrics("\n\n") is None
186 # ID tag lines alone do not count as synced lyrics content
187 assert extract_lrc_lyrics("[ar:Some Artist]\n[ti:Some Title]") is None
188
189
190def test_extract_rejects_timestamp_mention_in_text() -> None:
191 """Test that a timestamp-like mention within plain lyrics does not trigger detection."""
192 lyrics = (
193 "First plain line\nat [2:30] the beat drops\nanother plain line\nand yet another plain line"
194 )
195 assert extract_lrc_lyrics(lyrics) is None
196
197
198def test_extract_tolerates_some_untimed_lines() -> None:
199 """Test that LRC content with a minority of untimed lines is still detected."""
200 lyrics = "[00:12.00]First line\n[00:15.30]Second line\nuntimed line"
201 assert extract_lrc_lyrics(lyrics) == lyrics
202