from __future__ import annotations from dataclasses import dataclass from pathlib import Path from urllib.parse import parse_qs, urlparse from .models import PlaylistCandidate SUPPORTED_PLATFORMS = {"netease", "qq", "kuwo"} @dataclass class ParsedPlaylistFile: entries: list[PlaylistCandidate] total_lines: int skipped_lines: int def infer_platform_from_url(url: str) -> str | None: parsed = urlparse(url) host = parsed.netloc.lower() if host in {"music.163.com", "163.com"}: return "netease" if host.endswith("y.qq.com") or host == "qq.com": return "qq" if host.endswith("kuwo.cn") or host == "kuwo.cn": return "kuwo" return None def build_playlist_candidate(platform: str, url: str) -> PlaylistCandidate | None: platform = platform.strip().lower() normalized_url = url.strip() if platform not in SUPPORTED_PLATFORMS or not normalized_url: return None if platform == "netease": return _build_netease_candidate(normalized_url) if platform == "qq": return _build_qq_candidate(normalized_url) if platform == "kuwo": return _build_kuwo_candidate(normalized_url) return None def parse_playlist_file(path: str | Path) -> ParsedPlaylistFile: playlist_path = Path(path) raw_text = playlist_path.read_text(encoding="utf-8") lines = raw_text.splitlines() if raw_text.endswith(("\n", "\r")): lines.append("") entries: list[PlaylistCandidate] = [] seen: set[str] = set() skipped_lines = 0 for raw_line in lines: line = raw_line.strip() if not line or line.startswith("#"): continue platform: str | None = None url = line if "," in line: platform_text, url_text = line.split(",", 1) platform = platform_text.strip().lower() url = url_text.strip() if platform is None: platform = infer_platform_from_url(url) candidate = build_playlist_candidate(platform or "", url) if candidate is None: skipped_lines += 1 continue if candidate.playlist_key in seen: continue seen.add(candidate.playlist_key) entries.append(candidate) return ParsedPlaylistFile(entries=entries, total_lines=len(lines), skipped_lines=skipped_lines) def _build_netease_candidate(url: str) -> PlaylistCandidate | None: parsed = urlparse(url) if parsed.netloc.lower() not in {"music.163.com", "163.com"}: return None if not _path_matches(parsed.path, "/playlist") and not _fragment_path_matches(parsed.fragment, "/playlist"): return None remote_id = _extract_query_value(parsed, "id") if not remote_id: return None return PlaylistCandidate( platform="netease", pool_kind="manual_file", remote_id=remote_id, name=remote_id, url=f"https://music.163.com/#/playlist?id={remote_id}", ) def _build_qq_candidate(url: str) -> PlaylistCandidate | None: parsed = urlparse(url) if not (parsed.netloc.lower().endswith("y.qq.com") or parsed.netloc.lower() == "qq.com"): return None path_parts = [part for part in parsed.path.split("/") if part] if len(path_parts) < 2: return None remote_id = path_parts[-1].strip() if not remote_id: return None if "playlist" in path_parts: return PlaylistCandidate( platform="qq", pool_kind="manual_file", remote_id=remote_id, name=remote_id, url=f"https://y.qq.com/n/ryqq/playlist/{remote_id}", ) if "toplist" in path_parts: return PlaylistCandidate( platform="qq", pool_kind="manual_file", remote_id=remote_id, name=remote_id, url=f"https://y.qq.com/n/ryqq/toplist/{remote_id}", parse_strategy="qq_toplist", ) return None def _build_kuwo_candidate(url: str) -> PlaylistCandidate | None: parsed = urlparse(url) if not (parsed.netloc.lower().endswith("kuwo.cn") or parsed.netloc.lower() == "kuwo.cn"): return None path_parts = [part for part in parsed.path.split("/") if part] if "playlist_detail" in path_parts: remote_id = path_parts[-1].strip() if not remote_id: return None return PlaylistCandidate( platform="kuwo", pool_kind="manual_file", remote_id=remote_id, name=remote_id, url=f"https://www.kuwo.cn/playlist_detail/{remote_id}", ) if "rankList" in path_parts: remote_id = _extract_query_value(parsed, "bangId") if not remote_id: return None return PlaylistCandidate( platform="kuwo", pool_kind="manual_file", remote_id=remote_id, name=remote_id, url=f"https://www.kuwo.cn/rankList?bangId={remote_id}", parse_strategy="kuwo_toplist", metadata={"bang_id": remote_id}, ) return None def _extract_query_value(parsed, key: str) -> str | None: for query_text in (parsed.query, urlparse(parsed.fragment).query): value = parse_qs(query_text).get(key) if value and value[0].strip(): return value[0].strip() return None def _path_matches(path: str, expected_suffix: str) -> bool: return path.rstrip("/").endswith(expected_suffix) def _fragment_path_matches(fragment: str, expected_suffix: str) -> bool: if not fragment: return False return urlparse(fragment).path.rstrip("/").endswith(expected_suffix)