Spaces:
Running
Running
| """听歌识曲相关路由 /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"]) | |
| 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="识曲失败,请稍后重试", | |
| ) | |
| 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", | |
| ) | |
| 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", | |
| ) | |