/
/
1"""Helper utilities for QQ Music provider."""
2
3from __future__ import annotations
4
5import html
6import re
7from typing import Any
8
9_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
10_QRC_LINE_TIMESTAMP_PATTERN = re.compile(r"\[\d+,\d+\]")
11_QRC_LINE_PREFIX_PATTERN = re.compile(r"^\[(\d+),(\d+)\]")
12_QRC_WORD_TIMESTAMP_PATTERN = re.compile(r"\(\d+,\d+\)")
13
14
15def clean_text(value: Any, fallback: str = "") -> str:
16 """Normalize searchable/display text from QQ payload."""
17 if value is None:
18 return fallback
19 text = str(value).strip()
20 if not text:
21 return fallback
22 text = html.unescape(html.unescape(text))
23 text = _HTML_TAG_PATTERN.sub("", text)
24 text = " ".join(text.split())
25 return text or fallback
26
27
28def extract_first_text(data: dict[str, Any], keys: tuple[str, ...], fallback: str = "") -> str:
29 """Extract first non-empty text by key candidates."""
30 for key in keys:
31 val = data.get(key)
32 if isinstance(val, str) and clean_text(val):
33 return clean_text(val)
34 return fallback
35
36
37def normalize_image_url(raw: Any) -> str:
38 """Normalize QQ image URL (supports protocol-relative URLs)."""
39 url = clean_text(raw)
40 if not url:
41 return ""
42 if url.startswith("//"):
43 return f"https:{url}"
44 if url.startswith(("http://", "https://")):
45 return url
46 return ""
47
48
49def extract_artist_mid(artist_obj: dict[str, Any]) -> str:
50 """Extract QQ singer mid from common field variants."""
51 return str(
52 artist_obj.get("mid")
53 or artist_obj.get("MID")
54 or artist_obj.get("singerMid")
55 or artist_obj.get("singerMID")
56 or artist_obj.get("SingerMid")
57 or artist_obj.get("singer_mid")
58 or ""
59 )
60
61
62def normalize_qq_lyric_text(raw_text: str) -> str:
63 """Normalize QQ lyric/qrc payload to readable plain text."""
64 text = html.unescape(raw_text).replace("\r\n", "\n").replace("\r", "\n")
65 text = text.replace("\\n", "\n")
66 text = _QRC_LINE_TIMESTAMP_PATTERN.sub("", text)
67 text = _QRC_WORD_TIMESTAMP_PATTERN.sub("", text)
68 lines: list[str] = []
69 for raw_line in text.split("\n"):
70 cleaned_line = raw_line.strip()
71 if cleaned_line:
72 lines.append(cleaned_line)
73 return "\n".join(lines)
74
75
76def ms_to_lrc_timestamp(ms_value: int) -> str:
77 """Convert milliseconds to LRC timestamp string (mm:ss.xx)."""
78 minute = ms_value // 60000
79 second = (ms_value % 60000) // 1000
80 centisecond = (ms_value % 1000) // 10
81 return f"{minute:02d}:{second:02d}.{centisecond:02d}"
82
83
84def qrc_to_lrc(raw_text: str) -> str:
85 """Convert QQ QRC line-timed lyric text to basic LRC format."""
86 text = html.unescape(raw_text).replace("\r\n", "\n").replace("\r", "\n")
87 text = text.replace("\\n", "\n")
88 lrc_lines: list[str] = []
89 for raw_line in text.split("\n"):
90 line = raw_line.strip()
91 if not line:
92 continue
93 line_match = _QRC_LINE_PREFIX_PATTERN.match(line)
94 if not line_match:
95 continue
96 start_ms = int(line_match.group(1))
97 lyric_content = line[line_match.end() :]
98 lyric_content = _QRC_WORD_TIMESTAMP_PATTERN.sub("", lyric_content).strip()
99 if not lyric_content:
100 continue
101 lrc_lines.append(f"[{ms_to_lrc_timestamp(start_ms)}]{lyric_content}")
102 return "\n".join(lrc_lines)
103