milk-git55 commited on
Commit
649b7ba
·
verified ·
1 Parent(s): 0cb4130

Update music_api.py

Browse files
Files changed (1) hide show
  1. music_api.py +496 -592
music_api.py CHANGED
@@ -1,592 +1,496 @@
1
- #!/usr/bin/env python3
2
- """
3
- 修复版音乐API - 适配 Hugging Face Spaces (含自动扫描功能)
4
- """
5
-
6
- import os
7
- import sqlite3
8
- from pathlib import Path
9
- from typing import Optional, List, Dict
10
- import urllib.parse
11
-
12
- from fastapi import FastAPI, Query, HTTPException
13
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
14
- from fastapi.staticfiles import StaticFiles
15
- from fastapi.middleware.cors import CORSMiddleware
16
- import uvicorn
17
-
18
- # ==================== 第三方库导入 ====================
19
- # 用于读取音频元数据
20
- from mutagen import File as MutagenFile
21
-
22
- # ==================== 配置部分 ====================
23
-
24
- class Config:
25
- """配置类"""
26
- APP_NAME = "音乐API"
27
- VERSION = "4.1.0" # 版本升级
28
-
29
- # 路径配置
30
- BASE_DIR = Path(__file__).resolve().parent
31
- MUSIC_DIR = BASE_DIR / "static" / "music"
32
- COVERS_DIR = BASE_DIR / "static" / "covers"
33
- DATABASE_PATH = BASE_DIR / "music.db"
34
-
35
- # 服务器配置
36
- HOST = "0.0.0.0"
37
- PORT = int(os.environ.get("PORT", 7860))
38
- DEBUG = True
39
-
40
- def __init__(self):
41
- """确保目录存在"""
42
- self.MUSIC_DIR.mkdir(parents=True, exist_ok=True)
43
- self.COVERS_DIR.mkdir(parents=True, exist_ok=True)
44
-
45
- config = Config()
46
-
47
- # ==================== 辅助函数 ====================
48
-
49
- def extract_audio_metadata(file_path: Path) -> Dict:
50
- """
51
- 使用 mutagen 提取音频文件的元数据
52
- """
53
- try:
54
- # 尝试加载文件
55
- audio = MutagenFile(file_path)
56
- if audio is None:
57
- return None
58
-
59
- # 初始化默认值
60
- title = file_path.stem # 默认使用文件名
61
- artist = "Unknown Artist"
62
- album = ""
63
- genre = ""
64
- duration = 0
65
-
66
- # 尝试获取时长
67
- if hasattr(audio, 'info') and audio.info.length:
68
- duration = int(audio.info.length)
69
-
70
- # 尝试获取标签 (兼容 MP3 ID3, MP4, OGG, FLAC 等)
71
- # 常见的键名映射
72
- common_keys = {
73
- 'title': ['TIT2', '\xa9nam', 'TITLE', 'Title', 'title'],
74
- 'artist': ['TPE1', '\xa9ART', 'ARTIST', 'Artist', 'artist'],
75
- 'album': ['TALB', '\xa9alb', 'ALBUM', 'Album', 'album'],
76
- 'genre': ['TCON', '\xa9gen', 'GENRE', 'Genre', 'genre']
77
- }
78
-
79
- # 辅助函数:获取第一个匹配的标签值
80
- def get_tag(target_type):
81
- keys = common_keys.get(target_type, [])
82
- for k in keys:
83
- if k in audio:
84
- val = audio[k]
85
- if isinstance(val, list) and len(val) > 0:
86
- return str(val[0])
87
- return str(val)
88
- return None
89
-
90
- # 覆盖默认值
91
- found_title = get_tag('title')
92
- if found_title:
93
- title = found_title
94
-
95
- found_artist = get_tag('artist')
96
- if found_artist:
97
- artist = found_artist
98
-
99
- found_album = get_tag('album')
100
- if found_album:
101
- album = found_album
102
-
103
- found_genre = get_tag('genre')
104
- if found_genre:
105
- genre = found_genre
106
-
107
- return {
108
- "title": title,
109
- "artist": artist,
110
- "album": album,
111
- "genre": genre,
112
- "duration": duration,
113
- "file_size": file_path.stat().st_size
114
- }
115
-
116
- except Exception as e:
117
- print(f"⚠️ 无法读取 {file_path.name} 的元数据: {e}")
118
- return None
119
-
120
- # ==================== 数据库部分 ====================
121
-
122
- class Database:
123
- """数据库管理类"""
124
-
125
- @staticmethod
126
- def get_connection():
127
- """获取数据库连接"""
128
- conn = sqlite3.connect(str(config.DATABASE_PATH))
129
- conn.row_factory = sqlite3.Row
130
- return conn
131
-
132
- @staticmethod
133
- def init_database():
134
- """初始化数据库表结构"""
135
- conn = Database.get_connection()
136
- cursor = conn.cursor()
137
-
138
- cursor.execute('''
139
- CREATE TABLE IF NOT EXISTS music (
140
- id INTEGER PRIMARY KEY AUTOINCREMENT,
141
- title TEXT NOT NULL,
142
- artist TEXT NOT NULL,
143
- album TEXT,
144
- genre TEXT,
145
- duration INTEGER,
146
- file_name TEXT NOT NULL UNIQUE,
147
- file_size INTEGER,
148
- file_hash TEXT,
149
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
150
- )
151
- ''')
152
-
153
- cursor.execute('CREATE INDEX IF NOT EXISTS idx_title ON music(title)')
154
- cursor.execute('CREATE INDEX IF NOT EXISTS idx_artist ON music(artist)')
155
- cursor.execute('CREATE INDEX IF NOT EXISTS idx_filename ON music(file_name)')
156
-
157
- conn.commit()
158
- conn.close()
159
-
160
- @staticmethod
161
- def scan_music_files() -> Dict:
162
- """
163
- 扫描 static/music 目录并更新数据库
164
- """
165
- added_count = 0
166
- skipped_count = 0
167
- error_files = []
168
-
169
- # 支持的音频后缀
170
- audio_extensions = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.wma']
171
-
172
- # 获取所有文件
173
- if not config.MUSIC_DIR.exists():
174
- return {"status": "error", "message": "音乐目录不存在"}
175
-
176
- files = list(config.MUSIC_DIR.rglob('*'))
177
-
178
- conn = Database.get_connection()
179
- cursor = conn.cursor()
180
-
181
- for file_path in files:
182
- if not file_path.is_file():
183
- continue
184
- if file_path.suffix.lower() not in audio_extensions:
185
- continue
186
-
187
- file_name = file_path.name
188
-
189
- # 提取元数据
190
- meta = extract_audio_metadata(file_path)
191
- if not meta:
192
- # 如果读取失败,创建一个基本记录
193
- meta = {
194
- "title": file_path.stem,
195
- "artist": "Unknown",
196
- "album": "",
197
- "genre": "",
198
- "duration": 0,
199
- "file_size": file_path.stat().st_size
200
- }
201
- error_files.append(file_name)
202
-
203
- # 尝试插入数据库 (如果 file_name 已存在则忽略)
204
- try:
205
- cursor.execute('''
206
- INSERT OR IGNORE INTO music
207
- (title, artist, album, genre, duration, file_name, file_size)
208
- VALUES (?, ?, ?, ?, ?, ?, ?)
209
- ''', (
210
- meta['title'],
211
- meta['artist'],
212
- meta['album'],
213
- meta['genre'],
214
- meta['duration'],
215
- file_name,
216
- meta['file_size']
217
- ))
218
-
219
- if cursor.rowcount > 0:
220
- added_count += 1
221
- print(f"✅ 新增: {meta['title']} - {meta['artist']}")
222
- else:
223
- skipped_count += 1
224
- print(f"⏭️ 跳过: {file_name} (已存在)")
225
-
226
- except Exception as e:
227
- print(f"❌ 数据库错误 ({file_name}): {e}")
228
- error_files.append(file_name)
229
-
230
- conn.commit()
231
- conn.close()
232
-
233
- return {
234
- "status": "success",
235
- "scanned": len(files),
236
- "added": added_count,
237
- "skipped": skipped_count,
238
- "errors": len(error_files),
239
- "message": f"扫描完成。新增 {added_count} 首歌曲。"
240
- }
241
-
242
- @staticmethod
243
- def get_song_cover_url(song_id: int) -> str:
244
- """获取歌曲封面URL"""
245
- cover_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
246
-
247
- for ext in cover_extensions:
248
- cover_path = config.COVERS_DIR / f"{song_id}{ext}"
249
- if cover_path.exists():
250
- return f"/static/covers/{song_id}{ext}"
251
-
252
- default_path = config.COVERS_DIR / f"{song_id}_default.jpg"
253
- if default_path.exists():
254
- return f"/static/covers/{song_id}_default.jpg"
255
-
256
- return f"/api/music/{song_id}/default-cover"
257
-
258
- @staticmethod
259
- def get_all_songs(limit: int = 1000) -> List[Dict]:
260
- """获取所有歌曲"""
261
- conn = Database.get_connection()
262
- cursor = conn.cursor()
263
- cursor.execute(f"SELECT * FROM music ORDER BY title LIMIT ?", (limit,))
264
- rows = cursor.fetchall()
265
- conn.close()
266
-
267
- songs = []
268
- for row in rows:
269
- song_id = row["id"]
270
- songs.append({
271
- "id": song_id,
272
- "title": row["title"],
273
- "artist": row["artist"],
274
- "album": row["album"] or "",
275
- "genre": row["genre"] or "",
276
- "duration": row["duration"] or 0,
277
- "file_name": row["file_name"],
278
- "file_size": row["file_size"] or 0,
279
- "cover_url": Database.get_song_cover_url(song_id),
280
- "play_url": f"/api/music/{song_id}/play",
281
- "download_url": f"/api/music/{song_id}/download"
282
- })
283
- return songs
284
-
285
- @staticmethod
286
- def search_songs(query: str = None, artist: str = None, limit: int = 100) -> List[Dict]:
287
- """搜索歌曲"""
288
- conn = Database.get_connection()
289
- cursor = conn.cursor()
290
-
291
- sql = "SELECT * FROM music WHERE 1=1"
292
- params = []
293
-
294
- if query:
295
- sql += " AND (title LIKE ? OR artist LIKE ? OR album LIKE ?)"
296
- search_term = f"%{query}%"
297
- params.extend([search_term, search_term, search_term])
298
-
299
- if artist:
300
- sql += " AND artist LIKE ?"
301
- params.append(f"%{artist}%")
302
-
303
- sql += " ORDER BY title LIMIT ?"
304
- params.append(limit)
305
-
306
- cursor.execute(sql, params)
307
- rows = cursor.fetchall()
308
- conn.close()
309
-
310
- songs = []
311
- for row in rows:
312
- song_id = row["id"]
313
- songs.append({
314
- "id": song_id,
315
- "title": row["title"],
316
- "artist": row["artist"],
317
- "album": row["album"] or "",
318
- "genre": row["genre"] or "",
319
- "duration": row["duration"] or 0,
320
- "file_name": row["file_name"],
321
- "file_size": row["file_size"] or 0,
322
- "cover_url": Database.get_song_cover_url(song_id),
323
- "play_url": f"/api/music/{song_id}/play",
324
- "download_url": f"/api/music/{song_id}/download"
325
- })
326
- return songs
327
-
328
- @staticmethod
329
- def get_song_by_id(song_id: int) -> Optional[Dict]:
330
- """根据ID获取歌曲"""
331
- conn = Database.get_connection()
332
- cursor = conn.cursor()
333
- cursor.execute("SELECT * FROM music WHERE id = ?", (song_id,))
334
- row = cursor.fetchone()
335
- conn.close()
336
-
337
- if not row:
338
- return None
339
-
340
- return {
341
- "id": song_id,
342
- "title": row["title"],
343
- "artist": row["artist"],
344
- "album": row["album"] or "",
345
- "genre": row["genre"] or "",
346
- "duration": row["duration"] or 0,
347
- "file_name": row["file_name"],
348
- "file_size": row["file_size"] or 0,
349
- "cover_url": Database.get_song_cover_url(song_id),
350
- "play_url": f"/api/music/{song_id}/play",
351
- "download_url": f"/api/music/{song_id}/download"
352
- }
353
-
354
- # ==================== FastAPI应用部分 ====================
355
-
356
- app = FastAPI(
357
- title=config.APP_NAME,
358
- description="音乐API系统 - 封面URL直接绑定",
359
- version=config.VERSION,
360
- docs_url="/docs"
361
- )
362
-
363
- app.add_middleware(
364
- CORSMiddleware,
365
- allow_origins=["*"],
366
- allow_credentials=True,
367
- allow_methods=["*"],
368
- allow_headers=["*"],
369
- )
370
-
371
- app.mount("/static", StaticFiles(directory="static"), name="static")
372
-
373
- # ==================== API 文档页面 HTML (保持不变,为节省空间此处省略完整HTML,请使用上一次的HTML内容) ====================
374
- # 为了方便您直接复制,这里仍然包含完整的 HTML
375
- API_DOCS_HTML = """
376
- <!DOCTYPE html>
377
- <html lang="zh-CN">
378
- <head>
379
- <meta charset="UTF-8">
380
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
381
- <title>音乐API文档 - v4.1.0</title>
382
- <style>
383
- :root {
384
- --bg-color: #1a1a2e; --card-bg: #16213e; --text-primary: #e0e0e0; --text-secondary: #a0a0a0;
385
- --accent-color: #0f3460; --highlight: #e94560; --method-get: #61affe; --method-post: #49cc90;
386
- --code-bg: #0f0f1a;
387
- }
388
- * { margin: 0; padding: 0; box-sizing: border-box; }
389
- body { font-family: 'Segoe UI', sans-serif; background: var(--bg-color); color: var(--text-primary); line-height: 1.6; padding: 20px; }
390
- .container { max-width: 1000px; margin: 0 auto; }
391
- header { text-align: center; margin-bottom: 40px; padding-bottom: 20px; border-bottom: 1px solid var(--accent-color); }
392
- h1 { font-size: 2.5rem; margin-bottom: 10px; color: #fff; }
393
- .version { background: var(--highlight); padding: 2px 8px; border-radius: 4px; font-size: 0.9rem; }
394
- .endpoint-card { background: var(--card-bg); border-radius: 8px; padding: 20px; margin-bottom: 20px; border: 1px solid var(--accent-color); }
395
- .endpoint-header { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; }
396
- .method { color: #fff; padding: 4px 10px; border-radius: 4px; font-weight: bold; font-size: 0.9rem; min-width: 60px; text-align: center; }
397
- .method.get { background: var(--method-get); }
398
- .method.post { background: var(--method-post); }
399
- .path { font-family: 'Courier New', monospace; color: #fff; background: rgba(0,0,0,0.3); padding: 4px 10px; border-radius: 4px; flex: 1; word-break: break-all; }
400
- table { width: 100%; border-collapse: collapse; margin-top: 10px; }
401
- th, td { text-align: left; padding: 10px; border-bottom: 1px solid var(--accent-color); font-size: 0.9rem; }
402
- .code-block { background: var(--code-bg); padding: 15px; border-radius: 6px; overflow-x: auto; margin-top: 10px; font-family: monospace; font-size: 0.9rem; color: #a5d6ff; }
403
- .desc { color: var(--text-secondary); margin-bottom: 15px; }
404
- </style>
405
- </head>
406
- <body>
407
- <div class="container">
408
- <header>
409
- <h1>音乐API <span class="version">v4.1.0</span></h1>
410
- <p style="color:#aaa">基于 FastAPI 的轻量级音乐流媒体服务 | <a href="/docs" style="color:#61affe">Swagger文档</a></p>
411
- </header>
412
-
413
- <div style="background: var(--card-bg); padding: 20px; border-radius: 8px; margin-bottom: 30px; border-left: 4px solid #49cc90;">
414
- <h2 style="margin-bottom: 10px;">🚀 快速开始</h2>
415
- <p style="color:#ccc">1. 上传音乐文件到 <code>static/music/</code> 目录</p>
416
- <p style="color:#ccc">2. 访问此接口扫描文件: <a href="/api/scan" target="_blank" style="color:#fff; background:#49cc90; padding:2px 8px; text-decoration:none; border-radius:4px;">GET /api/scan</a></p>
417
- <p style="color:#ccc">3. 访问首页查看列表或使用 API 播放</p>
418
- </div>
419
-
420
- <h3 style="margin-bottom:20px;">接口列表</h3>
421
-
422
- <div class="endpoint-card">
423
- <div class="endpoint-header">
424
- <span class="method get">GET</span>
425
- <span class="path">/api/scan</span>
426
- </div>
427
- <p class="desc">扫描 static/music 目录并更新数据库(新增歌曲)</p>
428
- </div>
429
-
430
- <div class="endpoint-card">
431
- <div class="endpoint-header">
432
- <span class="method get">GET</span>
433
- <span class="path">/api/music</span>
434
- </div>
435
- <p class="desc">获取歌曲列表或搜索</p>
436
- </div>
437
-
438
- <div class="endpoint-card">
439
- <div class="endpoint-header">
440
- <span class="method get">GET</span>
441
- <span class="path">/api/music/{id}/play</span>
442
- </div>
443
- <p class="desc">播放指定ID的音乐</p>
444
- </div>
445
-
446
- <div style="text-align: center; margin-top: 50px; color: #666; font-size: 0.8rem;">
447
- Powered by FastAPI & HuggingFace Spaces
448
- </div>
449
- </div>
450
- </body>
451
- </html>
452
- """
453
-
454
- # ==================== 启动事件 ====================
455
-
456
- @app.on_event("startup")
457
- def startup_event():
458
- Database.init_database()
459
-
460
- # ==================== API路由部分 ====================
461
-
462
- @app.get("/")
463
- async def get_api_docs():
464
- return HTMLResponse(API_DOCS_HTML)
465
-
466
- @app.get("/api/scan")
467
- async def scan_library():
468
- """扫描音乐文件并导入数据库"""
469
- result = Database.scan_music_files()
470
- return JSONResponse(result)
471
-
472
- @app.get("/api/music")
473
- async def get_music(
474
- q: Optional[str] = Query(None, description="搜索关键词"),
475
- artist: Optional[str] = Query(None, description="艺术家"),
476
- limit: int = Query(100, ge=1, le=1000, description="返回数量")
477
- ):
478
- return Database.search_songs(q, artist, limit)
479
-
480
- @app.get("/api/music/{music_id}")
481
- async def get_music_by_id(music_id: int):
482
- song = Database.get_song_by_id(music_id)
483
- if not song:
484
- raise HTTPException(status_code=404, detail="歌曲不存在")
485
- return song
486
-
487
- @app.get("/api/music/{music_id}/play")
488
- async def play_music(music_id: int):
489
- song = Database.get_song_by_id(music_id)
490
- if not song:
491
- raise HTTPException(status_code=404, detail="歌曲不存在")
492
-
493
- file_path = config.MUSIC_DIR / song['file_name']
494
- if not file_path.exists():
495
- raise HTTPException(status_code=404, detail="音乐文件不存在")
496
-
497
- encoded_filename = urllib.parse.quote(song['file_name'])
498
-
499
- return FileResponse(
500
- file_path,
501
- filename=encoded_filename,
502
- media_type="audio/mpeg",
503
- headers={
504
- "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
505
- "Accept-Ranges": "bytes"
506
- }
507
- )
508
-
509
- @app.get("/api/music/{music_id}/download")
510
- async def download_music(music_id: int):
511
- song = Database.get_song_by_id(music_id)
512
- if not song:
513
- raise HTTPException(status_code=404, detail="歌曲不存在")
514
-
515
- file_path = config.MUSIC_DIR / song['file_name']
516
- if not file_path.exists():
517
- raise HTTPException(status_code=404, detail="音乐文件不存在")
518
-
519
- encoded_filename = urllib.parse.quote(song['file_name'])
520
-
521
- return FileResponse(
522
- file_path,
523
- filename=encoded_filename,
524
- media_type="application/octet-stream",
525
- headers={
526
- "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
527
- }
528
- )
529
-
530
- @app.get("/api/music/{music_id}/default-cover")
531
- async def get_default_cover(music_id: int):
532
- from io import BytesIO
533
- from PIL import Image, ImageDraw, ImageFont
534
- import base64
535
-
536
- img = Image.new('RGB', (300, 300), color=(106, 17, 203))
537
- draw = ImageDraw.Draw(img)
538
- try:
539
- font = ImageFont.truetype("arial.ttf", 100)
540
- except:
541
- font = ImageFont.load_default()
542
- draw.text((150, 150), "♪", fill=(255, 255, 255), font=font, anchor="mm")
543
-
544
- buffer = BytesIO()
545
- img.save(buffer, format="PNG")
546
- img_base64 = base64.b64encode(buffer.getvalue()).decode()
547
-
548
- return {
549
- "image": f"data:image/png;base64,{img_base64}",
550
- "song_id": music_id,
551
- "is_default": True
552
- }
553
-
554
- @app.get("/api/stats")
555
- async def get_stats():
556
- conn = Database.get_connection()
557
- cursor = conn.cursor()
558
-
559
- cursor.execute("SELECT COUNT(*) as total FROM music")
560
- total = cursor.fetchone()[0]
561
-
562
- cursor.execute("SELECT SUM(file_size) as total_size FROM music")
563
- total_size = cursor.fetchone()[0] or 0
564
-
565
- cover_files = list(config.COVERS_DIR.glob("*.jpg")) + list(config.COVERS_DIR.glob("*.png"))
566
- cover_count = len(cover_files)
567
-
568
- conn.close()
569
-
570
- return {
571
- "total_songs": total,
572
- "total_size_gb": round(total_size / (1024**3), 2),
573
- "cover_count": cover_count,
574
- "cover_percentage": round(cover_count / total * 100, 1) if total > 0 else 0
575
- }
576
-
577
- @app.get("/api/health")
578
- async def health_check():
579
- return {
580
- "status": "ok",
581
- "service": config.APP_NAME,
582
- "version": config.VERSION,
583
- "endpoints": {
584
- "API文档": "/",
585
- "扫描歌曲": "/api/scan",
586
- "Swagger交互文档": "/docs",
587
- "搜索音乐": "/api/music?q=关键词",
588
- "播放歌曲": "/api/music/{id}/play",
589
- "下载歌曲": "/api/music/{id}/download",
590
- "系统统计": "/api/stats"
591
- }
592
- }
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 修复版音乐API - 支持Dataset读取 + API文件上传(双源存储)
4
+ """
5
+
6
+ import os
7
+ import sqlite3
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Optional, List, Dict, Union
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
+
20
+ # ==================== 配置部分 ====================
21
+
22
+ class Config:
23
+ """配置类"""
24
+ APP_NAME = "音乐API"
25
+ VERSION = "4.5.0"
26
+
27
+ BASE_DIR = Path(__file__).resolve().parent
28
+
29
+ # ===== 挂载的 Dataset (只读) =====
30
+ # 假设挂载点为 /dataset
31
+ DATASET_DIR = Path("/dataset")
32
+
33
+ # ===== 持久存储 (可写) =====
34
+ # 如果开启了 Space 的 Persistent Storage,它是 /data,重启后数据保留
35
+ # 如果没开启,请改为 /tmp (但上传文件重启后会丢失)
36
+ STORAGE_DIR = Path("/data")
37
+
38
+ # ===== 双源目录定义 =====
39
+ # 逻辑:优先在 STORAGE_DIR 读取,找不到再去 DATASET_DIR 读取
40
+
41
+ # 音乐目录列表 (优先级从前到后)
42
+ MUSIC_DIRS = [
43
+ STORAGE_DIR / "static" / "music", # 新上传的文件放这里
44
+ DATASET_DIR / "static" / "music" # 原始 Dataset 的文件
45
+ ]
46
+
47
+ # 封面目录列表
48
+ COVER_DIRS = [
49
+ STORAGE_DIR / "static" / "covers",
50
+ DATASET_DIR / "static" / "covers"
51
+ ]
52
+
53
+ # 数据库路径 (放在持久存储里,重启不丢)
54
+ DATABASE_PATH = STORAGE_DIR / "music.db"
55
+
56
+ HOST = "0.0.0.0"
57
+ PORT = int(os.environ.get("PORT", 7860))
58
+ DEBUG = True
59
+
60
+ def __init__(self):
61
+ # 确保可写目录存在
62
+ for d in self.MUSIC_DIRS + self.COVER_DIRS:
63
+ d.mkdir(parents=True, exist_ok=True)
64
+ # 确保数据库目录存在
65
+ self.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ config = Config()
68
+
69
+ # ==================== 辅助函数 ====================
70
+
71
+ def find_file(filename: str, directories: List[Path]) -> Optional[Path]:
72
+ """在多个目录中查找文件"""
73
+ for directory in directories:
74
+ path = directory / filename
75
+ if path.exists() and path.is_file():
76
+ return path
77
+ return None
78
+
79
+ def extract_audio_metadata(file_path: Path) -> Dict:
80
+ """使用 mutagen 提取音频文件的元数据"""
81
+ try:
82
+ audio = MutagenFile(file_path)
83
+ if audio is None: return None
84
+ title = file_path.stem
85
+ artist = "Unknown Artist"
86
+ album = ""
87
+ genre = ""
88
+ duration = 0
89
+ if hasattr(audio, 'info') and audio.info.length:
90
+ duration = int(audio.info.length)
91
+ common_keys = {
92
+ 'title': ['TIT2', '\xa9nam', 'TITLE', 'Title', 'title'],
93
+ 'artist': ['TPE1', '\xa9ART', 'ARTIST', 'Artist', 'artist'],
94
+ 'album': ['TALB', '\xa9alb', 'ALBUM', 'Album', 'album'],
95
+ 'genre': ['TCON', '\xa9gen', 'GENRE', 'Genre', 'genre']
96
+ }
97
+ def get_tag(target_type):
98
+ for k in common_keys.get(target_type, []):
99
+ if k in audio:
100
+ val = audio[k]
101
+ if isinstance(val, list) and len(val) > 0:
102
+ return str(val[0])
103
+ return str(val)
104
+ return None
105
+ if get_tag('title'): title = get_tag('title')
106
+ if get_tag('artist'): artist = get_tag('artist')
107
+ if get_tag('album'): album = get_tag('album')
108
+ if get_tag('genre'): genre = get_tag('genre')
109
+ return {"title": title, "artist": artist, "album": album, "genre": genre, "duration": duration, "file_size": file_path.stat().st_size}
110
+ except Exception as e:
111
+ print(f"⚠️ 无法读取 {file_path.name}: {e}")
112
+ return None
113
+
114
+ # ==================== 数据库部分 ====================
115
+
116
+ class Database:
117
+ """数据库管理类"""
118
+ @staticmethod
119
+ def get_connection():
120
+ conn = sqlite3.connect(str(config.DATABASE_PATH))
121
+ conn.row_factory = sqlite3.Row
122
+ return conn
123
+
124
+ @staticmethod
125
+ def init_database():
126
+ conn = Database.get_connection()
127
+ cursor = conn.cursor()
128
+ cursor.execute('''
129
+ CREATE TABLE IF NOT EXISTS music (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ title TEXT NOT NULL,
132
+ artist TEXT NOT NULL,
133
+ album TEXT,
134
+ genre TEXT,
135
+ duration INTEGER,
136
+ file_name TEXT NOT NULL UNIQUE,
137
+ file_size INTEGER,
138
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
139
+ )
140
+ ''')
141
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_title ON music(title)')
142
+ cursor.execute('CREATE INDEX IF NOT EXISTS idx_filename ON music(file_name)')
143
+ conn.commit()
144
+ conn.close()
145
+
146
+ @staticmethod
147
+ def add_song(title: str, artist: str, album: str, genre: str, duration: int, file_name: str, file_size: int) -> int:
148
+ """添加一首歌到数据库"""
149
+ conn = Database.get_connection()
150
+ cursor = conn.cursor()
151
+ try:
152
+ cursor.execute('''
153
+ INSERT INTO music (title, artist, album, genre, duration, file_name, file_size)
154
+ VALUES (?, ?, ?, ?, ?, ?, ?)
155
+ ''', (title, artist, album, genre, duration, file_name, file_size))
156
+ song_id = cursor.lastrowid
157
+ conn.commit()
158
+ return song_id
159
+ except sqlite3.IntegrityError:
160
+ return 0
161
+ finally:
162
+ conn.close()
163
+
164
+ @staticmethod
165
+ def scan_music_files() -> Dict:
166
+ """扫描所有定义的音乐目录并更新数据库"""
167
+ added_count = 0
168
+ skipped_count = 0
169
+ audio_extensions = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.wma']
170
+ conn = Database.get_connection()
171
+ cursor = conn.cursor()
172
+
173
+ # 遍历所有定义的目录
174
+ for base_dir in config.MUSIC_DIRS:
175
+ if not base_dir.exists(): continue
176
+ print(f"正在扫描目录: {base_dir}")
177
+ files = list(base_dir.rglob('*'))
178
+
179
+ for file_path in files:
180
+ if not file_path.is_file(): continue
181
+ if file_path.suffix.lower() not in audio_extensions: continue
182
+
183
+ file_name = file_path.name
184
+ meta = extract_audio_metadata(file_path)
185
+
186
+ # 如果提取失败,使用默认值
187
+ if not meta:
188
+ meta = {"title": file_path.stem, "artist": "Unknown", "album": "", "genre": "", "duration": 0, "file_size": file_path.stat().st_size}
189
+
190
+ try:
191
+ cursor.execute('''
192
+ INSERT OR IGNORE INTO music
193
+ (title, artist, album, genre, duration, file_name, file_size)
194
+ VALUES (?, ?, ?, ?, ?, ?, ?)
195
+ ''', (meta['title'], meta['artist'], meta['album'], meta['genre'], meta['duration'], file_name, meta['file_size']))
196
+
197
+ if cursor.rowcount > 0:
198
+ added_count += 1
199
+ print(f"✅ 新增: {meta['title']} - {meta['artist']} (来源: {base_dir.name})")
200
+ else:
201
+ skipped_count += 1
202
+ except Exception as e:
203
+ print(f"❌ 错误 ({file_name}): {e}")
204
+
205
+ conn.commit()
206
+ conn.close()
207
+ return {"status": "success", "added": added_count, "skipped": skipped_count, "message": f"扫描完成,新增 {added_count} 首"}
208
+
209
+ @staticmethod
210
+ def get_all_songs(limit: int = 1000) -> List[Dict]:
211
+ conn = Database.get_connection()
212
+ cursor = conn.cursor()
213
+ cursor.execute("SELECT * FROM music ORDER BY id DESC LIMIT ?", (limit,))
214
+ rows = cursor.fetchall()
215
+ conn.close()
216
+ return [dict(row) for row in rows]
217
+
218
+ @staticmethod
219
+ def get_song_by_id(song_id: int) -> Optional[Dict]:
220
+ conn = Database.get_connection()
221
+ cursor = conn.cursor()
222
+ cursor.execute("SELECT * FROM music WHERE id = ?", (song_id,))
223
+ row = cursor.fetchone()
224
+ conn.close()
225
+ return dict(row) if row else None
226
+
227
+ # ==================== FastAPI应用部分 ====================
228
+
229
+ app = FastAPI(title=config.APP_NAME, version=config.VERSION, docs_url="/docs")
230
+
231
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
232
+
233
+ # ==================== 页面 ====================
234
+
235
+ API_DOCS_HTML = """
236
+ <!DOCTYPE html>
237
+ <html lang="zh-CN">
238
+ <head>
239
+ <meta charset="UTF-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
241
+ <title>音乐API - 管理面板</title>
242
+ <style>
243
+ :root { --bg: #1a1a2e; --card: #16213e; --text: #e0e0e0; --accent: #e94560; --input: #0f3460; }
244
+ body { font-family: sans-serif; background: var(--bg); color: var(--text); padding: 20px; max-width: 800px; margin: auto; }
245
+ h1 { text-align: center; }
246
+ .section { background: var(--card); padding: 20px; border-radius: 8px; margin-bottom: 20px; }
247
+ .section h2 { border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; }
248
+ label { display: block; margin: 10px 0 5px; font-weight: bold; }
249
+ 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;}
250
+ button { background: var(--accent); color: white; border: none; padding: 12px 24px; border-radius: 4px; cursor: pointer; width: 100%; font-size: 16px; }
251
+ button:hover { opacity: 0.9; }
252
+ .btn-secondary { background: #444; margin-top: 10px; }
253
+ .result { margin-top: 15px; padding: 10px; background: #0f0f1a; border-radius: 4px; display: none; }
254
+ pre { white-space: pre-wrap; word-break: break-all; }
255
+ </style>
256
+ </head>
257
+ <body>
258
+ <h1>🎵 音乐 API 管理面板</h1>
259
+
260
+ <!-- 扫描区域 -->
261
+ <div class="section">
262
+ <h2>📂 扫描库</h2>
263
+ <p>点击下方按钮,扫描 /data /dataset 目录下的所有音乐文件并更新数据库。</p>
264
+ <button onclick="scanLibrary()">扫描并更新数据库</button>
265
+ <div id="scanResult" class="result"><pre></pre></div>
266
+ </div>
267
+
268
+ <!-- 上传区域 -->
269
+ <div class="section">
270
+ <h2>⬆️ 上传新歌</h2>
271
+ <p>上传 MP3 文件和封面。文件将保存到可写存储 (/data/static)。</p>
272
+ <form id="uploadForm" onsubmit="uploadSong(event)">
273
+ <label>音乐文件 (MP3)</label>
274
+ <input type="file" id="file" accept="audio/*" required>
275
+
276
+ <label>封面图片 (可选, JPG/PNG)</label>
277
+ <input type="file" id="cover" accept="image/*">
278
+
279
+ <label>标题 (可选,留空自动读取)</label>
280
+ <input type="text" id="title" placeholder="自动读取...">
281
+
282
+ <label>艺术家 (可选,留空自动读取)</label>
283
+ <input type="text" id="artist" placeholder="自动读取...">
284
+
285
+ <button type="submit">开始上传</button>
286
+ </form>
287
+ <div id="uploadResult" class="result"><pre></pre></div>
288
+ </div>
289
+
290
+ <!-- 歌曲列表 -->
291
+ <div class="section">
292
+ <h2>📋 最近添加</h2>
293
+ <div id="songsList">加载中...</div>
294
+ </div>
295
+
296
+ <script>
297
+ const API_BASE = window.location.origin;
298
+
299
+ function showResult(id, msg) {
300
+ const el = document.getElementById(id);
301
+ el.style.display = 'block';
302
+ el.innerText = msg;
303
+ }
304
+
305
+ async function scanLibrary() {
306
+ showResult('scanResult', '正在扫描...');
307
+ try {
308
+ const res = await fetch(`${API_BASE}/api/scan`);
309
+ const data = await res.json();
310
+ showResult('scanResult', JSON.stringify(data, null, 2));
311
+ loadSongs(); // 刷新列表
312
+ } catch(e) {
313
+ showResult('scanResult', 'Error: ' + e);
314
+ }
315
+ }
316
+
317
+ async function uploadSong(e) {
318
+ e.preventDefault();
319
+ showResult('uploadResult', '正在上传...');
320
+
321
+ const formData = new FormData();
322
+ formData.append('file', document.getElementById('file').files[0]);
323
+ if(document.getElementById('cover').files[0]) {
324
+ formData.append('cover', document.getElementById('cover').files[0]);
325
+ }
326
+ formData.append('title', document.getElementById('title').value);
327
+ formData.append('artist', document.getElementById('artist').value);
328
+
329
+ try {
330
+ const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData });
331
+ const data = await res.json();
332
+ if(res.ok) {
333
+ showResult('uploadResult', `上传成功! ID: ${data.id}\n` + JSON.stringify(data, null, 2));
334
+ document.getElementById('uploadForm').reset();
335
+ loadSongs();
336
+ } else {
337
+ showResult('uploadResult', `上传失败: ${data.detail}`);
338
+ }
339
+ } catch(e) {
340
+ showResult('uploadResult', 'Error: ' + e);
341
+ }
342
+ }
343
+
344
+ async function loadSongs() {
345
+ try {
346
+ const res = await fetch(`${API_BASE}/api/music?limit=10`);
347
+ const songs = await res.json();
348
+ const html = songs.map(s =>
349
+ `<div style="padding:10px; border-bottom:1px solid #333;">
350
+ <b>${s.title}</b> - ${s.artist}<br>
351
+ <small>File: ${s.file_name} | Size: ${(s.file_size/1024/1024).toFixed(2)}MB</small>
352
+ </div>`
353
+ ).join('');
354
+ document.getElementById('songsList').innerHTML = html || '暂无歌曲,请扫描或上传';
355
+ } catch(e) {
356
+ document.getElementById('songsList').innerText = '加载失败';
357
+ }
358
+ }
359
+
360
+ loadSongs();
361
+ </script>
362
+ </body>
363
+ </html>
364
+ """
365
+
366
+ # ==================== 路由部分 ====================
367
+
368
+ @app.on_event("startup")
369
+ def startup_event():
370
+ Database.init_database()
371
+
372
+ @app.get("/")
373
+ async def get_frontend():
374
+ return HTMLResponse(API_DOCS_HTML)
375
+
376
+ # --- 资源服务 (核心逻辑: 优先本地,其次 Dataset) ---
377
+ @app.get("/static/{type}/{filename}")
378
+ async def serve_static(type: str, filename: str):
379
+ """
380
+ 统一的静态资源服务接口
381
+ type: 'music' or 'covers'
382
+ """
383
+ dirs = config.MUSIC_DIRS if type == 'music' else config.COVER_DIRS
384
+ filepath = find_file(filename, dirs)
385
+
386
+ if not filepath:
387
+ raise HTTPException(status_code=404, detail="文件未找到")
388
+
389
+ return FileResponse(filepath)
390
+
391
+ # --- API: 扫描 ---
392
+ @app.get("/api/scan")
393
+ async def scan_library():
394
+ return JSONResponse(Database.scan_music_files())
395
+
396
+ # --- API: 上传 ---
397
+ @app.post("/api/upload")
398
+ async def upload_song(
399
+ file: UploadFile = File(..., description="MP3 文件"),
400
+ cover: Optional[UploadFile] = File(None, description="封面图片"),
401
+ title: Optional[str] = Form(None, description="标题"),
402
+ artist: Optional[str] = Form(None, description="艺术家")
403
+ ):
404
+ """上传歌曲并更新数据库"""
405
+
406
+ # 1. 准备保存路径 (保存到 STORAGE_DIR)
407
+ write_music_dir = config.STORAGE_DIR / "static" / "music"
408
+ write_cover_dir = config.STORAGE_DIR / "static" / "covers"
409
+
410
+ write_music_dir.mkdir(parents=True, exist_ok=True)
411
+ write_cover_dir.mkdir(parents=True, exist_ok=True)
412
+
413
+ # 2. 保存音乐文件
414
+ # 使用安全文件名
415
+ safe_filename = urllib.parse.quote(file.filename).replace('/', '-')
416
+ music_path = write_music_dir / safe_filename
417
+
418
+ try:
419
+ with open(music_path, "wb") as buffer:
420
+ shutil.copyfileobj(file.file, buffer)
421
+ except Exception as e:
422
+ raise HTTPException(status_code=500, detail=f"保存音乐文件失败: {e}")
423
+
424
+ # 3. 提取元数据 (如果没提供)
425
+ if not title or not artist:
426
+ meta = extract_audio_metadata(music_path)
427
+ if meta:
428
+ if not title: title = meta['title']
429
+ if not artist: artist = meta['artist']
430
+
431
+ # 4. 保存封面 (如果有)
432
+ if cover:
433
+ # 封面文件名暂时用歌曲ID还不知道,所以先随机名或原始名,这里简单用原始名
434
+ safe_cover_name = urllib.parse.quote(cover.filename).replace('/', '-')
435
+ cover_path = write_cover_dir / safe_cover_name
436
+ try:
437
+ with open(cover_path, "wb") as buffer:
438
+ shutil.copyfileobj(cover.file, buffer)
439
+ # TODO: 这里有个逻辑问题,封面通常对应 ID,上传时还没 ID。
440
+ # 解决方案:暂时存为原名,上传后获取 ID 再重命名,或者前端调用单独的更新封面接口。
441
+ # 为简化,这里暂不重命名为 ID.jpg,而是依赖用户后续手动管理或者扫描逻辑适配。
442
+ # *改进策略*:将封面重命名为 {音乐ID}.jpg (插入数据库后执行)
443
+ except Exception as e:
444
+ print(f"保存封面失败: {e}")
445
+
446
+ # 5. 插入数据库
447
+ song_id = Database.add_song(
448
+ title=title or "未知标题",
449
+ artist=artist or "未知艺术家",
450
+ album="", # 上传接口暂未提供专辑字段
451
+ genre="",
452
+ duration=0, # 如果已提取 meta 可以填入
453
+ file_name=safe_filename,
454
+ file_size=music_path.stat().st_size
455
+ )
456
+
457
+ if song_id == 0:
458
+ # 插入失败(文件名冲突),删除刚才上传的文件
459
+ music_path.unlink()
460
+ raise HTTPException(status_code=400, detail="该文件名已存在数据库中")
461
+
462
+ # 6. 如果有封面,重命名为 ID.ext
463
+ if cover:
464
+ try:
465
+ ext = Path(safe_cover_name).suffix
466
+ target_cover_path = write_cover_dir / f"{song_id}{ext}"
467
+ cover_path.rename(target_cover_path)
468
+ except:
469
+ pass
470
+
471
+ return JSONResponse({
472
+ "id": song_id,
473
+ "title": title,
474
+ "artist": artist,
475
+ "file_name": safe_filename,
476
+ "message": "上传成功"
477
+ })
478
+
479
+ # --- 其他 API ---
480
+ @app.get("/api/music")
481
+ async def get_music(limit: int = 100):
482
+ songs = Database.get_all_songs(limit)
483
+ # 补全 URL
484
+ for s in songs:
485
+ s['cover_url'] = f"/static/covers/{s['id']}.jpg" # 假设 jpg,实际可以遍历后缀
486
+ s['play_url'] = f"/api/play/{s['id']}"
487
+ return songs
488
+
489
+ @app.get("/api/play/{song_id}")
490
+ async def play_music(song_id: int):
491
+ song = Database.get_song_by_id(song_id)
492
+ if not song: raise HTTPException(404)
493
+ # 查找实际文件
494
+ filepath = find_file(song['file_name'], config.MUSIC_DIRS)
495
+ if not filepath: raise HTTPException(404, detail="音乐文件丢失")
496
+ return FileResponse(filepath, media_type="audio/mpeg")