diff --git a/web/app.py b/web/app.py index 4ee725a..f249919 100644 --- a/web/app.py +++ b/web/app.py @@ -23,6 +23,7 @@ from web.errors import install_error_handlers from web.middleware import ResponseHygieneMiddleware from web.routers import account as account_router from web.routers import music as music_router +from web.routers import podcast as podcast_router from web.routers import site as site_router from web.routers import system as system_router @@ -71,6 +72,7 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI: app.include_router(site_router.router, prefix='/api/v1', tags=['site']) app.include_router(music_router.router, prefix='/api/v1', tags=['music']) app.include_router(account_router.router, prefix='/api/v1', tags=['account']) + app.include_router(podcast_router.router, prefix='/api/v1', tags=['podcast']) install_error_handlers(app) diff --git a/web/routers/podcast.py b/web/routers/podcast.py new file mode 100644 index 0000000..88e726a --- /dev/null +++ b/web/routers/podcast.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""播客端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。 + +已迁移(免费解析类,需登录,不扣次数): +- POST/GET /api/v1/podcast/parse ↔ Flask /podcast/parse +- POST/GET /api/v1/podcast/programs ↔ Flask /podcast/programs +- POST/GET /api/v1/podcast/search ↔ Flask /podcast/search +- POST/GET /api/v1/podcast/hot ↔ Flask /podcast/hot + +说明:podcast_api 仍是同步模块(requests),此处以 asyncio.to_thread 包装, +让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。 +""" +import asyncio +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Request + +import podcast_api +from core import media +from music_api import APIException +from web import ratelimit +from web.deps import SiteUser, require_login +from web.response import error, success + +router = APIRouter() + +LoginUser = Annotated[SiteUser, Depends(require_login)] + + +async def _request_data(request: Request) -> dict[str, Any]: + data: dict[str, Any] = {} + if request.method == 'GET': + data.update(dict(request.query_params)) + return data + try: + body = await request.json() + if isinstance(body, dict): + data.update(body) + except Exception: + pass + if not data: + try: + form = await request.form() + data.update(dict(form.items())) + except Exception: + pass + return data + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _cookies() -> dict[str, str]: + import music_api + return music_api.load_cookies() + + +def _extract_id(raw: Any) -> str: + from web.routers.music import _extract_id as _ex + return _ex(raw) + + +def _record_log(action: str, ip: str, song: str) -> None: + from web.routers.music import _record_log as _rl + _rl(action, ip, song) + + +@router.api_route('/podcast/parse', methods=['GET', 'POST']) +async def podcast_parse(_user: LoginUser, request: Request): + """播客解析:电台 → 详情+单集列表;单集 → 详情+音频直链。""" + await ratelimit.guard(request, bucket='free', limit=30, window=60) + data = await _request_data(request) + raw = data.get('id') or data.get('url') + if not raw: + return error("参数 'id' 不能为空", 400) + page_size = max(1, min(_safe_int(data.get('page_size'), 30), 100)) + quality = str(data.get('quality') or 'auto') + + cookies = _cookies() + try: + result = await asyncio.to_thread( + podcast_api.parse_podcast_ref, raw, cookies, page_size=page_size) + except APIException as e: + return error(str(e), 404) + except Exception: + return error('服务暂时不可用,请稍后再试', 500) + + if result.get('kind') == 'program': + program = result['program'] + song_id = program.get('main_song_id') + url_info = None + if song_id: + try: + url_info = await asyncio.to_thread( + podcast_api.resolve_episode_url, song_id, cookies, quality) + except Exception: + url_info = None + if url_info: + program['audio'] = { + 'url': url_info['url'], + 'level': url_info['level'], + 'level_name': media.quality_display_name(url_info['level']), + 'type': url_info['type'], + 'size': url_info['size'], + 'size_formatted': media.format_file_size(url_info['size']), + 'br': url_info['br'], + } + else: + program['audio'] = None + try: + _record_log('解析', ratelimit.client_ip(request), program.get('name') or str(raw)) + except Exception: + pass + else: + try: + _record_log('解析', ratelimit.client_ip(request), + (result.get('radio') or {}).get('name') or str(raw)) + except Exception: + pass + + return success(result, '播客解析成功') + + +@router.api_route('/podcast/programs', methods=['GET', 'POST']) +async def podcast_programs(_user: LoginUser, request: Request): + """播客单集列表(分页加载)。""" + await ratelimit.guard(request, bucket='free', limit=30, window=60) + data = await _request_data(request) + radio_id = data.get('radio_id') or data.get('id') + if not radio_id: + return error("参数 'radio_id' 不能为空", 400) + limit = max(1, min(_safe_int(data.get('limit'), 30), 100)) + offset = max(0, _safe_int(data.get('offset'), 0)) + asc = str(data.get('asc', 'true')).lower() in ('1', 'true', 'yes') + + radio_id = _extract_id(str(radio_id)) + if not str(radio_id).isdigit(): + return error('无效的电台 ID') + try: + result = await asyncio.to_thread( + podcast_api.get_radio_programs, radio_id, _cookies(), + limit=limit, offset=offset, asc=asc) + return success(result, '获取单集列表成功') + except APIException as e: + return error(str(e), 404) + except Exception: + return error('服务暂时不可用,请稍后再试', 500) + + +@router.api_route('/podcast/search', methods=['GET', 'POST']) +async def podcast_search(_user: LoginUser, request: Request): + """播客搜索(电台 / 单集声音)。""" + await ratelimit.guard(request, bucket='free', limit=30, window=60) + data = await _request_data(request) + keyword = data.get('keyword') or data.get('q') + if not keyword: + return error("参数 'keyword' 不能为空", 400) + limit = max(1, min(_safe_int(data.get('limit'), 20), 50)) + offset = max(0, _safe_int(data.get('offset'), 0)) + search_type = data.get('type', 'all') + if search_type not in ('all', 'radio', 'voice'): + search_type = 'all' + try: + result = await asyncio.to_thread( + podcast_api.search_podcast, keyword, _cookies(), + limit=limit, offset=offset, search_type=search_type) + return success(result, '播客搜索完成') + except APIException as e: + return error(str(e), 404) + except Exception: + return error('服务暂时不可用,请稍后再试', 500) + + +@router.api_route('/podcast/hot', methods=['GET', 'POST']) +async def podcast_hot(_user: LoginUser, request: Request): + """热门播客 + 播客分类列表。""" + await ratelimit.guard(request, bucket='free', limit=30, window=60) + data = await _request_data(request) + limit = max(1, min(_safe_int(data.get('limit'), 30), 100)) + offset = max(0, _safe_int(data.get('offset'), 0)) + cate_id = data.get('cate_id') or None + try: + radios = await asyncio.to_thread( + podcast_api.get_hot_radios, _cookies(), limit=limit, offset=offset, cate_id=cate_id) + except APIException as e: + return error(str(e), 404) + except Exception: + return error('服务暂时不可用,请稍后再试', 500) + try: + categories = await asyncio.to_thread(podcast_api.get_categories) + except APIException: + categories = [] + # 满页视为可能还有更多(与 Flask 同口径) + return success({'radios': radios, 'categories': categories, + 'has_more': len(radios) >= limit}, '获取热门播客成功')