File size: 5,261 Bytes
d731d8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""XTC 听歌识曲 FastAPI 路由。

无侵入集成到 gcli2api:所有路径前缀 /xtc,不与现有路由冲突。
"""
from __future__ import annotations

import base64
import logging
from typing import Any, Optional

from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse

from .ncm_proxy import get_song_detail, get_song_url
from .recognize import recognize_from_audio

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/xtc", tags=["XTC Recognize"])

# 可选鉴权:环境变量 XTC_API_KEY 设置后,请求头 X-API-Key 需匹配
import os
_API_KEY = os.environ.get("XTC_API_KEY", "")


def _check_key(request: Request) -> Optional[JSONResponse]:
    """校验 X-API-Key。未配置则放行。"""
    if not _API_KEY:
        return None
    provided = request.headers.get("X-API-Key", "")
    if provided == _API_KEY:
        return None
    return JSONResponse(
        status_code=401,
        content={"code": 401, "msg": "unauthorized"},
    )


def _ok(data: Any) -> JSONResponse:
    return JSONResponse(
        status_code=200,
        content={"code": 200, "data": data},
        headers={
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type, X-API-Key",
        },
    )


def _err(msg: str, status: int = 500) -> JSONResponse:
    return JSONResponse(
        status_code=status,
        content={"code": status, "msg": msg},
        headers={
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type, X-API-Key",
        },
    )


@router.options("/{path:path}")
async def cors_preflight(path: str) -> JSONResponse:
    return JSONResponse(
        status_code=204,
        content=None,
        headers={
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
            "Access-Control-Allow-Headers": "Content-Type, X-API-Key",
            "Access-Control-Max-Age": "86400",
        },
    )


@router.get("/health")
async def health() -> JSONResponse:
    """健康检查。"""
    import shutil
    node_ok = shutil.which("node") is not None
    ffmpeg_ok = shutil.which("ffmpeg") is not None
    return _ok({
        "service": "xtc-recognize",
        "node": node_ok,
        "ffmpeg": ffmpeg_ok,
        "auth": bool(_API_KEY),
    })


@router.post("/recognize")
async def recognize(request: Request) -> JSONResponse:
    """识曲接口。

    Body: JSON
        {
          "audio": "<base64 编码的音频字节>",
          "mime": "audio/amr",       # 可选
          "name": "record.amr",      # 可选
          "duration": 3              # 可选,默认 3
        }

    Returns:
        {code:200, data:{fp, list:[{id,name,artists,album,cover,duration,matchScore,startTime}], raw_match}}
    """
    auth_err = _check_key(request)
    if auth_err:
        return auth_err

    try:
        body = await request.json()
        if not isinstance(body, dict):
            return _err("invalid body", 400)
        audio_b64 = body.get("audio")
        if not audio_b64:
            return _err("audio required", 400)
        duration = int(body.get("duration") or 3)
        if duration < 1 or duration > 10:
            duration = 3
        mime = str(body.get("mime") or "")
        name = str(body.get("name") or "")

        audio_bytes = base64.b64decode(audio_b64)
        if len(audio_bytes) > 2 * 1024 * 1024:
            return _err("audio too large (max 2MB)", 400)

        result = await recognize_from_audio(
            audio_bytes,
            duration=duration,
            mime=mime,
        )
        # raw_match 体积大且含敏感字段,对外只返回精简后的 list
        return _ok({
            "fp": result["fp"],
            "list": result["list"],
            "count": len(result["list"]),
        })
    except Exception as e:
        logger.exception("[recognize] error")
        return _err(str(e) or "recognize failed")


@router.get("/song/url")
async def song_url(
    request: Request,
    id: str = Query(...),
    unblock: str = Query("false"),
    level: str = Query("standard"),
) -> JSONResponse:
    """获取试听 URL。

    公开接口仅返回 30s 试听片段(VIP 歌曲)。unblock 暂不支持。
    """
    auth_err = _check_key(request)
    if auth_err:
        return auth_err
    try:
        data = await get_song_url(id, unblock=(unblock == "true"), level=level)
        return _ok(data)
    except Exception as e:
        logger.exception("[song/url] error")
        return _err(str(e) or "get url failed")


@router.get("/song/detail")
async def song_detail(
    request: Request,
    id: str = Query(...),
) -> JSONResponse:
    """获取歌曲详情。"""
    auth_err = _check_key(request)
    if auth_err:
        return auth_err
    try:
        data = await get_song_detail(id)
        if not data:
            return _err("song not found", 404)
        return _ok(data)
    except Exception as e:
        logger.exception("[song/detail] error")
        return _err(str(e) or "get detail failed")