Files
musicdl-catalog-sync-suite/catalog-sync/musicdl/catalogsync/manual_playlists.py
T

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)