Archived
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。 worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给 另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成 协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。 改动(按子系统): - music_downloader:downloader / _aria2 / _concurrency / cover / _http / __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改 「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让 已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经 to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式 读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已 有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。 删除 get_http_session / _SESSION_LOCAL。 models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception: 它是「续传起点失效、残片已清、从头重下」,既不是确定性失败 (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立 成类且在 except 链中先于二者捕获。 - music_api:podcast.py 全部函数协程化(直链解析内部改 await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已 清空,文件只剩空壳。 - services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。 threading.Thread → loop.create_task(任务句柄字段 worker_thread → worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore, 终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。 删除 services/streaming.py(同步流垫片,唯一消费者已迁到 core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、 「入包」严格串行。 - web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是 把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。 web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。 web/routers/download.py 的播客端点补上 3 处漏掉的 await (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象, 靠 pyright 抓出来后修正。 留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、 mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、 PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/ 往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject 逐条注明理由。 取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker 协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件 循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。 测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的 get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks (顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...)); aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成 done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在 常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。 门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。 下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾; B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅 sync_http 自用)。
556 lines
21 KiB
Python
556 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""网易云音乐播客(电台 / 单集声音)API 模块
|
||
|
||
提供网易云"播客"板块相关接口封装,包括:
|
||
- 播客搜索(电台 / 单集声音)
|
||
- 播客电台详情与单集列表(分页)
|
||
- 单集(program)详情
|
||
- 热门播客推荐
|
||
- 单集音频直链解析(音质自动降级)
|
||
|
||
说明:
|
||
- 播客单集的音频本质上是网易云曲库中的 song(program.mainSong),
|
||
因此直链复用 NeteaseAPI.get_song_url(eapi song/enhance/player/url/v1)获取。
|
||
- 支持格式:mp3 / m4a / aac 等(以接口返回 type 为准)。
|
||
- 支持链接协议:music.163.com/djradio?id=(电台)、/program?id= 与 /sound?id=(单集)、
|
||
以及 163cn.tv 短链。
|
||
- 本模块**整体异步**:HTTP 统一走 core.http_client(进程级连接池 + 有界重试 +
|
||
8MB 响应体上限),调用方直接 await,不再需要同步门面/线程包装。
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
from typing import Any
|
||
|
||
from core.http_client import UpstreamError
|
||
from core.http_client import client as _http
|
||
|
||
from .api import NeteaseAPI
|
||
from .constants import APIConstants
|
||
from .errors import APIException
|
||
|
||
|
||
class PodcastConstants:
|
||
"""播客接口相关常量"""
|
||
|
||
# 搜索(cloudsearch:type=1009 播客电台 / 2000 单集声音)
|
||
SEARCH_API = 'https://music.163.com/api/cloudsearch/pc'
|
||
# 电台(播客)详情
|
||
DJRADIO_GET_API = 'https://music.163.com/api/djradio/get'
|
||
# 电台下的单集列表(分页)
|
||
DJ_PROGRAM_BYRADIO_API = 'https://music.163.com/api/dj/program/byradio'
|
||
# 单集详情
|
||
DJ_PROGRAM_DETAIL_API = 'https://music.163.com/api/dj/program/detail'
|
||
# 热门播客(必须携带有效 cateId)
|
||
DJRADIO_HOT_API = 'https://music.163.com/api/djradio/hot'
|
||
# 播客分类列表
|
||
DJRADIO_CATEGORY_API = 'https://music.163.com/api/djradio/category/get'
|
||
|
||
# 播客音频常用音质降级阶梯(无损对播客基本无意义)
|
||
QUALITY_LADDER = ['exhigh', 'standard']
|
||
|
||
# 热门接口的兜底分类ID(生活 / 情感 / 脱口秀 / 有声书)
|
||
DEFAULT_HOT_CATE_IDS = [6, 3, 8, 10001]
|
||
|
||
|
||
async def _post_json(url: str, data: dict[str, Any], cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||
"""发送表单 POST 请求并返回 JSON(响应体上限由 core.http_client 统一把关)"""
|
||
headers = {
|
||
'User-Agent': APIConstants.USER_AGENT,
|
||
'Referer': APIConstants.REFERER,
|
||
}
|
||
try:
|
||
response = await _http.request('POST', url, data=data, headers=headers,
|
||
cookies=cookies or {}, timeout=30)
|
||
result = response.json()
|
||
if not isinstance(result, dict):
|
||
raise APIException('播客接口响应异常(非标准 JSON 对象)')
|
||
except UpstreamError as e:
|
||
raise APIException(f"播客接口请求失败: {e}") from e
|
||
except json.JSONDecodeError as e:
|
||
raise APIException(f"解析播客接口响应失败: {e}") from e
|
||
if result.get('code') not in (200, None):
|
||
raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}")
|
||
return result
|
||
|
||
|
||
def _to_int(v: Any, default: int | None = None) -> int | None:
|
||
"""安全转 int:垃圾值/None 回退默认值(上游字段常为 null/字符串)。"""
|
||
try:
|
||
return int(v)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _ts_to_date(ts: int | None) -> str:
|
||
"""毫秒时间戳 → 日期字符串"""
|
||
try:
|
||
if not ts:
|
||
return ''
|
||
return time.strftime('%Y-%m-%d', time.localtime(int(ts) / 1000))
|
||
except (TypeError, ValueError, OSError, OverflowError):
|
||
return ''
|
||
|
||
|
||
def _fmt_duration(ms: int | None) -> str:
|
||
"""毫秒 → mm:ss / hh:mm:ss"""
|
||
try:
|
||
sec = int((ms or 0) / 1000)
|
||
except (TypeError, ValueError):
|
||
return ''
|
||
if sec <= 0:
|
||
return ''
|
||
h, rem = divmod(sec, 3600)
|
||
m, s = divmod(rem, 60)
|
||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||
|
||
|
||
def _fmt_count(n: int | None) -> str:
|
||
"""数量 → 万单位简写"""
|
||
try:
|
||
n = int(n or 0)
|
||
except (TypeError, ValueError):
|
||
return '0'
|
||
if n >= 100000000:
|
||
v = n / 100000000
|
||
return f"{v:.1f}亿".replace('.0', '')
|
||
if n >= 10000:
|
||
v = n / 10000
|
||
return f"{v:.1f}万".replace('.0', '')
|
||
return str(n)
|
||
|
||
|
||
# ============ 数据规范化 ============
|
||
|
||
def _norm_radio(raw: dict[str, Any]) -> dict[str, Any]:
|
||
"""规范化电台(播客)信息"""
|
||
dj = raw.get('dj') or {}
|
||
return {
|
||
'id': raw.get('id'),
|
||
'name': raw.get('name', ''),
|
||
'pic_url': raw.get('picUrl', ''),
|
||
'desc': str(raw.get('desc') or raw.get('description') or '').strip(),
|
||
'category': raw.get('category', ''),
|
||
'sub_count': raw.get('subCount', 0),
|
||
'sub_count_text': _fmt_count(raw.get('subCount')),
|
||
'program_count': raw.get('programCount', 0),
|
||
'play_count': raw.get('playCount', 0),
|
||
'play_count_text': _fmt_count(raw.get('playCount')),
|
||
'dj_name': dj.get('nickname', ''),
|
||
'dj_avatar': dj.get('avatarUrl', ''),
|
||
'create_date': _ts_to_date(raw.get('createTime')),
|
||
'second_category': raw.get('secondCategory', ''),
|
||
}
|
||
|
||
|
||
def _norm_program(raw: dict[str, Any]) -> dict[str, Any]:
|
||
"""规范化单集(program)信息"""
|
||
radio = raw.get('radio') or {}
|
||
dj = raw.get('dj') or radio.get('dj') or {}
|
||
main_song = raw.get('mainSong') or raw.get('mainMusic') or {}
|
||
return {
|
||
'id': raw.get('id'),
|
||
'name': raw.get('name', ''),
|
||
'cover_url': raw.get('coverUrl') or raw.get('cover') or radio.get('picUrl', ''),
|
||
'description': str(raw.get('description') or raw.get('desc') or '').strip(),
|
||
'duration': raw.get('duration', 0),
|
||
'duration_text': _fmt_duration(raw.get('duration') or main_song.get('duration')),
|
||
'create_date': _ts_to_date(raw.get('createTime')),
|
||
'serial_num': raw.get('serialNum', 0),
|
||
'listener_count': raw.get('listenerCount', 0),
|
||
'listener_count_text': _fmt_count(raw.get('listenerCount')),
|
||
'liked_count': raw.get('likedCount', 0),
|
||
'comment_count': raw.get('commentCount', 0),
|
||
'main_song_id': main_song.get('id'),
|
||
'main_song_name': main_song.get('name', ''),
|
||
'radio_id': radio.get('id'),
|
||
'radio_name': radio.get('name', ''),
|
||
'dj_name': dj.get('nickname', ''),
|
||
'category': raw.get('category') or radio.get('category', ''),
|
||
}
|
||
|
||
|
||
# ============ 链接 / ID 解析 ============
|
||
|
||
_SHORTLINK_CACHE: dict[str, str] = {}
|
||
|
||
|
||
async def _resolve_short_link(url: str) -> str:
|
||
"""解析 163cn.tv 短链,返回跳转后的真实链接(仅接受整串短链,子串不触发请求)"""
|
||
if not re.match(r'^https?://(www\.)?163cn\.tv/\S+$', str(url or '').strip(), re.IGNORECASE):
|
||
return url
|
||
# 带浏览器 UA(短链 CDN 对默认 UA 区别对待)+ 模块级缓存
|
||
#(此前仅音乐路径有缓存,播客同一分享链接每次解析都发真实 GET)
|
||
global _SHORTLINK_CACHE
|
||
cached = _SHORTLINK_CACHE.get(url)
|
||
if cached is not None:
|
||
return cached
|
||
resp = None
|
||
try:
|
||
resp = await _http.open('GET', url, allow_redirects=False, timeout=10,
|
||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||
real = resp.headers.get('Location', url)
|
||
except UpstreamError:
|
||
return url
|
||
finally:
|
||
if resp is not None:
|
||
resp.close()
|
||
# 短链展开口径——展开失败(200 无 Location/5xx,
|
||
# real==url)不写缓存,一次 CDN 抖动不再让该链接整个缓存存续期必然失败
|
||
if real == url:
|
||
return real
|
||
if len(_SHORTLINK_CACHE) > 500:
|
||
_SHORTLINK_CACHE.clear()
|
||
_SHORTLINK_CACHE[url] = real
|
||
return real
|
||
|
||
|
||
async def extract_podcast_ref(raw: str) -> dict[str, str]:
|
||
"""从用户输入中提取播客引用(正规 URL 解析,替代子串匹配与非数字剔除)
|
||
|
||
Returns:
|
||
{'kind': 'radio'|'program'|'unknown', 'id': '...'}
|
||
- radio: djradio?id= / radio?id= 链接
|
||
- program: program?id= / sound?id= 链接
|
||
- unknown: 纯数字 ID(需依次尝试单集/电台)
|
||
"""
|
||
from urllib.parse import parse_qs, urlsplit
|
||
text = str(raw or '').strip()
|
||
# 短链先还原(仅当整串就是一个短链 URL)
|
||
if '163cn.tv' in text.lower():
|
||
text = await _resolve_short_link(text)
|
||
|
||
# 有 URL 结构:按路径判断类型,query 正规解析 id(参数顺序无关)
|
||
if '://' in text or text.startswith('//'):
|
||
try:
|
||
parts = urlsplit(text if '://' in text else 'https:' + text)
|
||
q = parse_qs(parts.query)
|
||
# 兼容老式 #/program?id= 片段路由
|
||
if not q.get('id'):
|
||
m = re.search(r'[?&#]id=(\d+)', text)
|
||
if m:
|
||
q = {'id': [m.group(1)]}
|
||
pid = (q.get('id') or [''])[0]
|
||
if pid.isdigit():
|
||
path_l = parts.path.lower()
|
||
if 'djradio' in path_l or 'radio' in path_l:
|
||
return {'kind': 'radio', 'id': pid}
|
||
if 'program' in path_l or 'sound' in path_l or 'song' in path_l:
|
||
return {'kind': 'program', 'id': pid}
|
||
# 路径无类型线索(如 y2027.d.popchan.cn 之类播客域名):按 unknown 走探测
|
||
return {'kind': 'unknown', 'id': pid}
|
||
except ValueError:
|
||
pass
|
||
raise APIException('无法识别的播客链接,请粘贴电台/单集链接或直接输入纯数字 ID')
|
||
|
||
# 纯数字 ID
|
||
if text.isdigit():
|
||
return {'kind': 'unknown', 'id': text}
|
||
raise APIException('无法识别的播客链接或ID,支持 电台链接(djradio?id=)、单集链接(program?id=) 或纯数字ID')
|
||
|
||
|
||
# ============ 核心 API ============
|
||
|
||
async def search_podcast(keywords: str, cookies: dict[str, str] | None = None,
|
||
limit: int = 20, offset: int = 0,
|
||
search_type: str = 'all') -> dict[str, Any]:
|
||
"""搜索播客
|
||
|
||
Args:
|
||
keywords: 关键词
|
||
cookies: 请求 Cookie(可选)
|
||
limit: 每类返回数量(1-50)
|
||
offset: 偏移量
|
||
search_type: all=电台+单集 / radio=仅电台 / voice=仅单集
|
||
|
||
Returns:
|
||
{'radios': [...], 'voices': [...]}
|
||
"""
|
||
keywords = str(keywords or '').strip()
|
||
if not keywords:
|
||
raise APIException('搜索关键词不能为空')
|
||
limit = max(1, min(int(limit or 20), 50))
|
||
|
||
result: dict[str, Any] = {'radios': [], 'voices': []}
|
||
|
||
if search_type in ('all', 'radio'):
|
||
res = await _post_json(PodcastConstants.SEARCH_API,
|
||
{'s': keywords, 'type': 1009, 'limit': limit, 'offset': offset},
|
||
cookies)
|
||
radios = (res.get('result') or {}).get('djRadios') or []
|
||
result['radios'] = [_norm_radio(r) for r in radios]
|
||
|
||
if search_type in ('all', 'voice'):
|
||
res = await _post_json(PodcastConstants.SEARCH_API,
|
||
{'s': keywords, 'type': 2000, 'limit': limit, 'offset': offset},
|
||
cookies)
|
||
rdata = res.get('result') or {}
|
||
voices = rdata.get('songs') or rdata.get('voices') or rdata.get('programs') or []
|
||
result['voices'] = [_norm_program(v) for v in voices]
|
||
|
||
return result
|
||
|
||
|
||
async def get_radio_detail(radio_id: Any, cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||
"""获取播客电台(djradio)详情"""
|
||
try:
|
||
rid = int(str(radio_id).strip())
|
||
except (TypeError, ValueError):
|
||
raise APIException('无效的电台ID') from None
|
||
result = await _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies)
|
||
# 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空
|
||
data = result.get('djRadio') or result.get('data') or {}
|
||
if not data:
|
||
raise APIException('未找到该播客电台(可能已下架或ID错误)')
|
||
return _norm_radio(data)
|
||
|
||
|
||
async def get_radio_programs(radio_id: Any, cookies: dict[str, str] | None = None,
|
||
limit: int = 30, offset: int = 0, asc: bool = True) -> dict[str, Any]:
|
||
"""获取电台下的单集列表(分页)
|
||
|
||
Args:
|
||
radio_id: 电台ID
|
||
limit: 每页数量(1-100)
|
||
offset: 偏移量
|
||
asc: True=从第1集开始(正序),False=最新在前
|
||
|
||
Returns:
|
||
{'programs': [...], 'count': 总数, 'has_more': bool}
|
||
"""
|
||
try:
|
||
rid = int(str(radio_id).strip())
|
||
except (TypeError, ValueError):
|
||
raise APIException('无效的电台ID') from None
|
||
limit = max(1, min(int(limit or 30), 100))
|
||
result = await _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API,
|
||
{'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)),
|
||
'asc': 'true' if asc else 'false'},
|
||
cookies)
|
||
programs = [_norm_program(p) for p in (result.get('programs') or [])]
|
||
count = _to_int(result.get('count'))
|
||
offset_val = max(0, int(offset or 0))
|
||
return {
|
||
'programs': programs,
|
||
'count': count,
|
||
'has_more': (offset_val + len(programs)) < count if count else len(programs) >= limit,
|
||
}
|
||
|
||
|
||
async def get_program_detail(program_id: Any, cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||
"""获取单集(program)详情"""
|
||
try:
|
||
pid = int(str(program_id).strip())
|
||
except (TypeError, ValueError):
|
||
raise APIException('无效的单集ID') from None
|
||
result = await _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies)
|
||
program = result.get('program') or {}
|
||
if not program:
|
||
raise APIException('未找到该播客单集(可能已下架或ID错误)')
|
||
return _norm_program(program)
|
||
|
||
|
||
async def get_categories() -> list[dict[str, Any]]:
|
||
"""获取播客分类列表(GET 接口)
|
||
|
||
Returns:
|
||
[{'id': 6, 'name': '生活'}, ...]
|
||
"""
|
||
headers = {
|
||
'User-Agent': APIConstants.USER_AGENT,
|
||
'Referer': APIConstants.REFERER,
|
||
}
|
||
try:
|
||
response = await _http.request('GET', PodcastConstants.DJRADIO_CATEGORY_API,
|
||
headers=headers, timeout=30)
|
||
result = response.json()
|
||
except UpstreamError as e:
|
||
raise APIException(f"获取播客分类失败: {e}") from e
|
||
except json.JSONDecodeError as e:
|
||
raise APIException(f"解析播客分类响应失败: {e}") from e
|
||
if not isinstance(result, dict):
|
||
# 非 dict 响应(null/数组)显式报错,不再 AttributeError
|
||
raise APIException('解析播客分类响应失败(非标准 JSON 对象)')
|
||
cats = []
|
||
for c in (result.get('categories') or []):
|
||
if not isinstance(c, dict):
|
||
continue
|
||
try:
|
||
cid = int(c.get('id') or 0)
|
||
except (TypeError, ValueError):
|
||
continue # 垃圾 id 跳过该条,不再拖死整页分类
|
||
name = c.get('name')
|
||
if cid and name:
|
||
cats.append({'id': cid, 'name': str(name)})
|
||
if not cats:
|
||
raise APIException('播客分类列表为空')
|
||
return cats
|
||
|
||
|
||
async def get_hot_radios(cookies: dict[str, str] | None = None,
|
||
limit: int = 30, offset: int = 0,
|
||
cate_id: int | None = None) -> list[dict[str, Any]]:
|
||
"""获取分类下热门播客
|
||
|
||
Args:
|
||
cate_id: 播客分类ID;用户明确指定分类时只查该分类
|
||
(即便为空也不悄悄混入其他分类的内容),未指定时按默认分类依次尝试
|
||
|
||
Returns:
|
||
电台列表(已规范化)
|
||
"""
|
||
limit = max(1, min(int(limit or 30), 100))
|
||
cate_ids: list[int] = []
|
||
user_cate = False
|
||
if cate_id:
|
||
try:
|
||
cate_ids.append(int(cate_id))
|
||
user_cate = True
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if not user_cate:
|
||
for cid in PodcastConstants.DEFAULT_HOT_CATE_IDS:
|
||
if cid not in cate_ids:
|
||
cate_ids.append(cid)
|
||
|
||
for cid in cate_ids:
|
||
result = await _post_json(PodcastConstants.DJRADIO_HOT_API,
|
||
{'limit': limit, 'offset': max(0, int(offset or 0)), 'cateId': cid},
|
||
cookies)
|
||
radios = [_norm_radio(r) for r in (result.get('djRadios') or [])]
|
||
if radios:
|
||
return radios
|
||
if user_cate:
|
||
break # 指定分类为空就返回空,不注入默认分类内容
|
||
return []
|
||
|
||
|
||
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
|
||
|
||
|
||
def _falldown_order(level: str) -> list[str]:
|
||
"""音质降级尝试序列:只向下不向上(原实现指定 standard 会回退到更高的 exhigh)。
|
||
|
||
- auto/空 → 通用阶梯原序
|
||
- 已知音质 → 该音质 + 阶梯中低于它的部分
|
||
- 未知音质 → 该音质优先 + 通用阶梯(去重保序)
|
||
"""
|
||
level = str(level or 'auto').strip()
|
||
if not level or level == 'auto':
|
||
order = list(PodcastConstants.QUALITY_LADDER)
|
||
elif level in _FALLDOWN_LADDER:
|
||
idx = _FALLDOWN_LADDER.index(level)
|
||
order = [level, *_FALLDOWN_LADDER[idx + 1:]]
|
||
else:
|
||
order = [level, *PodcastConstants.QUALITY_LADDER]
|
||
return list(dict.fromkeys(order)) # 去重保序
|
||
|
||
|
||
async def resolve_episode_url(song_id: Any, cookies: dict[str, str] | None = None,
|
||
level: str = 'auto') -> dict[str, Any] | None:
|
||
"""解析单集音频直链(带音质降级)
|
||
|
||
Args:
|
||
song_id: 单集 mainSong 的歌曲ID
|
||
cookies: 全局账号 Cookie
|
||
level: 期望音质(auto=按阶梯自动尝试)
|
||
|
||
Returns:
|
||
{'url','level','type','size','br','md5'} 或 None(全部失败)
|
||
"""
|
||
order = _falldown_order(level)
|
||
# song_id 可能是 None/垃圾值(防御:int 失败按无音频处理,不再 TypeError 500)
|
||
try:
|
||
sid = int(str(song_id).strip())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
api = NeteaseAPI()
|
||
for lv in order:
|
||
try:
|
||
res = await api.get_song_url(sid, lv, cookies or {})
|
||
except (APIException, ValueError):
|
||
continue
|
||
data = (res or {}).get('data') or []
|
||
if data and data[0] and data[0].get('url'):
|
||
d = data[0]
|
||
return {
|
||
'url': d.get('url'),
|
||
'level': d.get('level') or lv,
|
||
'type': d.get('type') or 'mp3',
|
||
'size': _to_int(d.get('size')),
|
||
'br': d.get('br'),
|
||
'md5': d.get('md5', ''),
|
||
'requested_level': lv,
|
||
}
|
||
return None
|
||
|
||
|
||
# ============ 组合能力 ============
|
||
|
||
async def parse_podcast_ref(raw: str, cookies: dict[str, str] | None = None,
|
||
page_size: int = 30) -> dict[str, Any]:
|
||
"""智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情
|
||
|
||
Returns:
|
||
{'kind': 'radio', 'radio': {...}, 'programs': [...], 'count': n, 'has_more': bool}
|
||
或 {'kind': 'program', 'program': {...}}
|
||
"""
|
||
ref = await extract_podcast_ref(raw)
|
||
|
||
if ref['kind'] == 'radio':
|
||
radio = await get_radio_detail(ref['id'], cookies)
|
||
page = await get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
|
||
return {'kind': 'radio', 'radio': radio, **page}
|
||
|
||
if ref['kind'] == 'program':
|
||
return {'kind': 'program', 'program': await get_program_detail(ref['id'], cookies)}
|
||
|
||
# 纯数字:先试单集,再试电台
|
||
try:
|
||
return {'kind': 'program', 'program': await get_program_detail(ref['id'], cookies)}
|
||
except APIException:
|
||
pass
|
||
radio = await get_radio_detail(ref['id'], cookies)
|
||
page = await get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
|
||
return {'kind': 'radio', 'radio': radio, **page}
|
||
|
||
|
||
async def list_radio_all_program_ids(radio_id: Any, cookies: dict[str, str] | None = None,
|
||
max_count: int = 200) -> list[dict[str, Any]]:
|
||
"""拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}]
|
||
|
||
最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。
|
||
按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止,
|
||
不再固定 offset+=100 盲目翻页。
|
||
"""
|
||
out: list[dict[str, Any]] = []
|
||
seen_ids = set()
|
||
offset = 0
|
||
page_size = 100
|
||
max_pages = 30 # 有界保护:即使上游 has_more 恒真也最多翻 30 页
|
||
for _ in range(max_pages):
|
||
page = await get_radio_programs(radio_id, cookies, limit=page_size, offset=offset, asc=True)
|
||
new_count = 0
|
||
for p in page['programs']:
|
||
if p['id'] in seen_ids:
|
||
continue
|
||
seen_ids.add(p['id'])
|
||
out.append({
|
||
'id': p['id'],
|
||
'name': p['name'],
|
||
'main_song_id': p['main_song_id'],
|
||
'serial_num': p['serial_num'],
|
||
'cover_url': p['cover_url'],
|
||
})
|
||
new_count += 1
|
||
if len(out) >= max_count:
|
||
break
|
||
if len(out) >= max_count:
|
||
break
|
||
# 本页无新增(重复页/短页)或上游明确说没有更多 → 停止
|
||
if new_count == 0 or not page['has_more'] or not page['programs']:
|
||
break
|
||
offset += page_size
|
||
return out
|