180 lines
5.6 KiB
Python
180 lines
5.6 KiB
Python
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)
|