a3216 commited on
Commit
82d0e4f
·
verified ·
1 Parent(s): 53cce4e

sync from GitHub e499291: feat(music): 新增识曲录音归档与排查面板

Browse files

Auto-synced from GitHub commit e499291f64ddc02a5e2151fb78f4036d1b1b7cbb

app/admin_html.py CHANGED
@@ -290,6 +290,13 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
290
  .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
291
  .table-wrap table { min-width: 480px; }
292
 
 
 
 
 
 
 
 
293
  /* 窄屏(<768px):侧边栏改为可折叠抽屉,主内容全宽 */
294
  @media (max-width: 768px) {
295
  header { padding: 10px 12px; gap: 8px; }
@@ -362,6 +369,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
362
  <button data-tab="requests">请求历史</button>
363
  <button data-tab="devices">设备</button>
364
  <button data-tab="tts">TTS</button>
 
365
  <button data-tab="test">测试</button>
366
  </nav>
367
  <main>
@@ -816,6 +824,72 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
816
  <audio id="ttsTestAudio" controls style="width:100%;margin-top:8px;display:none"></audio>
817
  </div>
818
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  <div class="tab-content" data-tab="test">
820
  <div class="card">
821
  <h2>对话测试</h2>
@@ -927,6 +1001,7 @@ document.querySelectorAll('nav.tabs button').forEach(btn => {
927
  else if (tab === 'requests') { loadReqLimit(); loadRequests(1); loadReqStats(); }
928
  else if (tab === 'devices') loadDevices(1);
929
  else if (tab === 'tts') loadTtsSettings();
 
930
  };
931
  });
932
 
@@ -1723,6 +1798,181 @@ function stopTtsTest() {
1723
  $('ttsTestStatus').textContent = '已停止';
1724
  }
1725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1726
  // ===== 设备 =====
1727
  let _devPage = 1, _devPageSize = 60, _devTotal = 0;
1728
  let _curDeviceId = '', _devRecPage = 1, _devRecPageSize = 30, _devRecTotal = 0;
 
290
  .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
291
  .table-wrap table { min-width: 480px; }
292
 
293
+ /* 通用徽标(识曲录音状态等) */
294
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px;
295
+ background: rgba(255,255,255,0.06); color: var(--muted); }
296
+ /* 错误消息块 */
297
+ .err-msg { color: var(--err); background: rgba(248,113,113,0.08); border: 1px solid rgba(248,113,113,0.25);
298
+ padding: 8px 12px; border-radius: 6px; font-size: 13px; }
299
+
300
  /* 窄屏(<768px):侧边栏改为可折叠抽屉,主内容全宽 */
301
  @media (max-width: 768px) {
302
  header { padding: 10px 12px; gap: 8px; }
 
369
  <button data-tab="requests">请求历史</button>
370
  <button data-tab="devices">设备</button>
371
  <button data-tab="tts">TTS</button>
372
+ <button data-tab="music-records">识曲录音</button>
373
  <button data-tab="test">测试</button>
374
  </nav>
375
  <main>
 
824
  <audio id="ttsTestAudio" controls style="width:100%;margin-top:8px;display:none"></audio>
825
  </div>
826
  </div>
827
+
828
+ <!-- 识曲录音归档 -->
829
+ <div class="tab-content" data-tab="music-records">
830
+ <div class="card">
831
+ <h2>识曲录音归档 <span id="mrCount" class="muted" style="font-size:12px;font-weight:normal"></span></h2>
832
+ <p class="muted" style="margin:0 0 10px;font-size:13px">
833
+ 每次 <code>/v1/music/recognize</code> 调用都会归档录音与中间产物(PCM 大小/指纹长度/网易云原始响应),
834
+ 用于定位"未识别到歌曲"是录音质量、ffmpeg 解码、指纹生成还是网易云返回空。
835
+ 浏览器无法直接播 AMR,播放时后端用 ffmpeg 转码为 MP3。
836
+ </p>
837
+ <div class="filter-row">
838
+ <label class="shrink">最近</label>
839
+ <select id="mrHours" class="shrink">
840
+ <option value="">全部</option>
841
+ <option value="1">1 小时</option>
842
+ <option value="24" selected>24 小时</option>
843
+ <option value="168">7 天</option>
844
+ <option value="720">30 天</option>
845
+ </select>
846
+ <select id="mrOk" class="shrink">
847
+ <option value="">全部</option>
848
+ <option value="1">仅命中</option>
849
+ <option value="0">仅未命中</option>
850
+ </select>
851
+ <input id="mrDevice" class="grow" placeholder="设备 ID 模糊搜索">
852
+ <button class="shrink" onclick="loadMusicRecords(1)">查询</button>
853
+ <button class="ghost shrink" onclick="loadMusicRecords(_mrPage)">刷新</button>
854
+ <button class="danger shrink" onclick="clearMusicRecords()">清空</button>
855
+ </div>
856
+ <div id="mrList">点查询开始</div>
857
+ <div class="row" style="margin-top:12px;justify-content:space-between">
858
+ <div class="muted" id="mrPageInfo"></div>
859
+ <div class="row" style="gap:6px">
860
+ <button class="ghost small" onclick="mrPrev()">上一页</button>
861
+ <button class="ghost small" onclick="mrNext()">下一页</button>
862
+ </div>
863
+ </div>
864
+ </div>
865
+
866
+ <div class="card hidden" id="mrDetailCard">
867
+ <h2>录音详情 #<span id="mrDetailId"></span>
868
+ <button class="ghost small" onclick="closeMrDetail()">关闭</button>
869
+ <button class="danger small" onclick="delMrDetail()">删除此条</button>
870
+ </h2>
871
+ <div id="mrDetailBody">加载中...</div>
872
+ </div>
873
+
874
+ <div class="card">
875
+ <h2>容量设置</h2>
876
+ <div class="row" style="gap:6px">
877
+ <span class="muted">保留最近</span>
878
+ <select id="mrLimitSetting">
879
+ <option value="0">关闭归档</option>
880
+ <option value="50">50 条</option>
881
+ <option value="100">100 条</option>
882
+ <option value="200">200 条</option>
883
+ <option value="500">500 条</option>
884
+ <option value="1000">1000 条</option>
885
+ </select>
886
+ <span class="muted">条录音(超限自动删除最旧的)</span>
887
+ <button onclick="saveMrLimit()">保存</button>
888
+ </div>
889
+ <p class="muted">0 = 完全关闭录音归档;每条 AMR 约 50KB,磁盘占用 = 条数 × 50KB。</p>
890
+ </div>
891
+ </div>
892
+
893
  <div class="tab-content" data-tab="test">
894
  <div class="card">
895
  <h2>对话测试</h2>
 
1001
  else if (tab === 'requests') { loadReqLimit(); loadRequests(1); loadReqStats(); }
1002
  else if (tab === 'devices') loadDevices(1);
1003
  else if (tab === 'tts') loadTtsSettings();
1004
+ else if (tab === 'music-records') { loadMrLimit(); loadMusicRecords(1); }
1005
  };
1006
  });
1007
 
 
1798
  $('ttsTestStatus').textContent = '已停止';
1799
  }
1800
 
1801
+ // ===== 识曲录音归档 =====
1802
+ let _mrPage = 1, _mrPageSize = 30, _mrTotal = 0;
1803
+
1804
+ async function loadMusicRecords(page) {
1805
+ _mrPage = page || 1;
1806
+ const hours = $('mrHours').value;
1807
+ const okVal = $('mrOk').value;
1808
+ const device = $('mrDevice').value.trim();
1809
+ const params = new URLSearchParams();
1810
+ params.set('limit', _mrPageSize);
1811
+ params.set('offset', (_mrPage - 1) * _mrPageSize);
1812
+ if (hours) params.set('hours', hours);
1813
+ if (okVal !== '') params.set('ok', okVal);
1814
+ if (device) params.set('device_id', device);
1815
+ $('mrList').innerHTML = '<div class="muted">加载中...</div>';
1816
+ try {
1817
+ const r = await api('/admin/api/music-records?' + params.toString());
1818
+ _mrTotal = r.total || 0;
1819
+ $('mrCount').textContent = '(共 ' + _mrTotal + ' 条)';
1820
+ const items = r.items || [];
1821
+ if (!items.length) {
1822
+ $('mrList').innerHTML = '<div class="muted">无匹配记录</div>';
1823
+ $('mrPageInfo').textContent = '';
1824
+ return;
1825
+ }
1826
+ let html = '<table class="rec-table"><thead><tr>'
1827
+ + '<th>#</th><th>时间</th><th>时长</th><th>AMR</th><th>PCM</th><th>指纹</th>'
1828
+ + '<th>网易</th><th>结果</th><th>耗时</th><th>设备</th><th>操作</th>'
1829
+ + '</tr></thead><tbody>';
1830
+ for (const it of items) {
1831
+ const okBadge = it.result_count > 0
1832
+ ? '<span class="badge" style="background:rgba(74,222,128,0.15);color:var(--ok)">命中 ' + it.result_count + '</span>'
1833
+ : '<span class="badge" style="background:rgba(248,113,113,0.15);color:var(--err)">未命中</span>';
1834
+ const errTag = it.error_step
1835
+ ? '<span class="badge" style="background:rgba(251,191,36,0.15);color:var(--warn)" title="' + esc(it.error_msg || '').slice(0, 200) + '">失败:' + esc(it.error_step) + '</span>'
1836
+ : '';
1837
+ const ncmCell = it.ncm_code != null
1838
+ ? (String(it.ncm_code) === '200' ? '<span style="color:var(--ok)">' + it.ncm_code + '</span>' : '<span style="color:var(--err)">' + it.ncm_code + '</span>')
1839
+ : '<span class="muted">-</span>';
1840
+ html += '<tr>'
1841
+ + '<td>' + it.id + '</td>'
1842
+ + '<td title="' + fmtTime(it.ts) + '">' + _fmtRelative(it.ts) + '</td>'
1843
+ + '<td>' + it.duration + 's</td>'
1844
+ + '<td>' + fmtBytes(it.amr_size) + '</td>'
1845
+ + '<td>' + (it.pcm_size ? fmtBytes(it.pcm_size) : '<span class="muted">-</span>') + '</td>'
1846
+ + '<td>' + (it.fp_len ? it.fp_len + ' B' : '<span class="muted">-</span>') + '</td>'
1847
+ + '<td>' + ncmCell + (it.ncm_result_count != null ? ' / ' + it.ncm_result_count : '') + '</td>'
1848
+ + '<td>' + okBadge + ' ' + errTag + '</td>'
1849
+ + '<td>' + (it.elapsed_ms != null ? it.elapsed_ms + 'ms' : '<span class="muted">-</span>') + '</td>'
1850
+ + '<td class="mono" style="max-width:120px;overflow:hidden;text-overflow:ellipsis" title="' + esc(it.device_id || '') + '">' + esc(it.device_id || '-') + '</td>'
1851
+ + '<td><button class="ghost small" onclick="viewMusicRecord(' + it.id + ')">详情</button></td>'
1852
+ + '</tr>';
1853
+ }
1854
+ html += '</tbody></table>';
1855
+ $('mrList').innerHTML = html;
1856
+ const totalPages = Math.ceil(_mrTotal / _mrPageSize) || 1;
1857
+ $('mrPageInfo').textContent = '第 ' + _mrPage + ' / ' + totalPages + ' 页,共 ' + _mrTotal + ' 条';
1858
+ } catch (e) {
1859
+ $('mrList').innerHTML = '<div class="err-msg">加载失败: ' + esc(e.message) + '</div>';
1860
+ }
1861
+ }
1862
+
1863
+ function mrPrev() { if (_mrPage > 1) loadMusicRecords(_mrPage - 1); }
1864
+ function mrNext() { if (_mrPage < Math.ceil(_mrTotal / _mrPageSize)) loadMusicRecords(_mrPage + 1); }
1865
+
1866
+ async function viewMusicRecord(rid) {
1867
+ $('mrDetailCard').classList.remove('hidden');
1868
+ $('mrDetailId').textContent = rid;
1869
+ $('mrDetailBody').innerHTML = '<div class="muted">加载中...</div>';
1870
+ try {
1871
+ const r = await api('/admin/api/music-records/' + rid);
1872
+ const rec = r.record;
1873
+ if (!rec) { $('mrDetailBody').innerHTML = '<div class="err-msg">未找到</div>'; return; }
1874
+ // 构造 audio URL(admin key 通过 ?k= 传入)
1875
+ const audioUrl = '/admin/api/music-records/' + rid + '/audio?k=' + encodeURIComponent(ADMIN_KEY);
1876
+ let ncmRawPretty = rec.ncm_raw || '';
1877
+ if (ncmRawPretty) {
1878
+ try { ncmRawPretty = JSON.stringify(JSON.parse(ncmRawPretty), null, 2); } catch (e) {}
1879
+ }
1880
+ const errBlock = rec.error_step
1881
+ ? '<div class="err-msg" style="margin-top:8px"><b>失败步骤:</b> ' + esc(rec.error_step) + '<br><b>错误:</b> ' + esc(rec.error_msg || '') + '</div>'
1882
+ : '';
1883
+ const stepBadge = (cond, label) => cond
1884
+ ? '<span class="badge" style="background:rgba(74,222,128,0.15);color:var(--ok)">' + label + ' ✓</span>'
1885
+ : '<span class="badge" style="background:rgba(248,113,113,0.15);color:var(--err)">' + label + ' ✗</span>';
1886
+ let html = '<div class="row" style="gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px">'
1887
+ + stepBadge(rec.amr_size > 0, '录音上传')
1888
+ + stepBadge(rec.pcm_size > 0, 'ffmpeg 解码')
1889
+ + stepBadge(rec.fp_len > 0, '指纹生成')
1890
+ + stepBadge(rec.ncm_code === 200, '网易云响应')
1891
+ + stepBadge(rec.result_count > 0, '命中歌曲')
1892
+ + '</div>';
1893
+ html += '<div class="card" style="background:rgba(255,255,255,0.03);padding:10px 12px;margin-bottom:10px">'
1894
+ + '<h3 style="margin:0 0 8px">录音回放</h3>'
1895
+ + '<audio controls preload="metadata" src="' + audioUrl + '" style="width:100%"></audio>'
1896
+ + '<p class="muted" style="margin:6px 0 0;font-size:12px">AMR 大小 ' + fmtBytes(rec.amr_size) + ',转 MP3 后由浏览器播放;首次加载需 ffmpeg 转码。</p>'
1897
+ + '</div>';
1898
+ html += '<table class="rec-table"><tbody>'
1899
+ + '<tr><th>记录 ID</th><td>' + rec.id + '</td></tr>'
1900
+ + '<tr><th>时间</th><td>' + fmtTime(rec.ts) + '</td></tr>'
1901
+ + '<tr><th>录音时长</th><td>' + rec.duration + ' 秒</td></tr>'
1902
+ + '<tr><th>AMR 大小</th><td>' + fmtBytes(rec.amr_size) + '</td></tr>'
1903
+ + '<tr><th>PCM 大小</th><td>' + (rec.pcm_size ? fmtBytes(rec.pcm_size) + '(8000Hz mono f32le)' : '-') + '</td></tr>'
1904
+ + '<tr><th>指纹长度</th><td>' + (rec.fp_len || '-') + ' 字符(base64)</td></tr>'
1905
+ + '<tr><th>网易云 code</th><td>' + (rec.ncm_code != null ? rec.ncm_code : '-') + '</td></tr>'
1906
+ + '<tr><th>网易云 message</th><td>' + esc(rec.ncm_msg || '-') + '</td></tr>'
1907
+ + '<tr><th>网易 result 数</th><td>' + (rec.ncm_result_count != null ? rec.ncm_result_count : '-') + '</td></tr>'
1908
+ + '<tr><th>格式化结果数</th><td>' + rec.result_count + '</td></tr>'
1909
+ + '<tr><th>总耗时</th><td>' + (rec.elapsed_ms != null ? rec.elapsed_ms + ' ms' : '-') + '</td></tr>'
1910
+ + '<tr><th>客户端 IP</th><td>' + esc(rec.client_ip || '-') + '</td></tr>'
1911
+ + '<tr><th>设备 ID</th><td class="mono">' + esc(rec.device_id || '-') + '</td></tr>'
1912
+ + '<tr><th>访问 Key</th><td class="mono">' + esc(rec.access_key || '-') + '</td></tr>'
1913
+ + '<tr><th>User-Agent</th><td>' + esc(rec.user_agent || '-') + '</td></tr>'
1914
+ + '</tbody></table>';
1915
+ html += errBlock;
1916
+ if (ncmRawPretty) {
1917
+ html += '<div class="card" style="background:rgba(255,255,255,0.03);padding:10px 12px;margin-top:10px">'
1918
+ + '<h3 style="margin:0 0 6px">网易云原始响应</h3>'
1919
+ + '<pre style="max-height:400px;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all">'
1920
+ + esc(ncmRawPretty) + '</pre></div>';
1921
+ }
1922
+ $('mrDetailBody').innerHTML = html;
1923
+ $('mrDetailBody').scrollIntoView({behavior:'smooth', block:'start'});
1924
+ } catch (e) {
1925
+ $('mrDetailBody').innerHTML = '<div class="err-msg">加载失败: ' + esc(e.message) + '</div>';
1926
+ }
1927
+ }
1928
+
1929
+ function closeMrDetail() {
1930
+ $('mrDetailCard').classList.add('hidden');
1931
+ $('mrDetailBody').innerHTML = '';
1932
+ }
1933
+
1934
+ async function delMrDetail() {
1935
+ const rid = parseInt($('mrDetailId').textContent, 10);
1936
+ if (!rid) return;
1937
+ if (!confirm('确认删除录音 #' + rid + '?')) return;
1938
+ try {
1939
+ await api('/admin/api/music-records/' + rid, {method: 'DELETE'});
1940
+ toast('已删除 #' + rid);
1941
+ closeMrDetail();
1942
+ await loadMusicRecords(_mrPage);
1943
+ } catch (e) {
1944
+ toast('删除失败: ' + e.message, true);
1945
+ }
1946
+ }
1947
+
1948
+ async function clearMusicRecords() {
1949
+ if (!confirm('确认清空全部识曲录音?此操作不可恢复。')) return;
1950
+ try {
1951
+ const r = await api('/admin/api/music-records', {method: 'DELETE'});
1952
+ toast('已清空 ' + r.cleared + ' 条');
1953
+ await loadMusicRecords(1);
1954
+ } catch (e) {
1955
+ toast('清空失败: ' + e.message, true);
1956
+ }
1957
+ }
1958
+
1959
+ async function loadMrLimit() {
1960
+ try {
1961
+ const r = await api('/admin/api/music-records/settings');
1962
+ $('mrLimitSetting').value = String(r.limit);
1963
+ } catch (e) { /* 静默 */ }
1964
+ }
1965
+
1966
+ async function saveMrLimit() {
1967
+ const limit = parseInt($('mrLimitSetting').value, 10);
1968
+ try {
1969
+ const r = await api('/admin/api/music-records/settings', {method: 'PUT', body: {limit}});
1970
+ toast('已保存:保留最近 ' + r.limit + ' 条');
1971
+ } catch (e) {
1972
+ toast('保存失败: ' + e.message, true);
1973
+ }
1974
+ }
1975
+
1976
  // ===== 设备 =====
1977
  let _devPage = 1, _devPageSize = 60, _devTotal = 0;
1978
  let _curDeviceId = '', _devRecPage = 1, _devRecPageSize = 30, _devRecTotal = 0;
app/api/music.py CHANGED
@@ -8,7 +8,7 @@ from __future__ import annotations
8
  import logging
9
  from typing import Optional
10
 
11
- from fastapi import APIRouter, Depends, File, Query, UploadFile
12
  from fastapi.responses import JSONResponse
13
 
14
  from ..auth import require_access_key
@@ -22,6 +22,7 @@ router = APIRouter(prefix="/v1/music", tags=["music"])
22
 
23
  @router.post("/recognize")
24
  async def recognize(
 
25
  file: UploadFile = File(..., description="AMR 录音文件"),
26
  duration: int = Query(8, ge=3, le=15, description="录音时长(秒)"),
27
  _key: str = Depends(require_access_key),
@@ -29,6 +30,7 @@ async def recognize(
29
  """听歌识曲:上传 AMR 录音,返回识别结果列表。
30
 
31
  链路:AMR → ffmpeg 转 PCM → node wasm 生成指纹 → 网易云 audio_match。
 
32
  """
33
  amr_bytes = await file.read()
34
  if not amr_bytes:
@@ -36,8 +38,21 @@ async def recognize(
36
  # 限制上传大小 2MB(8 秒 AMR 约 50KB,留充足余量)
37
  if len(amr_bytes) > 2 * 1024 * 1024:
38
  raise HttpError("file too large", status=413, code="bad_request", hint="录音文件超过 2MB 限制")
 
 
 
 
 
 
 
39
  try:
40
- result = await music_service.recognize(amr_bytes, duration=duration)
 
 
 
 
 
 
41
  return JSONResponse(result)
42
  except HttpError:
43
  raise
 
8
  import logging
9
  from typing import Optional
10
 
11
+ from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
12
  from fastapi.responses import JSONResponse
13
 
14
  from ..auth import require_access_key
 
22
 
23
  @router.post("/recognize")
24
  async def recognize(
25
+ request: Request,
26
  file: UploadFile = File(..., description="AMR 录音文件"),
27
  duration: int = Query(8, ge=3, le=15, description="录音时长(秒)"),
28
  _key: str = Depends(require_access_key),
 
30
  """听歌识曲:上传 AMR 录音,返回识别结果列表。
31
 
32
  链路:AMR → ffmpeg 转 PCM → node wasm 生成指纹 → 网易云 audio_match。
33
+ 每次请求都会归档录音与诊断信息到 music_record 表(admin 面板可回放)。
34
  """
35
  amr_bytes = await file.read()
36
  if not amr_bytes:
 
38
  # 限制上传大小 2MB(8 秒 AMR 约 50KB,留充足余量)
39
  if len(amr_bytes) > 2 * 1024 * 1024:
40
  raise HttpError("file too large", status=413, code="bad_request", hint="录音文件超过 2MB 限制")
41
+ # 提取设备 ID:优先 header x-xtc-device-id,其次 query 参数 device_id
42
+ device_id = (
43
+ request.headers.get("x-xtc-device-id")
44
+ or request.headers.get("x-device-id")
45
+ or request.query_params.get("device_id")
46
+ or ""
47
+ )
48
  try:
49
+ result = await music_service.recognize(
50
+ amr_bytes, duration=duration,
51
+ client_ip=request.client.host if request.client else "",
52
+ device_id=device_id,
53
+ access_key=_key,
54
+ user_agent=request.headers.get("user-agent", ""),
55
+ )
56
  return JSONResponse(result)
57
  except HttpError:
58
  raise
app/api/music_records.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """识曲录音管理 API(admin):/admin/api/music-records
2
+
3
+ - GET /admin/api/music-records 列表(摘要,含筛选)
4
+ - GET /admin/api/music-records/{id} 详情(含完整 ncm_raw / error_msg)
5
+ - GET /admin/api/music-records/{id}/audio 播放录音(AMR→MP3 on-the-fly)
6
+ - DELETE /admin/api/music-records/{id} 删除单条
7
+ - DELETE /admin/api/music-records 清空全部
8
+ - GET /admin/api/music-records/settings 容量配置
9
+ - PUT /admin/api/music-records/settings 更新容量
10
+
11
+ audio 端点支持 ?k=<admin_key> URL 参数鉴权(HTML5 <audio src> 无法自定义 header)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import subprocess
18
+ import tempfile
19
+ from typing import Optional
20
+
21
+ from fastapi import APIRouter, Body, Depends, Query, Request
22
+ from fastapi.responses import Response
23
+
24
+ from ..errors import HttpError
25
+ from ..services import music_record_store
26
+ from ._common import audit_admin, ok_with_cors
27
+ from .admin import _require_admin
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ router = APIRouter(prefix="/admin/api/music-records", tags=["music-records"])
32
+
33
+ # 浏览器无法直接播放 AMR;用 ffmpeg 转换为 MP3。结果缓存到内存(每条录音仅几 KB)
34
+ # 缓存键: record_id;值: mp3 字节。条目在原录音删除时随之失效(list/详情接口从 DB 读,
35
+ # 录音文件被删后 amr_path 失效,下次请求 audio 会返回 410)
36
+ _AUDIO_CACHE: dict[int, bytes] = {}
37
+ _AUDIO_CACHE_MAX = 50 # 最多缓存 50 条录音的 mp3
38
+
39
+
40
+ def _verify_admin_query_or_header(request: Request, k: Optional[str]) -> str:
41
+ """audio 端点鉴权:header 优先,否则接受 ?k= URL 参数。
42
+
43
+ HTML5 <audio src> 无法携带自定义 header,admin 面板播放录音时只能把 admin key
44
+ 放在 URL query 中。这是 admin 接口与 request-log 详情查看一样的安全级别。
45
+ """
46
+ # 先按标准 header 走
47
+ auth = request.headers.get("authorization")
48
+ admin_header = (
49
+ request.headers.get("x-xtc-admin-key")
50
+ or request.headers.get("X-XTC-Admin-Key")
51
+ )
52
+ key = None
53
+ if admin_header:
54
+ key = admin_header.strip()
55
+ elif auth:
56
+ a = auth.strip()
57
+ if a.lower().startswith("bearer "):
58
+ key = a[7:].strip()
59
+ else:
60
+ key = a
61
+ if not key and k:
62
+ key = k.strip()
63
+ from ..auth import verify_admin_key
64
+ verify_admin_key(key)
65
+ return key or ""
66
+
67
+
68
+ @router.get("")
69
+ async def list_records(
70
+ _admin: str = Depends(_require_admin),
71
+ limit: int = Query(default=50, ge=1, le=500),
72
+ offset: int = Query(default=0, ge=0),
73
+ hours: Optional[int] = Query(default=None, ge=1, le=720),
74
+ ok: Optional[int] = Query(default=None, ge=0, le=1),
75
+ device_id: Optional[str] = Query(default=None),
76
+ ):
77
+ ok_filter = None
78
+ if ok is not None:
79
+ ok_filter = bool(ok)
80
+ result = music_record_store.list_records(
81
+ limit=limit, offset=offset, hours=hours,
82
+ ok=ok_filter, device_id=device_id,
83
+ )
84
+ return ok_with_cors(result)
85
+
86
+
87
+ @router.get("/settings")
88
+ async def get_settings_endpoint(_admin: str = Depends(_require_admin)):
89
+ return ok_with_cors({"limit": music_record_store.get_limit()})
90
+
91
+
92
+ @router.put("/settings")
93
+ async def put_settings_endpoint(
94
+ body: dict = Body(default={}),
95
+ _admin: str = Depends(_require_admin),
96
+ ):
97
+ limit = body.get("limit")
98
+ if limit is None:
99
+ raise HttpError("limit is required", status=400, code="bad_request")
100
+ new_limit = music_record_store.set_limit(int(limit))
101
+ return ok_with_cors({"limit": new_limit})
102
+
103
+
104
+ @router.get("/{rid}")
105
+ async def get_record(
106
+ rid: int,
107
+ _admin: str = Depends(_require_admin),
108
+ ):
109
+ rec = music_record_store.get_record(rid)
110
+ if not rec:
111
+ raise HttpError(f"music record not found: {rid}", status=404, code="not_found")
112
+ # ncm_raw 可能是 JSON 字符串,前端展示时再 parse
113
+ return ok_with_cors({"record": rec})
114
+
115
+
116
+ @router.get("/{rid}/audio")
117
+ async def get_audio(
118
+ rid: int,
119
+ request: Request,
120
+ k: Optional[str] = Query(default=None),
121
+ ):
122
+ """返回 MP3 录音供浏览器播放。
123
+
124
+ 使用 ?k=<admin_key> 鉴权,<audio src> 元素无法带自定义 header。
125
+ AMR→MP3 转码用 ffmpeg,结果按 record_id 缓存到内存。
126
+ """
127
+ _verify_admin_query_or_header(request, k)
128
+ rec = music_record_store.get_record(rid)
129
+ if not rec:
130
+ raise HttpError(f"music record not found: {rid}", status=404, code="not_found")
131
+
132
+ # 命中缓存直接返回
133
+ if rid in _AUDIO_CACHE:
134
+ mp3 = _AUDIO_CACHE[rid]
135
+ return Response(
136
+ content=mp3, media_type="audio/mpeg",
137
+ headers={"Content-Length": str(len(mp3)),
138
+ "Cache-Control": "private, max-age=3600"},
139
+ )
140
+
141
+ amr_bytes = music_record_store.read_amr(rid)
142
+ if not amr_bytes:
143
+ raise HttpError("amr file missing (可能 Space 已重建,磁盘文件丢失)",
144
+ status=410, code="gone")
145
+
146
+ # ffmpeg 转 MP3:AMR → 16k mono mp3
147
+ mp3 = await _transcode_amr_to_mp3(amr_bytes)
148
+ if mp3 is None:
149
+ raise HttpError("ffmpeg transcode failed", status=500, code="internal_error")
150
+
151
+ # 写入缓存(FIFO)
152
+ if len(_AUDIO_CACHE) >= _AUDIO_CACHE_MAX:
153
+ oldest = next(iter(_AUDIO_CACHE))
154
+ del _AUDIO_CACHE[oldest]
155
+ _AUDIO_CACHE[rid] = mp3
156
+
157
+ return Response(
158
+ content=mp3, media_type="audio/mpeg",
159
+ headers={"Content-Length": str(len(mp3)),
160
+ "Cache-Control": "private, max-age=3600"},
161
+ )
162
+
163
+
164
+ async def _transcode_amr_to_mp3(amr_bytes: bytes) -> Optional[bytes]:
165
+ """用 ffmpeg 把 AMR 字节流转成 MP3。失败返回 None。"""
166
+ import os
167
+ import shutil
168
+ ffmpeg_bin = os.environ.get("FFMPEG_BIN", "ffmpeg")
169
+ if not shutil.which(ffmpeg_bin) and not os.path.exists(ffmpeg_bin):
170
+ logger.error("[music-records/audio] ffmpeg not installed")
171
+ return None
172
+
173
+ with tempfile.NamedTemporaryFile(suffix=".amr", delete=False) as fin:
174
+ fin.write(amr_bytes)
175
+ fin_path = fin.name
176
+ out_path = fin_path + ".mp3"
177
+ try:
178
+ proc = await asyncio.create_subprocess_exec(
179
+ ffmpeg_bin, "-y", "-i", fin_path,
180
+ "-ar", "16000", "-ac", "1", "-b:a", "32k",
181
+ out_path,
182
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
183
+ )
184
+ _, stderr = await proc.communicate()
185
+ if proc.returncode != 0:
186
+ err = stderr.decode("utf-8", "replace")[-500:]
187
+ logger.error("[music-records/audio] ffmpeg transcode failed: %s", err)
188
+ return None
189
+ with open(out_path, "rb") as f:
190
+ return f.read()
191
+ except Exception as e:
192
+ logger.error("[music-records/audio] transcode exception: %s", e)
193
+ return None
194
+ finally:
195
+ for p in (fin_path, out_path):
196
+ try:
197
+ import os as _os
198
+ _os.unlink(p)
199
+ except OSError:
200
+ pass
201
+
202
+
203
+ @router.delete("/{rid}")
204
+ async def delete_record(
205
+ rid: int,
206
+ admin_key: str = Depends(_require_admin),
207
+ ):
208
+ # 失效音频缓存
209
+ _AUDIO_CACHE.pop(rid, None)
210
+ ok = music_record_store.delete_record(rid)
211
+ if not ok:
212
+ raise HttpError(f"music record not found: {rid}", status=404, code="not_found")
213
+ audit_admin("admin.music_record.delete", admin_key, target=str(rid))
214
+ return ok_with_cors({"deleted": True, "id": rid})
215
+
216
+
217
+ @router.delete("")
218
+ async def clear_all(admin_key: str = Depends(_require_admin)):
219
+ _AUDIO_CACHE.clear()
220
+ count = music_record_store.clear_all()
221
+ audit_admin(
222
+ "admin.music_record.clear_all",
223
+ admin_key, target="all", cleared=count,
224
+ )
225
+ return ok_with_cors({"cleared": count})
app/database.py CHANGED
@@ -273,9 +273,33 @@ def _create_schema(conn: sqlite3.Connection) -> None:
273
  );
274
  CREATE INDEX IF NOT EXISTS idx_request_log_ts ON request_log(ts);
275
  CREATE INDEX IF NOT EXISTS idx_request_log_path ON request_log(path);
276
- CREATE INDEX IF NOT EXISTS idx_request_log_status ON request_log(status_code);
277
  CREATE INDEX IF NOT EXISTS idx_request_log_key ON request_log(access_key);
278
  CREATE INDEX IF NOT EXISTS idx_request_log_device ON request_log(device_id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  """
280
  )
281
  # 兼容已存在的库:若旧表无 device_id 列则补加(ALTER TABLE 幂等检查)
 
273
  );
274
  CREATE INDEX IF NOT EXISTS idx_request_log_ts ON request_log(ts);
275
  CREATE INDEX IF NOT EXISTS idx_request_log_path ON request_log(path);
276
+ CREATE INDEX IF NOT EXISTS idx_request_log_status ON request_log(status);
277
  CREATE INDEX IF NOT EXISTS idx_request_log_key ON request_log(access_key);
278
  CREATE INDEX IF NOT EXISTS idx_request_log_device ON request_log(device_id);
279
+
280
+ -- 识曲录音归档:用于排查"未识别到歌曲"问题(录音质量/解码/指纹/网易响应)
281
+ CREATE TABLE IF NOT EXISTS music_record (
282
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
283
+ ts INTEGER NOT NULL,
284
+ duration INTEGER NOT NULL,
285
+ amr_size INTEGER NOT NULL DEFAULT 0,
286
+ amr_path TEXT,
287
+ pcm_size INTEGER NOT NULL DEFAULT 0,
288
+ fp_len INTEGER NOT NULL DEFAULT 0,
289
+ ncm_code INTEGER,
290
+ ncm_msg TEXT,
291
+ ncm_result_count INTEGER NOT NULL DEFAULT 0,
292
+ ncm_raw TEXT,
293
+ result_count INTEGER NOT NULL DEFAULT 0,
294
+ elapsed_ms INTEGER,
295
+ error_step TEXT,
296
+ error_msg TEXT,
297
+ client_ip TEXT,
298
+ device_id TEXT,
299
+ access_key TEXT,
300
+ user_agent TEXT
301
+ );
302
+ CREATE INDEX IF NOT EXISTS idx_music_record_ts ON music_record(ts);
303
  """
304
  )
305
  # 兼容已存在的库:若旧表无 device_id 列则补加(ALTER TABLE 幂等检查)
app/main.py CHANGED
@@ -247,6 +247,7 @@ def create_app() -> FastAPI:
247
  image_fix,
248
  logs,
249
  music,
 
250
  openai_compat,
251
  pseudo_stream,
252
  request_logs,
@@ -278,6 +279,7 @@ def create_app() -> FastAPI:
278
  app.include_router(user_files.router)
279
  app.include_router(user_backups.router)
280
  app.include_router(music.router)
 
281
  app.include_router(logs.router)
282
 
283
  @app.options("/{path:path}")
 
247
  image_fix,
248
  logs,
249
  music,
250
+ music_records,
251
  openai_compat,
252
  pseudo_stream,
253
  request_logs,
 
279
  app.include_router(user_files.router)
280
  app.include_router(user_backups.router)
281
  app.include_router(music.router)
282
+ app.include_router(music_records.router)
283
  app.include_router(logs.router)
284
 
285
  @app.options("/{path:path}")
app/services/music_record_store.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """识曲录音归档存储:AMR 文件落盘 + 元数据入 SQLite。
2
+
3
+ 仅用于排查"未识别到歌曲"问题:手表录音质量、ffmpeg 解码、指纹生成、网易云响应
4
+ 全链路都可在此定位。
5
+
6
+ 设计:
7
+ - AMR 文件存到本地 /data/music_records/<id>.amr(HF 免费档 ephemeral,重启丢失可接受)
8
+ - 元数据存 SQLite,与 request_log 类似轮转
9
+ - 容量默认 100 条(每条 ~50KB AMR,~5MB 总量上限)
10
+ - 浏览器不能直接播 AMR,提供 audio 端点用 ffmpeg 转 MP3 on-the-fly
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from ..database import get_conn
21
+ from ..db_writer import enqueue, is_started
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # 录音文件落盘根目录:优先 /data(HF 持久卷),不可写回退 /tmp
26
+ _RECORDS_DIR_CANDIDATES = ("/data/music_records", "/tmp/music_records")
27
+ _DEFAULT_LIMIT = 100
28
+
29
+
30
+ def _records_dir() -> Path:
31
+ for cand in _RECORDS_DIR_CANDIDATES:
32
+ p = Path(cand)
33
+ try:
34
+ p.mkdir(parents=True, exist_ok=True)
35
+ (p / ".wtest").touch(exist_ok=True)
36
+ (p / ".wtest").unlink(missing_ok=True)
37
+ return p
38
+ except Exception:
39
+ continue
40
+ p = Path("/tmp/music_records")
41
+ p.mkdir(parents=True, exist_ok=True)
42
+ return p
43
+
44
+
45
+ def amr_path(record_id: int) -> Path:
46
+ """根据记录 id 构造 AMR 文件路径。"""
47
+ return _records_dir() / f"{record_id}.amr"
48
+
49
+
50
+ def _ensure_limit_default(conn) -> None:
51
+ try:
52
+ row = conn.execute(
53
+ "SELECT value FROM kv WHERE key = 'music_record_limit'"
54
+ ).fetchone()
55
+ if not row:
56
+ conn.execute(
57
+ "INSERT INTO kv(key, value, updated_at) VALUES(?,?,?)",
58
+ ("music_record_limit", str(_DEFAULT_LIMIT), int(time.time())),
59
+ )
60
+ except Exception as e:
61
+ logger.warning("[music_record] init default limit failed: %s", e)
62
+
63
+
64
+ def get_limit() -> int:
65
+ """读取容量上限。启动后缓存避免每次 SELECT。"""
66
+ try:
67
+ with get_conn() as conn:
68
+ row = conn.execute(
69
+ "SELECT value FROM kv WHERE key = 'music_record_limit'"
70
+ ).fetchone()
71
+ if row:
72
+ return int(row["value"])
73
+ except Exception:
74
+ pass
75
+ return _DEFAULT_LIMIT
76
+
77
+
78
+ def set_limit(limit: int) -> int:
79
+ new_limit = max(0, min(1000, int(limit)))
80
+ try:
81
+ with get_conn() as conn:
82
+ # INSERT OR REPLACE 保证即使首次设置(kv 中无此 key)也能写入
83
+ conn.execute(
84
+ "INSERT OR REPLACE INTO kv(key, value, updated_at) VALUES(?,?,?)",
85
+ ("music_record_limit", str(new_limit), int(time.time())),
86
+ )
87
+ except Exception as e:
88
+ logger.warning("[music_record] set limit failed: %s", e)
89
+ # 缩容时立即轮转
90
+ _rotate()
91
+ return new_limit
92
+
93
+
94
+ def create_record(
95
+ *,
96
+ duration: int,
97
+ amr_bytes: bytes,
98
+ client_ip: Optional[str] = None,
99
+ device_id: Optional[str] = None,
100
+ access_key: Optional[str] = None,
101
+ user_agent: Optional[str] = None,
102
+ ) -> int:
103
+ """创建一条识曲录音记录并落盘 AMR 字节。返回新记录 id。
104
+
105
+ 调用方拿到 id 后,在后续各步骤用 update_record 增量更新诊断字段。
106
+ """
107
+ ts = int(time.time())
108
+ # 先 INSERT 拿到 id
109
+ with get_conn() as conn:
110
+ cur = conn.execute(
111
+ "INSERT INTO music_record(ts, duration, amr_size, client_ip, device_id, "
112
+ "access_key, user_agent) VALUES(?,?,?,?,?,?,?)",
113
+ (ts, int(duration), len(amr_bytes),
114
+ (client_ip or "")[:64], (device_id or "")[:128],
115
+ (access_key or "")[:64], (user_agent or "")[:256]),
116
+ )
117
+ rid = int(cur.lastrowid)
118
+ # 落盘 AMR
119
+ try:
120
+ path = amr_path(rid)
121
+ path.write_bytes(amr_bytes)
122
+ with get_conn() as conn:
123
+ conn.execute(
124
+ "UPDATE music_record SET amr_path=? WHERE id=?",
125
+ (str(path), rid),
126
+ )
127
+ except Exception as e:
128
+ logger.warning("[music_record %d] save amr failed: %s", rid, e)
129
+ _rotate()
130
+ return rid
131
+
132
+
133
+ def update_record(record_id: int, **fields) -> None:
134
+ """增量更新记录字段。仅允许 schema 中存在的列。
135
+
136
+ 任意失败不抛出,避免影响识曲主流程。
137
+ """
138
+ allowed = {
139
+ "pcm_size", "fp_len", "ncm_code", "ncm_msg", "ncm_result_count",
140
+ "ncm_raw", "result_count", "elapsed_ms", "error_step", "error_msg",
141
+ }
142
+ sets, args = [], []
143
+ for k, v in fields.items():
144
+ if k not in allowed:
145
+ continue
146
+ if v is None:
147
+ continue
148
+ # ncm_raw / error_msg 可能很长,截断避免 SQLite 行过大
149
+ if isinstance(v, str) and len(v) > 65536:
150
+ v = v[:65536] + "...[truncated]"
151
+ sets.append(f"{k}=?")
152
+ args.append(v)
153
+ if not sets:
154
+ return
155
+ args.append(record_id)
156
+ sql = f"UPDATE music_record SET {','.join(sets)} WHERE id=?"
157
+
158
+ def _task(c):
159
+ try:
160
+ c.execute(sql, args)
161
+ except Exception as e:
162
+ logger.warning("[music_record %d] update failed: %s, fields=%s",
163
+ record_id, e, list(fields.keys()))
164
+
165
+ try:
166
+ if is_started():
167
+ enqueue(_task)
168
+ return
169
+ except Exception:
170
+ pass
171
+ try:
172
+ with get_conn() as conn:
173
+ _task(conn)
174
+ except Exception as e:
175
+ logger.warning("[music_record %d] update sync failed: %s", record_id, e)
176
+
177
+
178
+ def _rotate() -> None:
179
+ """超容量删除最旧记录(含磁盘文件)。"""
180
+ limit = get_limit()
181
+ if limit <= 0:
182
+ # 关闭归档:清空全部
183
+ clear_all()
184
+ return
185
+ try:
186
+ with get_conn() as conn:
187
+ row = conn.execute(
188
+ "SELECT COUNT(*) AS c FROM music_record"
189
+ ).fetchone()
190
+ total = int(row["c"]) if row else 0
191
+ if total <= limit:
192
+ return
193
+ excess = total - limit
194
+ # 取最旧的 excess 条
195
+ old = conn.execute(
196
+ "SELECT id, amr_path FROM music_record ORDER BY id ASC LIMIT ?",
197
+ (excess,),
198
+ ).fetchall()
199
+ ids = [r["id"] for r in old]
200
+ for r in old:
201
+ p = r["amr_path"]
202
+ if p:
203
+ try:
204
+ os.unlink(p)
205
+ except Exception:
206
+ pass
207
+ conn.execute(
208
+ f"DELETE FROM music_record WHERE id IN ({','.join('?' * len(ids))})",
209
+ ids,
210
+ )
211
+ except Exception as e:
212
+ logger.warning("[music_record] rotate failed: %s", e)
213
+
214
+
215
+ def list_records(
216
+ *, limit: int = 50, offset: int = 0,
217
+ hours: Optional[int] = None,
218
+ ok: Optional[bool] = None,
219
+ device_id: Optional[str] = None,
220
+ ) -> dict:
221
+ """列表查询(摘要,不带 ncm_raw / error_msg 长文本)。"""
222
+ where, args = [], []
223
+ if hours:
224
+ where.append("ts >= ?")
225
+ args.append(int(time.time()) - hours * 3600)
226
+ if ok is True:
227
+ where.append("result_count > 0")
228
+ elif ok is False:
229
+ where.append("result_count = 0")
230
+ if device_id:
231
+ where.append("device_id LIKE ?")
232
+ args.append(f"%{device_id}%")
233
+ where_clause = ("WHERE " + " AND ".join(where)) if where else ""
234
+ try:
235
+ with get_conn() as conn:
236
+ row = conn.execute(
237
+ f"SELECT COUNT(*) AS c FROM music_record {where_clause}",
238
+ args,
239
+ ).fetchone()
240
+ total = int(row["c"]) if row else 0
241
+ rows = conn.execute(
242
+ f"SELECT id, ts, duration, amr_size, pcm_size, fp_len, "
243
+ f"ncm_code, ncm_msg, ncm_result_count, result_count, "
244
+ f"elapsed_ms, error_step, client_ip, device_id "
245
+ f"FROM music_record {where_clause} "
246
+ f"ORDER BY id DESC LIMIT ? OFFSET ?",
247
+ args + [int(limit), int(offset)],
248
+ ).fetchall()
249
+ items = [dict(r) for r in rows]
250
+ return {"total": total, "items": items, "limit": limit, "offset": offset}
251
+ except Exception as e:
252
+ logger.warning("[music_record] list failed: %s", e)
253
+ return {"total": 0, "items": [], "limit": limit, "offset": offset}
254
+
255
+
256
+ def get_record(record_id: int) -> Optional[dict]:
257
+ """详情:包含完整 ncm_raw / error_msg。"""
258
+ try:
259
+ with get_conn() as conn:
260
+ row = conn.execute(
261
+ "SELECT * FROM music_record WHERE id=?",
262
+ (record_id,),
263
+ ).fetchone()
264
+ return dict(row) if row else None
265
+ except Exception as e:
266
+ logger.warning("[music_record %d] get failed: %s", record_id, e)
267
+ return None
268
+
269
+
270
+ def delete_record(record_id: int) -> bool:
271
+ try:
272
+ with get_conn() as conn:
273
+ row = conn.execute(
274
+ "SELECT amr_path FROM music_record WHERE id=?",
275
+ (record_id,),
276
+ ).fetchone()
277
+ if not row:
278
+ return False
279
+ p = row["amr_path"]
280
+ if p:
281
+ try:
282
+ os.unlink(p)
283
+ except Exception:
284
+ pass
285
+ conn.execute("DELETE FROM music_record WHERE id=?", (record_id,))
286
+ return True
287
+ except Exception as e:
288
+ logger.warning("[music_record %d] delete failed: %s", record_id, e)
289
+ return False
290
+
291
+
292
+ def clear_all() -> int:
293
+ try:
294
+ with get_conn() as conn:
295
+ rows = conn.execute("SELECT amr_path FROM music_record").fetchall()
296
+ for r in rows:
297
+ p = r["amr_path"]
298
+ if p:
299
+ try:
300
+ os.unlink(p)
301
+ except Exception:
302
+ pass
303
+ cur = conn.execute("DELETE FROM music_record")
304
+ return int(cur.rowcount or 0)
305
+ except Exception as e:
306
+ logger.warning("[music_record] clear_all failed: %s", e)
307
+ return 0
308
+
309
+
310
+ def read_amr(record_id: int) -> Optional[bytes]:
311
+ """读取 AMR 原始字节。文件丢失返回 None。"""
312
+ try:
313
+ path = amr_path(record_id)
314
+ if not path.exists():
315
+ # 数据库可能有旧路径,尝试从 amr_path 列恢复
316
+ with get_conn() as conn:
317
+ row = conn.execute(
318
+ "SELECT amr_path FROM music_record WHERE id=?",
319
+ (record_id,),
320
+ ).fetchone()
321
+ if row and row["amr_path"]:
322
+ p2 = Path(row["amr_path"])
323
+ if p2.exists():
324
+ return p2.read_bytes()
325
+ return None
326
+ return path.read_bytes()
327
+ except Exception as e:
328
+ logger.warning("[music_record %d] read amr failed: %s", record_id, e)
329
+ return None
app/services/music_service.py CHANGED
@@ -27,6 +27,7 @@ import httpx
27
  from Crypto.Cipher import AES
28
 
29
  from ..errors import HttpError
 
30
 
31
  logger = logging.getLogger(__name__)
32
 
@@ -244,27 +245,95 @@ async def _kuwo_search_and_url(song_name: str, artist: str, client: httpx.AsyncC
244
 
245
  # ===== 对外接口 =====
246
 
247
- async def recognize(amr_bytes: bytes, duration: int = 8) -> dict:
248
- """完整识曲流程:AMR → PCM → 指纹 → 网易云识别。返回格式化结果列表。"""
 
 
 
 
 
 
 
 
 
 
 
249
  t0 = time.time()
250
- pcm_bytes = await _amr_to_float32_pcm(amr_bytes)
251
- logger.info("[recognize] ffmpeg decode done, pcm bytes=%d, elapsed=%dms",
252
- len(pcm_bytes), int((time.time() - t0) * 1000))
 
 
 
 
 
 
 
253
 
254
- fp = await _generate_fingerprint(pcm_bytes, duration)
255
- logger.info("[recognize] fingerprint generated, fp_len=%d, elapsed=%dms",
256
- len(fp), int((time.time() - t0) * 1000))
 
 
 
 
 
 
 
 
257
 
258
- async with httpx.AsyncClient() as client:
259
- raw = await _call_audio_match(fp, duration, client)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
- result = raw.get("result")
262
- if not result:
263
- return {"ok": True, "result": [], "message": "未识别到歌曲,可重试", "elapsed_ms": int((time.time() - t0) * 1000)}
 
 
 
 
 
264
 
265
  # 格式化:每首歌提取 id/name/artists/album/startTime
266
  songs = []
267
- for item in result:
268
  song = item.get("song") or {}
269
  if not song.get("id"):
270
  continue
@@ -275,11 +344,16 @@ async def recognize(amr_bytes: bytes, duration: int = 8) -> dict:
275
  "album": (song.get("album") or {}).get("name", ""),
276
  "startTime": item.get("startTime", 0),
277
  })
 
 
 
 
278
  return {
279
  "ok": True,
280
  "result": songs,
281
  "count": len(songs),
282
  "elapsed_ms": int((time.time() - t0) * 1000),
 
283
  }
284
 
285
 
 
27
  from Crypto.Cipher import AES
28
 
29
  from ..errors import HttpError
30
+ from ..services import music_record_store
31
 
32
  logger = logging.getLogger(__name__)
33
 
 
245
 
246
  # ===== 对外接口 =====
247
 
248
+ async def recognize(
249
+ amr_bytes: bytes,
250
+ duration: int = 8,
251
+ *,
252
+ client_ip: Optional[str] = None,
253
+ device_id: Optional[str] = None,
254
+ access_key: Optional[str] = None,
255
+ user_agent: Optional[str] = None,
256
+ ) -> dict:
257
+ """完整识曲流程:AMR → PCM → 指纹 → 网易云识别。返回格式化结果列表。
258
+
259
+ 每个关键步骤都会写入 music_record 表,用于在管理面板回放录音与定位失败原因。
260
+ """
261
  t0 = time.time()
262
+ # 创建归档记录:哪怕后续任何步骤失败,至少保留了 AMR 文件可回放
263
+ rid = music_record_store.create_record(
264
+ duration=duration,
265
+ amr_bytes=amr_bytes,
266
+ client_ip=client_ip,
267
+ device_id=device_id,
268
+ access_key=access_key,
269
+ user_agent=user_agent,
270
+ )
271
+ logger.info("[recognize rid=%d] archive created, amr_bytes=%d", rid, len(amr_bytes))
272
 
273
+ try:
274
+ pcm_bytes = await _amr_to_float32_pcm(amr_bytes)
275
+ except Exception as e:
276
+ music_record_store.update_record(
277
+ rid, error_step="ffmpeg", error_msg=str(e),
278
+ elapsed_ms=int((time.time() - t0) * 1000),
279
+ )
280
+ raise
281
+ music_record_store.update_record(rid, pcm_size=len(pcm_bytes))
282
+ logger.info("[recognize rid=%d] ffmpeg decode done, pcm bytes=%d, elapsed=%dms",
283
+ rid, len(pcm_bytes), int((time.time() - t0) * 1000))
284
 
285
+ try:
286
+ fp = await _generate_fingerprint(pcm_bytes, duration)
287
+ except Exception as e:
288
+ music_record_store.update_record(
289
+ rid, error_step="fingerprint", error_msg=str(e),
290
+ elapsed_ms=int((time.time() - t0) * 1000),
291
+ )
292
+ raise
293
+ music_record_store.update_record(rid, fp_len=len(fp))
294
+ logger.info("[recognize rid=%d] fingerprint generated, fp_len=%d, elapsed=%dms",
295
+ rid, len(fp), int((time.time() - t0) * 1000))
296
+
297
+ # 调网易云并保留原始响应(截断到 64KB 入库,足够排查)
298
+ ncm_raw_full: dict = {}
299
+ ncm_code: Optional[int] = None
300
+ ncm_msg: Optional[str] = None
301
+ ncm_result_count = 0
302
+ try:
303
+ async with httpx.AsyncClient() as client:
304
+ ncm_raw_full = await _call_audio_match(fp, duration, client)
305
+ except Exception as e:
306
+ music_record_store.update_record(
307
+ rid, error_step="netease", error_msg=str(e),
308
+ elapsed_ms=int((time.time() - t0) * 1000),
309
+ )
310
+ raise
311
+ # _call_audio_match 返回 data 字段(dict),保留 code/message 若存在
312
+ if isinstance(ncm_raw_full, dict):
313
+ ncm_code = ncm_raw_full.get("code")
314
+ ncm_msg = ncm_raw_full.get("message") or ncm_raw_full.get("msg")
315
+ raw_result = ncm_raw_full.get("result") if isinstance(ncm_raw_full, dict) else None
316
+ ncm_result_count = len(raw_result) if isinstance(raw_result, list) else 0
317
+ music_record_store.update_record(
318
+ rid,
319
+ ncm_code=ncm_code,
320
+ ncm_msg=(ncm_msg or "")[:512] if ncm_msg else None,
321
+ ncm_result_count=ncm_result_count,
322
+ ncm_raw=json.dumps(ncm_raw_full, ensure_ascii=False),
323
+ )
324
 
325
+ if not raw_result:
326
+ music_record_store.update_record(
327
+ rid, result_count=0,
328
+ elapsed_ms=int((time.time() - t0) * 1000),
329
+ )
330
+ return {"ok": True, "result": [], "message": "未识别到歌曲,可重试",
331
+ "elapsed_ms": int((time.time() - t0) * 1000),
332
+ "record_id": rid}
333
 
334
  # 格式化:每首歌提取 id/name/artists/album/startTime
335
  songs = []
336
+ for item in raw_result:
337
  song = item.get("song") or {}
338
  if not song.get("id"):
339
  continue
 
344
  "album": (song.get("album") or {}).get("name", ""),
345
  "startTime": item.get("startTime", 0),
346
  })
347
+ music_record_store.update_record(
348
+ rid, result_count=len(songs),
349
+ elapsed_ms=int((time.time() - t0) * 1000),
350
+ )
351
  return {
352
  "ok": True,
353
  "result": songs,
354
  "count": len(songs),
355
  "elapsed_ms": int((time.time() - t0) * 1000),
356
+ "record_id": rid,
357
  }
358
 
359