"""Resolve NetEase song IDs to short-lived playable audio URLs.""" from __future__ import annotations import logging import re import urllib.parse from typing import Any import aiohttp class NeteaseResolver: def __init__( self, logger: logging.Logger, *, api_base: str = "https://music.163.com", music_u: str = "", ): self.logger = logger self.api_base = api_base.rstrip("/") self.music_u = str(music_u or "").strip() self.last_error_code = "" def update_api_base(self, api_base: str): self.api_base = str(api_base or "https://music.163.com").rstrip("/") def update_auth(self, music_u: str): self.music_u = str(music_u or "").strip() def _headers(self) -> dict[str, str]: cookie = "os=pc; appver=2.9.8;" if self.music_u: cookie += f" MUSIC_U={self.music_u};" return { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://music.163.com/", "Cookie": cookie, "X-Real-IP": "218.75.111.114", "X-Forwarded-For": "218.75.111.114", } async def _probe_url(self, url: str) -> str: timeout = aiohttp.ClientTimeout(total=12) async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session: try: async with session.get(url, allow_redirects=True, headers={"Range": "bytes=0-1"}) as resp: content_type = str(resp.headers.get("Content-Type", "")).lower() if resp.status in (200, 206) and ("audio" in content_type or "octet-stream" in content_type): return str(resp.url) except Exception: return "" return "" async def _get_json(self, url: str) -> dict[str, Any] | None: timeout = aiohttp.ClientTimeout(total=12) try: async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session: async with session.get(url, allow_redirects=True) as resp: if resp.status != 200: return None payload = await resp.json(content_type=None) return payload if isinstance(payload, dict) else None except Exception: return None async def account_status(self) -> dict[str, Any]: if not self.music_u: return {"authenticated": False} payload = await self._get_json(f"{self.api_base}/api/nuser/account/get") if not payload: return {"authenticated": False} account = payload.get("account") profile = payload.get("profile") if not isinstance(account, dict) or not account.get("id"): return {"authenticated": False} profile = profile if isinstance(profile, dict) else {} return { "authenticated": True, "user_id": str(account.get("id") or ""), "nickname": str(profile.get("nickname") or "网易云用户"), "vip_type": int(account.get("vipType") or profile.get("vipType") or 0), } async def _fetch_player_entry(self, song_id: str) -> dict[str, Any] | None: url = ( f"{self.api_base}/api/song/enhance/player/url" f"?ids=%5B{urllib.parse.quote(song_id)}%5D&br=320000" ) payload = await self._get_json(url) entries = payload.get("data") if payload else None if not isinstance(entries, list) or not entries or not isinstance(entries[0], dict): return None return entries[0] @staticmethod def _is_trial_entry(entry: dict[str, Any]) -> bool: if entry.get("freeTrialInfo"): return True privilege = entry.get("freeTrialPrivilege") if not isinstance(privilege, dict): return False return any(bool(privilege.get(key)) for key in ("resConsumable", "userConsumable", "listenType")) @staticmethod def playlist_id(value: str | int) -> str: match = re.search(r"(?:playlist\?id=|\bid=)?(\d{5,})", str(value or "")) return match.group(1) if match else "" async def fetch_playlist(self, playlist: str | int) -> dict[str, Any] | None: playlist_id = self.playlist_id(playlist) if not playlist_id: return None timeout = aiohttp.ClientTimeout(total=15) url = f"{self.api_base}/api/playlist/detail?id={urllib.parse.quote(playlist_id)}" try: async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session: async with session.get(url, allow_redirects=True) as resp: if resp.status != 200: return None payload = await resp.json(content_type=None) except Exception as exc: self.logger.warning(f"[mpv] 获取网易云歌单失败: {exc}") return None result = payload.get("result") if isinstance(payload, dict) else None if not isinstance(result, dict): return None tracks = result.get("tracks") if not isinstance(tracks, list): return None songs: list[dict[str, Any]] = [] for track in tracks: if not isinstance(track, dict): continue song_id = self.playlist_id(track.get("id", "")) if not song_id: continue artists_raw = track.get("artists") or track.get("ar") or [] artists = "/".join( str(item.get("name") or "").strip() for item in artists_raw if isinstance(item, dict) and str(item.get("name") or "").strip() ) album = track.get("album") or track.get("al") or {} duration_ms = track.get("duration") or track.get("dt") or 0 try: duration_sec = max(0, int(duration_ms) // 1000) except (TypeError, ValueError): duration_sec = 0 songs.append({ "id": song_id, "name": str(track.get("name") or f"歌曲{song_id}"), "artist": artists or "未知歌手", "duration_sec": duration_sec, "cover": str(album.get("picUrl") or "") if isinstance(album, dict) else "", "source": "background_playlist", "playlist_id": playlist_id, }) if not songs: return None return { "id": playlist_id, "name": str(result.get("name") or f"歌单{playlist_id}"), "songs": songs, } async def fetch_song_detail(self, song_id: str | int) -> dict[str, Any] | None: clean_id = self.playlist_id(song_id) if not clean_id: return None timeout = aiohttp.ClientTimeout(total=12) url = f"{self.api_base}/api/song/detail/?id={clean_id}&ids=[{clean_id}]" try: async with aiohttp.ClientSession(headers=self._headers(), timeout=timeout) as session: async with session.get(url, allow_redirects=True) as resp: payload = await resp.json(content_type=None) except Exception: return None songs = payload.get("songs") if isinstance(payload, dict) else None if not isinstance(songs, list) or not songs or not isinstance(songs[0], dict): return None track = songs[0] album = track.get("album") or track.get("al") or {} return { "cover": str(album.get("picUrl") or album.get("blurPicUrl") or "") if isinstance(album, dict) else "", } async def resolve(self, song: dict[str, Any]) -> dict[str, Any] | None: self.last_error_code = "" song_id = "".join(ch for ch in str(song.get("id", "")) if ch.isdigit()) if not song_id: self.last_error_code = "invalid_song_id" return None entry = await self._fetch_player_entry(song_id) if entry: if self._is_trial_entry(entry): reason = "登录已失效或账号没有完整播放权益" if self.music_u else "未配置网易云登录" self.logger.warning( f"[mpv] 拒绝播放试听片段: {song.get('name')} ({song_id}),{reason}" ) self.last_error_code = "preview_only" return None player_url = str(entry.get("url") or "").strip() if player_url: resolved = await self._probe_url(player_url) if resolved: return { "url": resolved, "source": "netease.player.auth" if self.music_u else "netease.player", "song_id": song_id, } # NetEase's public outer URL provides a short-lived CDN redirect for songs available to the current region/account tier. outer = f"{self.api_base}/song/media/outer/url?id={urllib.parse.quote(song_id)}.mp3" resolved = await self._probe_url(outer) if not resolved: self.last_error_code = "unavailable" self.logger.warning(f"[mpv] 无法获取可播放地址: {song.get('name')} ({song_id})") return None return { "url": resolved, "source": "netease.outer", "song_id": song_id, }