milk-git55 commited on
Commit
c3beb5e
·
verified ·
1 Parent(s): 10a0607

Update music_api.py

Browse files
Files changed (1) hide show
  1. music_api.py +190 -352
music_api.py CHANGED
@@ -1,432 +1,270 @@
1
  #!/usr/bin/env python3
2
- """
3
- 修复版音乐API - 适配 Gradio/Non-Docker Space (直接读取 Dataset)
4
- """
5
-
6
  import os
7
  import sqlite3
8
  import shutil
9
  from pathlib import Path
10
- from typing import Optional, List, Dict
11
- import urllib.parse
12
-
13
- from fastapi import FastAPI, Query, HTTPException, UploadFile, File, Form
14
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
15
- from fastapi.staticfiles import StaticFiles
16
  from fastapi.middleware.cors import CORSMiddleware
17
  import uvicorn
18
  from mutagen import File as MutagenFile
19
- from huggingface_hub import hf_hub_download, snapshot_download, HfApi
20
-
21
- # ==================== 配置部分 ====================
22
 
 
23
  class Config:
24
- """配置类"""
25
- APP_NAME = "音乐API"
26
- VERSION = "4.6.0"
27
-
28
- BASE_DIR = Path(__file__).resolve().parent
29
-
30
- # ===== Dataset 配置 =====
31
- # 请填入您的 Dataset 路径,例如 "your_username/my-music-library"
32
- DATASET_REPO_ID = "my-music-library"
33
- # 本地缓存目录 (HuggingFace 会自动缓存下载的文件)
34
- DATASET_CACHE_DIR = Path("/tmp/hf_cache")
35
 
36
- # ===== 本地存储 (新上传的文件) =====
37
- # 非Docker环境下,通常只有 /tmp 或 /home/user,这里用 /tmp 缓存新上传
38
- # 注意:Space 重启后 /tmp 会清空,这是 Gradio 模式的限制
39
- UPLOAD_DIR = Path("/tmp/uploads")
40
 
41
- # 数据库路径
 
 
42
  DATABASE_PATH = Path("/tmp/music.db")
43
 
 
44
  HOST = "0.0.0.0"
45
  PORT = int(os.environ.get("PORT", 7860))
46
- DEBUG = True
47
-
48
- def __init__(self):
49
- self.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
50
- self.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
51
 
52
  config = Config()
 
 
53
 
54
  # ==================== 辅助函数 ====================
55
-
56
- def get_dataset_files():
57
- """
58
- 尝试获取 Dataset 的文件列表。
59
- 由于非Docker模式无法直接挂载,我们尝试从 Hub 获取文件列表。
60
- 如果失败(例如没有权限),返回空列表。
61
- """
62
  try:
63
  api = HfApi()
64
- # 获取 repo 所有文件树
65
- repo_files = api.list_repo_tree(repo_id=config.DATASET_REPO_ID, token=os.environ.get("HF_TOKEN"), repo_type="dataset")
66
-
67
- # 转换成相对路径列表
68
- files = []
69
- for file_info in repo_files:
70
- if file_info.type == "file":
71
- # 转换为 Path 对象的相对路径
72
- files.append(file_info.path)
73
- return files
74
  except Exception as e:
75
- print(f"⚠️ 无法连接到 Dataset Hub: {e}")
76
  return []
77
 
78
- def extract_audio_metadata(file_path: Path) -> Dict:
79
- """使用 mutagen 提取音频文件的元数据"""
80
  try:
81
  audio = MutagenFile(file_path)
82
- if audio is None: return None
83
- title = file_path.stem
84
- artist = "Unknown Artist"
85
- album = ""
86
- genre = ""
87
  duration = 0
88
  if hasattr(audio, 'info') and audio.info.length:
89
  duration = int(audio.info.length)
90
- common_keys = {
91
- 'title': ['TIT2', '\xa9nam', 'TITLE', 'Title', 'title'],
92
- 'artist': ['TPE1', '\xa9ART', 'ARTIST', 'Artist', 'artist'],
93
- 'album': ['TALB', '\xa9alb', 'ALBUM', 'Album', 'album'],
94
- 'genre': ['TCON', '\xa9gen', 'GENRE', 'Genre', 'genre']
 
95
  }
96
- def get_tag(target_type):
97
- for k in common_keys.get(target_type, []):
98
- if k in audio:
99
- val = audio[k]
100
- if isinstance(val, list) and len(val) > 0:
101
- return str(val[0])
102
- return str(val)
103
- return None
104
- if get_tag('title'): title = get_tag('title')
105
- if get_tag('artist'): artist = get_tag('artist')
106
- if get_tag('album'): album = get_tag('album')
107
- if get_tag('genre'): genre = get_tag('genre')
108
- return {"title": title, "artist": artist, "album": album, "genre": genre, "duration": duration, "file_size": file_path.stat().st_size}
109
- except Exception as e:
110
- return None
111
-
112
- # ==================== 数据库部分 ====================
113
 
 
114
  class Database:
115
  @staticmethod
116
- def get_connection():
117
- conn = sqlite3.connect(str(config.DATABASE_PATH))
118
- conn.row_factory = sqlite3.Row
119
- return conn
120
-
121
- @staticmethod
122
- def init_database():
123
- conn = Database.get_connection()
124
- cursor = conn.cursor()
125
- cursor.execute('''
126
- CREATE TABLE IF NOT EXISTS music (
127
  id INTEGER PRIMARY KEY AUTOINCREMENT,
128
- title TEXT NOT NULL,
129
- artist TEXT NOT NULL,
130
- album TEXT,
131
- genre TEXT,
132
- duration INTEGER,
133
- file_name TEXT NOT NULL UNIQUE,
134
- file_size INTEGER,
135
- source_type TEXT DEFAULT 'local', -- 'local' 或 'dataset'
136
- file_path TEXT,
137
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
138
- )
139
- ''')
140
  conn.commit()
141
  conn.close()
142
-
143
- @staticmethod
144
- def add_song(title, artist, album, genre, duration, file_name, file_size, source_type, file_path):
145
- conn = Database.get_connection()
146
- cursor = conn.cursor()
147
- try:
148
- cursor.execute('''
149
- INSERT OR IGNORE INTO music
150
- (title, artist, album, genre, duration, file_name, file_size, source_type, file_path)
151
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
152
- ''', (title, artist, album, genre, duration, file_name, file_size, source_type, file_path))
153
- song_id = cursor.lastrowid
154
- conn.commit()
155
- return song_id
156
- except sqlite3.IntegrityError:
157
- return 0
158
- finally:
159
- conn.close()
160
 
161
  @staticmethod
162
- def scan_dataset_files():
163
- """
164
- Dataset Hub 扫描文件并注册到数据库
165
- 注意:这里只是注册元数据,文件会在第一次播放时才下载
166
- """
167
- added_count = 0
168
- skipped_count = 0
169
 
170
- # 1. 获取文件列表
171
- remote_files = get_dataset_files()
172
- if not remote_files:
173
- return {"status": "error", "message": "无法获取 Dataset 文件列表,请检查名称或 Token"}
174
 
175
- # 2. 扫描本地上传目录
176
- local_files = []
177
- if config.UPLOAD_DIR.exists():
178
- for ext in ['.mp3', '.flac', '.wav', '.ogg', '.m4a']:
179
- local_files.extend(config.UPLOAD_DIR.glob(f"*{ext}"))
180
-
181
- conn = Database.get_connection()
182
- cursor = conn.cursor()
183
-
184
- # 处理本地文件
185
- for f_path in local_files:
186
- meta = extract_audio_metadata(f_path) or {"title": f_path.stem, "artist": "Unknown", "album": "", "genre": "", "duration": 0, "file_size": f_path.stat().st_size}
187
- song_id = Database.add_song(meta['title'], meta['artist'], meta['album'], meta['genre'], meta['duration'], f_path.name, meta['file_size'], 'local', str(f_path))
188
- if song_id > 0:
189
- added_count += 1
190
- print(f"✅ 本地: {meta['title']}")
191
- else:
192
- skipped_count += 1
193
-
194
  # 处理远程文件
195
- # 假设 Dataset 结构是 static/music/song.mp3
196
- target_prefix = "static/music/"
197
- for file_path in remote_files:
198
- if not file_path.startswith(target_prefix):
199
- continue
200
-
201
- # 提取文件名
202
- filename = file_path.split("/")[-1]
203
 
204
- # 简单估算大小 (这里无法获取远程文件真实大小,先填0)
205
- size = 0
206
- duration = 0 # 无法扫描远程文件元数据
207
-
208
- # 默认元数据
209
- title = Path(filename).stem
210
- artist = "Unknown Artist"
211
 
212
- song_id = Database.add_song(title, artist, "", "", duration, filename, size, 'dataset', file_path)
213
- if song_id > 0:
214
- added_count += 1
215
- print(f"✅ 远程: {filename}")
216
- else:
217
- skipped_count += 1
218
-
219
  conn.close()
220
- return {"status": "success", "added": added_count, "skipped": skipped_count, "message": f"扫描完成"}
221
 
222
  @staticmethod
223
- def get_all_songs(limit=100):
224
- conn = Database.get_connection()
225
- cursor = conn.cursor()
226
- cursor.execute("SELECT * FROM music ORDER BY id DESC LIMIT ?", (limit,))
227
- rows = cursor.fetchall()
 
228
  conn.close()
229
- return [dict(row) for row in rows]
230
-
231
  @staticmethod
232
- def get_song_by_id(song_id: int):
233
- conn = Database.get_connection()
234
- cursor = conn.cursor()
235
- cursor.execute("SELECT * FROM music WHERE id = ?", (song_id,))
236
- row = cursor.fetchone()
 
237
  conn.close()
238
- return dict(row) if row else None
239
 
240
- # ==================== FastAPI应用部分 ====================
 
 
 
 
 
241
 
242
- API_DOCS_HTML = API_DOCS_HTML = """
243
- <!DOCTYPE html>
244
- <html lang="zh-CN">
245
- <head>
246
- <meta charset="UTF-8">
247
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
248
- <title>音乐API - 管理面板</title>
249
  <style>
250
- :root { --bg: #1a1a2e; --card: #16213e; --text: #e0e0e0; --accent: #e94560; --input: #0f3460; }
251
- body { font-family: sans-serif; background: var(--bg); color: var(--text); padding: 20px; max-width: 800px; margin: auto; }
252
- h1 { text-align: center; }
253
- .section { background: var(--card); padding: 20px; border-radius: 8px; margin-bottom: 20px; }
254
- .section h2 { border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; }
255
- label { display: block; margin: 10px 0 5px; font-weight: bold; }
256
- input[type="text"], input[type="file"] { width: 100%; padding: 10px; background: #333; border: 1px solid #444; color: white; border-radius: 4px; box-sizing: border-box; margin-bottom: 15px;}
257
- button { background: var(--accent); color: white; border: none; padding: 12px 24px; border-radius: 4px; cursor: pointer; width: 100%; font-size: 16px; }
258
- button:hover { opacity: 0.9; }
259
- .btn-secondary { background: #444; margin-top: 10px; }
260
- .result { margin-top: 15px; padding: 10px; background: #0f0f1a; border-radius: 4px; display: none; }
261
- pre { white-space: pre-wrap; word-break: break-all; }
262
  </style>
263
- </head>
264
- <body>
265
- <h1>🎵 音乐 API 管理面板</h1>
266
-
267
- <!-- 扫描区域 -->
268
- <div class="section">
269
- <h2>📂 扫描库</h2>
270
- <p>点击下方按钮,扫描 /data 和 /dataset 目录下的所有音乐文件并更新数据库。</p>
271
- <button onclick="scanLibrary()">扫描并更新数据库</button>
272
- <div id="scanResult" class="result"><pre></pre></div>
273
- </div>
274
-
275
- <!-- 上传区域 -->
276
- <div class="section">
277
- <h2>⬆️ 上传新歌</h2>
278
- <p>上传 MP3 文件和封面。文件将保存到可写存储 (/data/static)。</p>
279
- <form id="uploadForm" onsubmit="uploadSong(event)">
280
- <label>音乐文件 (MP3)</label>
281
- <input type="file" id="file" accept="audio/*" required>
282
-
283
- <label>封面图片 (可选, JPG/PNG)</label>
284
- <input type="file" id="cover" accept="image/*">
285
-
286
- <label>标题 (可选,留空自动读取)</label>
287
- <input type="text" id="title" placeholder="自动读取...">
288
-
289
- <label>艺术家 (可选,留空自动读取)</label>
290
- <input type="text" id="artist" placeholder="自动读取...">
291
-
292
- <button type="submit">开始上传</button>
293
- </form>
294
- <div id="uploadResult" class="result"><pre></pre></div>
295
- </div>
296
-
297
- <!-- 歌曲列表 -->
298
- <div class="section">
299
- <h2>📋 最近添加</h2>
300
- <div id="songsList">加载中...</div>
301
- </div>
302
 
303
- <script>
304
- const API_BASE = window.location.origin;
 
 
305
 
306
- function showResult(id, msg) {
307
- const el = document.getElementById(id);
308
- el.style.display = 'block';
309
- el.innerText = msg;
310
- }
311
 
312
- async function scanLibrary() {
313
- showResult('scanResult', '正在扫描...');
314
- try {
315
- const res = await fetch(`${API_BASE}/api/scan`);
316
- const data = await res.json();
317
- showResult('scanResult', JSON.stringify(data, null, 2));
318
- loadSongs(); // 刷新列表
319
- } catch(e) {
320
- showResult('scanResult', 'Error: ' + e);
321
  }
322
- }
323
-
324
- async function uploadSong(e) {
325
- e.preventDefault();
326
- showResult('uploadResult', '正在上传...');
327
-
328
- const formData = new FormData();
329
- formData.append('file', document.getElementById('file').files[0]);
330
- if(document.getElementById('cover').files[0]) {
331
- formData.append('cover', document.getElementById('cover').files[0]);
332
  }
333
- formData.append('title', document.getElementById('title').value);
334
- formData.append('artist', document.getElementById('artist').value);
335
-
336
- try {
337
- const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData });
338
- const data = await res.json();
339
- if(res.ok) {
340
- showResult('uploadResult', `上传成功! ID: ${data.id}\n` + JSON.stringify(data, null, 2));
341
- document.getElementById('uploadForm').reset();
342
- loadSongs();
343
- } else {
344
- showResult('uploadResult', `上传失败: ${data.detail}`);
345
- }
346
- } catch(e) {
347
- showResult('uploadResult', 'Error: ' + e);
348
  }
349
- }
350
-
351
- async function loadSongs() {
352
- try {
353
- const res = await fetch(`${API_BASE}/api/music?limit=10`);
354
- const songs = await res.json();
355
- const html = songs.map(s =>
356
- `<div style="padding:10px; border-bottom:1px solid #333;">
357
- <b>${s.title}</b> -${s.artist}<br>
358
- <small>File: ${s.file_name} | Size:${(s.file_size/1024/1024).toFixed(2)}MB</small>
359
- </div>`
360
- ).join('');
361
- document.getElementById('songsList').innerHTML = html || '暂无歌曲,请扫描或上传';
362
- } catch(e) {
363
- document.getElementById('songsList').innerText = '加载失败';
364
  }
365
- }
366
-
367
- loadSongs();
368
- </script>
369
- </body>
370
- </html>
371
- """
372
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
373
-
374
- # 页面 (保持之前的 HTML 结构,这里为了简洁略去,请使用上一版完整的 HTML)
375
- # ... (请确保复制上一版完整的 API_DOCS_HTML 字符串到这里,或者在代码末尾单独提供) ...
376
- # 为了代码完整性,这里附上一个简单的 HTML 占位
377
-
378
 
379
- @app.on_event("startup")
380
- def startup_event():
381
- Database.init_database()
382
 
383
- @app.get("/")
384
- async def get_frontend():
385
- return HTMLResponse(API_DOCS_HTML)
386
 
387
- # --- 播放接口 (核心逻辑) ---
388
  @app.get("/api/play/{song_id}")
389
- async def play_music(song_id: int):
390
- song = Database.get_song_by_id(song_id)
391
  if not song: raise HTTPException(404)
392
 
393
- # 逻辑分支
394
- if song['source_type'] == 'local':
395
- # 本地文件直接返回
396
- path = Path(song['file_path'])
397
- if not path.exists(): raise HTTPException(404)
398
- return FileResponse(path)
399
-
400
- elif song['source_type'] == 'dataset':
401
- # 远程文件:下载到本地缓存,然后返回
402
  try:
403
- # 使用 hf_hub_download 下载单文件
404
  local_path = hf_hub_download(
405
  repo_id=config.DATASET_REPO_ID,
406
- filename=song['file_path'],
407
  repo_type="dataset",
408
- cache_dir=str(config.DATASET_CACHE_DIR),
409
  resume_download=True
410
  )
411
  return FileResponse(local_path)
412
  except Exception as e:
413
- raise HTTPException(500, detail=f"Dataset 下载失败: {e}")
414
-
415
- # --- 其他 API ---
416
- @app.get("/api/scan")
417
- async def scan():
418
- return JSONResponse(Database.scan_dataset_files())
419
-
420
- @app.get("/api/music")
421
- async def get_music(limit: int = 100):
422
- return Database.get_all_songs(limit)
423
 
424
  @app.post("/api/upload")
425
  async def upload(file: UploadFile = File(...)):
426
  dest = config.UPLOAD_DIR / file.filename
427
- with open(dest, "wb") as f:
428
- shutil.copyfileobj(file.file, f)
429
-
430
- # 简单插入数据库
431
- Database.scan_dataset_files() # 重新扫描以更新列表
432
- return JSONResponse({"status": "success", "filename": file.filename})
 
 
1
  #!/usr/bin/env python3
 
 
 
 
2
  import os
3
  import sqlite3
4
  import shutil
5
  from pathlib import Path
6
+ from typing import Optional, List
7
+ from fastapi import FastAPI, UploadFile, File, Query, HTTPException
 
 
8
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
 
9
  from fastapi.middleware.cors import CORSMiddleware
10
  import uvicorn
11
  from mutagen import File as MutagenFile
12
+ from huggingface_hub import HfApi, hf_hub_download
 
 
13
 
14
+ # ==================== 配置 ====================
15
  class Config:
16
+ APP_NAME = "Music API"
17
+ VERSION = "5.1.0"
 
 
 
 
 
 
 
 
 
18
 
19
+ # Dataset ID (可在 Settings -> Variables 设置 HF_DATASET_REPO_ID)
20
+ DATASET_REPO_ID = os.environ.get("HF_DATASET_REPO_ID", "my-music-library")
 
 
21
 
22
+ # 本地缓存和上传目录
23
+ CACHE_DIR = Path("/tmp/hf_cache")
24
+ UPLOAD_DIR = Path("/tmp/uploads")
25
  DATABASE_PATH = Path("/tmp/music.db")
26
 
27
+ # 服务器配置
28
  HOST = "0.0.0.0"
29
  PORT = int(os.environ.get("PORT", 7860))
 
 
 
 
 
30
 
31
  config = Config()
32
+ config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
33
+ config.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
34
 
35
  # ==================== 辅助函数 ====================
36
+ def get_remote_files():
 
 
 
 
 
 
37
  try:
38
  api = HfApi()
39
+ tree = api.list_repo_tree(repo_id=config.DATASET_REPO_ID, repo_type="dataset")
40
+ return [item.path for item in tree if item.type == "file"]
 
 
 
 
 
 
 
 
41
  except Exception as e:
42
+ print(f"Error fetching dataset: {e}")
43
  return []
44
 
45
+ def get_metadata(file_path: Path):
 
46
  try:
47
  audio = MutagenFile(file_path)
48
+ if not audio: return None
49
+ title = artist = album = genre = ""
 
 
 
50
  duration = 0
51
  if hasattr(audio, 'info') and audio.info.length:
52
  duration = int(audio.info.length)
53
+
54
+ tags = {
55
+ 'title': ['TIT2', '\xa9nam', 'TITLE', 'Title'],
56
+ 'artist': ['TPE1', '\xa9ART', 'ARTIST', 'Artist'],
57
+ 'album': ['TALB', '\xa9alb', 'ALBUM', 'Album'],
58
+ 'genre': ['TCON', '\xa9gen', 'GENRE', 'Genre']
59
  }
60
+
61
+ for k, v in tags.items():
62
+ val = None
63
+ if audio:
64
+ for key in v:
65
+ if key in audio:
66
+ val = str(audio[key][0] if isinstance(audio[key], list) else audio[key])
67
+ break
68
+ if val: locals()[k] = val
69
+
70
+ if not title: title = file_path.stem
71
+ if not artist: artist = "Unknown"
72
+
73
+ return {"title": title, "artist": artist, "album": album, "genre": genre, "duration": duration, "size": file_path.stat().st_size}
74
+ except: return None
 
 
75
 
76
+ # ==================== 数据库 ====================
77
  class Database:
78
  @staticmethod
79
+ def init():
80
+ conn = sqlite3.connect(config.DATABASE_PATH)
81
+ c = conn.cursor()
82
+ c.execute('''CREATE TABLE IF NOT EXISTS music (
 
 
 
 
 
 
 
83
  id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ title TEXT, artist TEXT, album TEXT, genre TEXT,
85
+ duration INTEGER, file_name TEXT, source TEXT, path TEXT,
86
+ UNIQUE(file_name)
87
+ )''')
 
 
 
 
 
 
 
 
88
  conn.commit()
89
  conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  @staticmethod
92
+ def scan():
93
+ Database.init()
94
+ remote_files = get_remote_files()
95
+ local_files = list(config.UPLOAD_DIR.glob("*.mp3")) + list(config.UPLOAD_DIR.glob("*.wav"))
 
 
 
96
 
97
+ conn = sqlite3.connect(config.DATABASE_PATH)
98
+ c = conn.cursor()
99
+ count = 0
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  # 处理远程文件
102
+ for path in remote_files:
103
+ if not path.lower().endswith(('.mp3', '.flac', '.wav', '.ogg')): continue
104
+ filename = path.split('/')[-1]
105
+ try:
106
+ c.execute("INSERT OR IGNORE INTO music (title, artist, file_name, source, path) VALUES (?,?,?,?,?)",
107
+ (Path(filename).stem, "Dataset Artist", filename, "dataset", path))
108
+ if c.rowcount > 0: count += 1
109
+ except: pass
110
 
111
+ # 处理本地文件
112
+ for path in local_files:
113
+ meta = get_metadata(path) or {}
114
+ c.execute("INSERT OR IGNORE INTO music (title, artist, file_name, source, path, duration) VALUES (?,?,?,?,?,?)",
115
+ (meta.get('title'), meta.get('artist'), path.name, "local", str(path), meta.get('duration', 0)))
116
+ if c.rowcount > 0: count += 1
 
117
 
118
+ conn.commit()
 
 
 
 
 
 
119
  conn.close()
120
+ return {"added": count}
121
 
122
  @staticmethod
123
+ def get_all(limit=100):
124
+ conn = sqlite3.connect(config.DATABASE_PATH)
125
+ conn.row_factory = sqlite3.Row
126
+ c = conn.cursor()
127
+ c.execute("SELECT * FROM music ORDER BY id DESC LIMIT ?", (limit,))
128
+ songs = [dict(row) for row in c.fetchall()]
129
  conn.close()
130
+ return songs
131
+
132
  @staticmethod
133
+ def get_by_id(song_id):
134
+ conn = sqlite3.connect(config.DATABASE_PATH)
135
+ conn.row_factory = sqlite3.Row
136
+ c = conn.cursor()
137
+ c.execute("SELECT * FROM music WHERE id = ?", (song_id,))
138
+ song = c.fetchone()
139
  conn.close()
140
+ return dict(song) if song else None
141
 
142
+ # ==================== FastAPI ====================
143
+ app = FastAPI()
144
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
145
+
146
+ @app.on_event("startup")
147
+ def startup(): Database.scan()
148
 
149
+ @app.get("/", response_class=HTMLResponse)
150
+ async def index():
151
+ return """
152
+ <!DOCTYPE html>
153
+ <html>
154
+ <head><title>Music Player</title>
 
155
  <style>
156
+ body { font-family: sans-serif; max-width: 800px; margin: 20px auto; background: #f4f4f9; padding: 20px; }
157
+ .card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px; }
158
+ h2 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
159
+ button { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
160
+ button:hover { background: #0056b3; }
161
+ .song-list { list-style: none; padding: 0; }
162
+ .song-item { padding: 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
163
+ .song-item:hover { background: #f9f9f9; cursor: pointer; }
164
+ .playing { color: #007bff; font-weight: bold; }
165
+ input[type="file"] { margin-bottom: 10px; }
166
+ #player { position: fixed; bottom: 0; left: 0; right: 0; background: #333; color: white; padding: 10px; display: flex; align-items: center; gap: 10px; }
 
167
  </style>
168
+ </head>
169
+ <body>
170
+ <div class="card">
171
+ <h2>📚 Library Management</h2>
172
+ <button onclick="scan()">Scan Library</button>
173
+ <div id="scan-status"></div>
174
+ </div>
175
+
176
+ <div class="card">
177
+ <h2>⬆️ Upload</h2>
178
+ <input type="file" id="fileInput" accept="audio/*">
179
+ <button onclick="upload()">Upload</button>
180
+ <div id="upload-status"></div>
181
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
+ <div class="card">
184
+ <h2>🎵 Songs (<span id="count">0</span>)</h2>
185
+ <ul class="song-list" id="songList"></ul>
186
+ </div>
187
 
188
+ <audio id="audio" controls style="width:100%; display:none;"></audio>
189
+ <div id="player"></div>
 
 
 
190
 
191
+ <script>
192
+ const API = '';
193
+ async function scan() {
194
+ document.getElementById('scan-status').innerText = 'Scanning...';
195
+ await fetch(API + '/api/scan');
196
+ loadSongs();
197
+ document.getElementById('scan-status').innerText = 'Done!';
 
 
198
  }
199
+ async function loadSongs() {
200
+ const res = await fetch(API + '/api/music?limit=100');
201
+ const songs = await res.json();
202
+ document.getElementById('count').innerText = songs.length;
203
+ const list = document.getElementById('songList');
204
+ list.innerHTML = songs.map(s => `
205
+ <li class="song-item" onclick="play(${s.id})">
206
+ <span>${s.title} - ${s.artist}</span>
207
+ <small>${s.source === 'dataset' ? '☁️' : '💾'}</small>
208
+ </li>`).join('');
209
  }
210
+ async function play(id) {
211
+ const audio = document.getElementById('audio');
212
+ audio.src = API + '/api/play/' + id;
213
+ audio.style.display = 'block';
214
+ audio.play();
 
 
 
 
 
 
 
 
 
 
215
  }
216
+ async function upload() {
217
+ const fileInput = document.getElementById('fileInput');
218
+ if(!fileInput.files[0]) return alert('Select file');
219
+ const formData = new FormData();
220
+ formData.append('file', fileInput.files[0]);
221
+ const res = await fetch(API + '/api/upload', { method: 'POST', body: formData });
222
+ if(res.ok) { alert('Uploaded!'); scan(); }
223
+ else alert('Failed');
 
 
 
 
 
 
 
224
  }
225
+ loadSongs();
226
+ </script>
227
+ </body>
228
+ </html>
229
+ """
 
 
 
 
 
 
 
 
230
 
231
+ @app.get("/api/scan")
232
+ async def scan():
233
+ return Database.scan()
234
 
235
+ @app.get("/api/music")
236
+ async def get_music(limit: int = 100):
237
+ return Database.get_all(limit)
238
 
 
239
  @app.get("/api/play/{song_id}")
240
+ async def play(song_id: int):
241
+ song = Database.get_by_id(song_id)
242
  if not song: raise HTTPException(404)
243
 
244
+ if song['source'] == 'dataset':
245
+ # 下载远程文件到缓存
 
 
 
 
 
 
 
246
  try:
 
247
  local_path = hf_hub_download(
248
  repo_id=config.DATASET_REPO_ID,
249
+ filename=song['path'],
250
  repo_type="dataset",
251
+ cache_dir=str(config.CACHE_DIR),
252
  resume_download=True
253
  )
254
  return FileResponse(local_path)
255
  except Exception as e:
256
+ raise HTTPException(500, detail=str(e))
257
+ else:
258
+ # 本地文件
259
+ return FileResponse(song['path'])
 
 
 
 
 
 
260
 
261
  @app.post("/api/upload")
262
  async def upload(file: UploadFile = File(...)):
263
  dest = config.UPLOAD_DIR / file.filename
264
+ with open(dest, "wb") as f: shutil.copyfileobj(file.file, f)
265
+ # 简单触发扫描
266
+ Database.scan()
267
+ return {"status": "ok"}
268
+
269
+ if __name__ == "__main__":
270
+ uvicorn.run(app, host=config.HOST, port=config.PORT)