"""听歌识曲相关路由 /v1/music/*。 所有接口复用 require_access_key 鉴权,与现有 /v1/xtc/* 接口安全级别一致。 响应格式统一 { ok: bool, ... },错误走全局 HttpError handler。 """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from fastapi.responses import JSONResponse from ..auth import require_access_key from ..errors import HttpError from ..services import music_service logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/music", tags=["music"]) @router.post("/recognize") async def recognize( request: Request, file: UploadFile = File(..., description="AMR 录音文件"), duration: int = Query(8, ge=3, le=15, description="录音时长(秒)"), _key: str = Depends(require_access_key), ): """听歌识曲:上传 AMR 录音,返回识别结果列表。 链路:AMR → ffmpeg 转 PCM → node wasm 生成指纹 → 网易云 audio_match。 每次请求都会归档录音与诊断信息到 music_record 表(admin 面板可回放)。 """ amr_bytes = await file.read() if not amr_bytes: raise HttpError("empty file", status=400, code="bad_request", hint="录音文件为空") # 限制上传大小 2MB(8 秒 AMR 约 50KB,留充足余量) if len(amr_bytes) > 2 * 1024 * 1024: raise HttpError("file too large", status=413, code="bad_request", hint="录音文件超过 2MB 限制") # 提取设备 ID:优先 header x-xtc-device-id,其次 query 参数 device_id device_id = ( request.headers.get("x-xtc-device-id") or request.headers.get("x-device-id") or request.query_params.get("device_id") or "" ) # 实验性音频增强:客户端通过 x-xtc-enhance: 1 开启 enhance = request.headers.get("x-xtc-enhance") == "1" try: result = await music_service.recognize( amr_bytes, duration=duration, enhance=enhance, client_ip=request.client.host if request.client else "", device_id=device_id, access_key=_key, user_agent=request.headers.get("user-agent", ""), ) return JSONResponse(result) except HttpError: raise except Exception as e: logger.exception("[music/recognize] unexpected error") raise HttpError( f"recognize failed: {e}", status=500, code="internal_error", hint="识曲失败,请稍后重试", ) @router.get("/song/url") async def song_url( id: int = Query(..., description="歌曲 ID"), level: str = Query("standard", description="音质: standard/exhigh/lossless"), unblock: bool = Query(False, description="无 URL 时尝试换源(酷我)"), name: str = Query("", description="歌曲名(换源搜索用)"), artist: str = Query("", description="歌手名(换源搜索用)"), _key: str = Depends(require_access_key), ): """获取歌曲播放 URL。""" try: result = await music_service.get_song_url( id, level=level, unblock=unblock, song_name=name, artist=artist, ) return JSONResponse(result) except HttpError: raise except Exception as e: logger.exception("[music/song/url] unexpected error") raise HttpError( f"get song url failed: {e}", status=500, code="internal_error", ) @router.get("/song/detail") async def song_detail( ids: str = Query(..., description="歌曲 ID 列表,逗号分隔"), _key: str = Depends(require_access_key), ): """批量获取歌曲详情。""" try: id_list = [int(x.strip()) for x in ids.split(",") if x.strip()][:50] except ValueError: raise HttpError("invalid ids format", status=400, code="bad_request", hint="ids 应为逗号分隔的数字") try: result = await music_service.get_song_details(id_list) return JSONResponse(result) except HttpError: raise except Exception as e: logger.exception("[music/song/detail] unexpected error") raise HttpError( f"get song detail failed: {e}", status=500, code="internal_error", )