/
/
/
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 extract_album_mid(album_obj: dict[str, Any]) -> str:
63 """Extract QQ album mid from common field variants."""
64 return str(
65 album_obj.get("mid")
66 or album_obj.get("albumMid")
67 or album_obj.get("albumMID")
68 or album_obj.get("album_mid")
69 or album_obj.get("albummid")
70 or album_obj.get("id")
71 or album_obj.get("albumID")
72 or ""
73 )
74
75
76def extract_track_mid(track_obj: dict[str, Any]) -> str:
77 """Extract QQ song mid from common field variants."""
78 return str(
79 track_obj.get("mid")
80 or track_obj.get("songMid")
81 or track_obj.get("songmid")
82 or track_obj.get("id")
83 or ""
84 )
85
86
87def extract_playlist_ids(playlist_obj: dict[str, Any]) -> tuple[Any, Any]:
88 """Extract the QQ (dissid, dirid) pair from common field variants."""
89 dissid = playlist_obj.get("tid") or playlist_obj.get("dissid") or playlist_obj.get("id") or 0
90 dirid = playlist_obj.get("dirid") or playlist_obj.get("dirId") or 0
91 return (dissid, dirid)
92
93
94def normalize_qq_lyric_text(raw_text: str) -> str:
95 """Normalize QQ lyric/qrc payload to readable plain text."""
96 text = html.unescape(raw_text).replace("\r\n", "\n").replace("\r", "\n")
97 text = text.replace("\\n", "\n")
98 text = _QRC_LINE_TIMESTAMP_PATTERN.sub("", text)
99 text = _QRC_WORD_TIMESTAMP_PATTERN.sub("", text)
100 lines: list[str] = []
101 for raw_line in text.split("\n"):
102 cleaned_line = raw_line.strip()
103 if cleaned_line:
104 lines.append(cleaned_line)
105 return "\n".join(lines)
106
107
108def ms_to_lrc_timestamp(ms_value: int) -> str:
109 """Convert milliseconds to LRC timestamp string (mm:ss.xx)."""
110 minute = ms_value // 60000
111 second = (ms_value % 60000) // 1000
112 centisecond = (ms_value % 1000) // 10
113 return f"{minute:02d}:{second:02d}.{centisecond:02d}"
114
115
116def qrc_to_lrc(raw_text: str) -> str:
117 """Convert QQ QRC line-timed lyric text to basic LRC format."""
118 text = html.unescape(raw_text).replace("\r\n", "\n").replace("\r", "\n")
119 text = text.replace("\\n", "\n")
120 lrc_lines: list[str] = []
121 for raw_line in text.split("\n"):
122 line = raw_line.strip()
123 if not line:
124 continue
125 line_match = _QRC_LINE_PREFIX_PATTERN.match(line)
126 if not line_match:
127 continue
128 start_ms = int(line_match.group(1))
129 lyric_content = line[line_match.end() :]
130 lyric_content = _QRC_WORD_TIMESTAMP_PATTERN.sub("", lyric_content).strip()
131 if not lyric_content:
132 continue
133 lrc_lines.append(f"[{ms_to_lrc_timestamp(start_ms)}]{lyric_content}")
134 return "\n".join(lrc_lines)
135