ljx77qaq commited on
Commit
8e4a00e
·
verified ·
1 Parent(s): 9c95c35

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +352 -590
app.py CHANGED
@@ -1,42 +1,53 @@
1
  import os
2
- import io
3
  import base64
4
  import tempfile
5
  import uuid
 
6
  from typing import Optional
7
 
8
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
9
- from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
10
  from mutagen.mp3 import MP3
11
  from mutagen.id3 import (
12
- ID3, TIT2, TPE1, TALB, TDRC, TRCK, TCON, COMM, APIC, TPE2, TCOM, TPOS
 
13
  )
14
 
15
  app = FastAPI()
16
-
17
  TEMP_DIR = tempfile.mkdtemp()
18
  file_store = {}
19
 
20
 
 
 
 
 
 
21
  def extract_cover(tags):
22
  if tags is None:
23
  return None
24
  for key in tags:
25
  if key.startswith("APIC"):
26
  apic = tags[key]
27
- mime = apic.mime
28
  data = base64.b64encode(apic.data).decode("utf-8")
29
- return {"mime": mime, "data": f"data:{mime};base64,{data}"}
30
  return None
31
 
32
 
 
 
 
 
 
 
 
 
 
33
  def get_tag_text(tags, key, default=""):
34
  if tags is None:
35
  return default
36
  tag = tags.get(key)
37
- if tag:
38
- return str(tag)
39
- return default
40
 
41
 
42
  @app.post("/api/upload")
@@ -56,10 +67,17 @@ async def upload_file(file: UploadFile = File(...)):
56
  try:
57
  audio = MP3(file_path)
58
  tags = audio.tags
 
 
 
 
 
 
59
 
60
- info = {
61
  "file_id": file_id,
62
  "filename": file.filename,
 
63
  "duration": round(audio.info.length, 2),
64
  "bitrate": audio.info.bitrate // 1000,
65
  "sample_rate": audio.info.sample_rate,
@@ -74,18 +92,11 @@ async def upload_file(file: UploadFile = File(...)):
74
  "disc": get_tag_text(tags, "TPOS"),
75
  "genre": get_tag_text(tags, "TCON"),
76
  "composer": get_tag_text(tags, "TCOM"),
77
- "comment": "",
 
78
  },
79
  "cover": extract_cover(tags),
80
  }
81
-
82
- if tags:
83
- for key in tags:
84
- if key.startswith("COMM"):
85
- info["tags"]["comment"] = str(tags[key])
86
- break
87
-
88
- return info
89
  except Exception as e:
90
  raise HTTPException(status_code=500, detail=f"读取失败: {str(e)}")
91
 
@@ -93,6 +104,7 @@ async def upload_file(file: UploadFile = File(...)):
93
  @app.post("/api/save")
94
  async def save_tags(
95
  file_id: str = Form(...),
 
96
  title: str = Form(""),
97
  artist: str = Form(""),
98
  album: str = Form(""),
@@ -103,6 +115,7 @@ async def save_tags(
103
  genre: str = Form(""),
104
  composer: str = Form(""),
105
  comment: str = Form(""),
 
106
  cover: Optional[UploadFile] = File(None),
107
  remove_cover: str = Form("false"),
108
  ):
@@ -117,7 +130,6 @@ async def save_tags(
117
  audio.add_tags()
118
  except Exception:
119
  pass
120
-
121
  tags = audio.tags
122
 
123
  tag_map = {
@@ -131,7 +143,6 @@ async def save_tags(
131
  "TCON": (TCON, genre),
132
  "TCOM": (TCOM, composer),
133
  }
134
-
135
  for key, (cls, val) in tag_map.items():
136
  if val:
137
  tags[key] = cls(encoding=3, text=val)
@@ -139,29 +150,37 @@ async def save_tags(
139
  del tags[key]
140
 
141
  # Comment
142
- keys_to_del = [k for k in tags if k.startswith("COMM")]
143
- for k in keys_to_del:
144
  del tags[k]
145
  if comment:
146
  tags["COMM::eng"] = COMM(encoding=3, lang="eng", desc="", text=comment)
147
 
 
 
 
 
 
 
148
  # Cover
149
  if remove_cover == "true":
150
- keys_to_del = [k for k in tags if k.startswith("APIC")]
151
- for k in keys_to_del:
152
  del tags[k]
153
  elif cover:
154
  cover_data = await cover.read()
155
  mime = cover.content_type or "image/jpeg"
156
- keys_to_del = [k for k in tags if k.startswith("APIC")]
157
- for k in keys_to_del:
158
  del tags[k]
159
- tags["APIC:"] = APIC(
160
- encoding=3, mime=mime, type=3, desc="Cover", data=cover_data
161
- )
162
 
163
  audio.save()
164
- return {"success": True, "message": "保存成功"}
 
 
 
 
 
 
 
165
  except Exception as e:
166
  raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
167
 
@@ -170,9 +189,11 @@ async def save_tags(
170
  async def download_file(file_id: str):
171
  if file_id not in file_store:
172
  raise HTTPException(status_code=404, detail="文件不存在")
173
- file_path = file_store[file_id]["path"]
174
- original_name = file_store[file_id]["original_name"]
175
- return FileResponse(file_path, media_type="audio/mpeg", filename=original_name)
 
 
176
 
177
 
178
  @app.delete("/api/file/{file_id}")
@@ -186,655 +207,396 @@ async def delete_file(file_id: str):
186
  return {"success": True}
187
 
188
 
189
- HTML_CONTENT = """
190
- <!DOCTYPE html>
191
  <html lang="zh-CN">
192
  <head>
193
  <meta charset="UTF-8">
194
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
195
  <title>MP3 Tag Editor</title>
196
  <style>
197
- * { margin: 0; padding: 0; box-sizing: border-box; }
198
- body {
199
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
200
- background: #0f0f13;
201
- color: #e0e0e0;
202
- min-height: 100vh;
203
- }
204
-
205
- .header {
206
- background: #1a1a24;
207
- border-bottom: 1px solid #2a2a3a;
208
- padding: 16px 24px;
209
- display: flex;
210
- align-items: center;
211
- gap: 12px;
212
- }
213
- .header h1 {
214
- font-size: 20px;
215
- font-weight: 700;
216
- color: #63e2b7;
217
- }
218
- .header span { color: #888; font-size: 13px; }
219
-
220
- .container {
221
- max-width: 920px;
222
- margin: 24px auto;
223
- padding: 0 20px;
224
- }
225
-
226
- .card {
227
- background: #1a1a24;
228
- border: 1px solid #2a2a3a;
229
- border-radius: 10px;
230
- padding: 24px;
231
- margin-bottom: 16px;
232
- }
233
- .card-title {
234
- font-size: 15px;
235
- font-weight: 600;
236
- margin-bottom: 16px;
237
- color: #ccc;
238
- }
239
-
240
- /* Upload */
241
- .upload-zone {
242
- border: 2px dashed #3a3a4a;
243
- border-radius: 10px;
244
- padding: 48px 24px;
245
- text-align: center;
246
- cursor: pointer;
247
- transition: all 0.2s;
248
- }
249
- .upload-zone:hover, .upload-zone.dragover {
250
- border-color: #63e2b7;
251
- background: rgba(99, 226, 183, 0.05);
252
- }
253
- .upload-zone svg { width: 48px; height: 48px; color: #555; margin-bottom: 12px; }
254
- .upload-zone p { color: #aaa; margin-top: 8px; }
255
- .upload-zone .main-text { font-size: 16px; color: #ccc; }
256
-
257
- /* File Info Bar */
258
- .file-bar {
259
- display: flex;
260
- align-items: center;
261
- justify-content: space-between;
262
- flex-wrap: wrap;
263
- gap: 8px;
264
- }
265
- .file-bar-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
266
- .file-name { font-weight: 600; color: #63e2b7; }
267
- .tag-badge {
268
- display: inline-block;
269
- padding: 2px 8px;
270
- border-radius: 4px;
271
- font-size: 12px;
272
- background: #2a2a3a;
273
- color: #aaa;
274
- }
275
- .tag-badge.info { background: #1a3a5c; color: #7ec8e3; }
276
- .tag-badge.success { background: #1a3c2a; color: #63e2b7; }
277
-
278
- .btn-close-file {
279
- background: none;
280
- border: none;
281
- color: #e06c75;
282
- cursor: pointer;
283
- font-size: 13px;
284
- padding: 4px 8px;
285
- border-radius: 4px;
286
- }
287
- .btn-close-file:hover { background: rgba(224,108,117,0.1); }
288
-
289
- /* Layout */
290
- .editor-grid {
291
- display: grid;
292
- grid-template-columns: 240px 1fr;
293
- gap: 16px;
294
- }
295
- @media (max-width: 700px) {
296
- .editor-grid { grid-template-columns: 1fr; }
297
- }
298
-
299
- /* Cover */
300
- .cover-box {
301
- width: 100%;
302
- aspect-ratio: 1;
303
- border-radius: 8px;
304
- overflow: hidden;
305
- background: #12121a;
306
- border: 1px dashed #2a2a3a;
307
- display: flex;
308
- align-items: center;
309
- justify-content: center;
310
- cursor: pointer;
311
- }
312
- .cover-box img {
313
- width: 100%;
314
- height: 100%;
315
- object-fit: cover;
316
- }
317
- .cover-placeholder {
318
- text-align: center;
319
- color: #555;
320
- }
321
- .cover-placeholder svg { width: 48px; height: 48px; }
322
-
323
- /* Form */
324
- .form-grid {
325
- display: grid;
326
- grid-template-columns: 1fr 1fr;
327
- gap: 12px;
328
- }
329
- .form-grid .full { grid-column: 1 / -1; }
330
-
331
- .form-group { display: flex; flex-direction: column; gap: 4px; }
332
- .form-group label {
333
- font-size: 12px;
334
- color: #888;
335
- font-weight: 500;
336
- }
337
- .form-group input,
338
- .form-group textarea {
339
- background: #12121a;
340
- border: 1px solid #2a2a3a;
341
- border-radius: 6px;
342
- padding: 8px 12px;
343
- color: #e0e0e0;
344
- font-size: 14px;
345
- outline: none;
346
- transition: border-color 0.2s;
347
- width: 100%;
348
- }
349
- .form-group input:focus,
350
- .form-group textarea:focus {
351
- border-color: #63e2b7;
352
- }
353
- .form-group textarea { resize: vertical; min-height: 68px; }
354
-
355
- /* Buttons */
356
- .btn {
357
- padding: 8px 20px;
358
- border-radius: 6px;
359
- border: 1px solid transparent;
360
- cursor: pointer;
361
- font-size: 14px;
362
- font-weight: 500;
363
- transition: all 0.2s;
364
- display: inline-flex;
365
- align-items: center;
366
- gap: 6px;
367
- }
368
- .btn:disabled { opacity: 0.4; cursor: not-allowed; }
369
- .btn-primary {
370
- background: #63e2b7;
371
- color: #0f0f13;
372
- border-color: #63e2b7;
373
- }
374
- .btn-primary:hover:not(:disabled) { background: #7aebb0; }
375
- .btn-success {
376
- background: #2a6b4a;
377
- color: #e0e0e0;
378
- border-color: #3a8a5a;
379
- }
380
- .btn-success:hover:not(:disabled) { background: #3a8a5a; }
381
- .btn-ghost {
382
- background: transparent;
383
- color: #aaa;
384
- border-color: #3a3a4a;
385
- }
386
- .btn-ghost:hover:not(:disabled) { border-color: #63e2b7; color: #63e2b7; }
387
- .btn-danger-ghost {
388
- background: transparent;
389
- color: #e06c75;
390
- border-color: #5a2a2a;
391
- }
392
- .btn-danger-ghost:hover:not(:disabled) { background: rgba(224,108,117,0.1); }
393
- .btn-block { width: 100%; justify-content: center; }
394
-
395
- .actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
396
-
397
- .cover-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
398
-
399
- /* Loading */
400
- .loading {
401
- text-align: center;
402
- padding: 48px;
403
- color: #888;
404
- }
405
- .spinner {
406
- width: 32px; height: 32px;
407
- border: 3px solid #2a2a3a;
408
- border-top-color: #63e2b7;
409
- border-radius: 50%;
410
- animation: spin 0.8s linear infinite;
411
- margin: 0 auto 12px;
412
- }
413
- @keyframes spin { to { transform: rotate(360deg); } }
414
-
415
- .footer {
416
- text-align: center;
417
- padding: 16px;
418
- color: #555;
419
- font-size: 12px;
420
- border-top: 1px solid #1a1a24;
421
- margin-top: 24px;
422
- }
423
-
424
- .toast {
425
- position: fixed;
426
- top: 20px;
427
- right: 20px;
428
- padding: 12px 20px;
429
- border-radius: 8px;
430
- font-size: 14px;
431
- z-index: 9999;
432
- animation: slideIn 0.3s ease;
433
- max-width: 360px;
434
- }
435
- .toast.success { background: #1a3c2a; color: #63e2b7; border: 1px solid #2a5a3a; }
436
- .toast.error { background: #3c1a1a; color: #e06c75; border: 1px solid #5a2a2a; }
437
- .toast.info { background: #1a2a3c; color: #7ec8e3; border: 1px solid #2a3a5a; }
438
- @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
439
-
440
- /* Modal */
441
- .modal-overlay {
442
- position: fixed; inset: 0;
443
- background: rgba(0,0,0,0.7);
444
- display: flex; align-items: center; justify-content: center;
445
- z-index: 9998;
446
- }
447
- .modal-content {
448
- background: #1a1a24;
449
- border: 1px solid #2a2a3a;
450
- border-radius: 12px;
451
- padding: 20px;
452
- max-width: 500px;
453
- width: 90%;
454
- }
455
- .modal-content img { width: 100%; border-radius: 8px; }
456
- .modal-header {
457
- display: flex; justify-content: space-between; align-items: center;
458
- margin-bottom: 16px;
459
- }
460
- .modal-header h3 { font-size: 16px; }
461
-
462
- input[type="file"] { display: none; }
463
  </style>
464
  </head>
465
  <body>
466
 
467
  <div class="header">
468
- <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#63e2b7" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
469
  <h1>MP3 Tag Editor</h1>
470
- <span>在线编辑 MP3 元数据信息</span>
471
  </div>
472
 
473
- <div class="container" id="app">
474
 
475
  <!-- Upload -->
476
  <div class="card" id="uploadCard">
477
- <div class="card-title">上传文件</div>
478
- <div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
479
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 16V4m0 0L8 8m4-4l4 4"/><path d="M20 16v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2"/></svg>
480
  <p class="main-text">点击或拖拽 MP3 文件到此处</p>
481
- <p>支持 .mp3 格式文件</p>
482
  </div>
483
  <input type="file" id="fileInput" accept=".mp3">
484
  </div>
485
 
486
  <!-- Loading -->
487
- <div class="card" id="loadingCard" style="display:none;">
488
- <div class="loading">
489
- <div class="spinner"></div>
490
- <p>正在解析文件...</p>
491
- </div>
492
  </div>
493
 
494
  <!-- Editor -->
495
- <div id="editorSection" style="display:none;">
496
 
497
- <!-- File bar -->
498
- <div class="card" id="fileBarCard">
499
  <div class="file-bar">
500
  <div class="file-bar-left">
501
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#63e2b7" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
502
- <span class="file-name" id="fileName"></span>
503
- <span class="tag-badge info" id="fileBitrate"></span>
504
- <span class="tag-badge success" id="fileSampleRate"></span>
505
- <span class="tag-badge" id="fileDuration"></span>
 
506
  </div>
507
- <button class="btn-close-file" onclick="resetFile()">✕ 关闭文件</button>
508
  </div>
509
  </div>
510
 
511
  <div class="editor-grid">
512
- <!-- Left: Cover -->
513
  <div>
514
  <div class="card">
515
- <div class="card-title">封面图片</div>
516
- <div class="cover-box" id="coverBox" onclick="showCoverPreview()">
517
- <img id="coverImg" style="display:none;">
518
- <div class="cover-placeholder" id="coverPlaceholder">
519
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
520
- <p style="margin-top:8px;">无封面</p>
521
  </div>
522
  </div>
523
  <div class="cover-actions">
524
- <button class="btn btn-ghost btn-block" onclick="document.getElementById('coverInput').click()">
525
- 🖼️ <span id="coverBtnText">添加封面</span>
526
- </button>
527
- <button class="btn btn-danger-ghost btn-block" id="removeCoverBtn" style="display:none;" onclick="removeCover()">
528
- 🗑️ 移除封面
529
- </button>
530
  </div>
531
  <input type="file" id="coverInput" accept="image/*">
532
  </div>
533
  </div>
534
 
535
- <!-- Right: Form -->
536
  <div>
537
  <div class="card">
538
- <div class="card-title">标签信息</div>
539
- <div class="form-grid">
540
- <div class="form-group full">
541
- <label>标题</label>
542
- <input type="text" id="tag_title" placeholder="歌曲标题">
543
- </div>
544
- <div class="form-group">
545
- <label>艺术家</label>
546
- <input type="text" id="tag_artist" placeholder="表演者">
547
- </div>
548
- <div class="form-group">
549
- <label>专辑艺术家</label>
550
- <input type="text" id="tag_album_artist" placeholder="专辑艺术家">
551
- </div>
552
- <div class="form-group">
553
- <label>专辑</label>
554
- <input type="text" id="tag_album" placeholder="专辑名称">
555
- </div>
556
- <div class="form-group">
557
- <label>年份</label>
558
- <input type="text" id="tag_year" placeholder="发行年份">
 
 
 
 
 
 
 
 
 
559
  </div>
560
- <div class="form-group">
561
- <label>音轨号</label>
562
- <input type="text" id="tag_track" placeholder="如: 1 或 1/12">
 
 
 
 
563
  </div>
564
- <div class="form-group">
565
- <label>碟片号</label>
566
- <input type="text" id="tag_disc" placeholder=": 1 或 1/2">
 
 
 
 
567
  </div>
568
- <div class="form-group">
569
- <label>流派</label>
570
- <input type="text" id="tag_genre" placeholder="音乐流派" list="genreList">
571
- <datalist id="genreList">
572
- <option value="Pop"><option value="Rock"><option value="Hip-Hop">
573
- <option value="R&B"><option value="Jazz"><option value="Blues">
574
- <option value="Classical"><option value="Country"><option value="Electronic">
575
- <option value="Dance"><option value="Folk"><option value="Indie">
576
- <option value="Metal"><option value="Punk"><option value="Reggae">
577
- <option value="Soul"><option value="Alternative"><option value="Ambient">
578
- <option value="Funk"><option value="Latin"><option value="Soundtrack">
579
- <option value="J-Pop"><option value="K-Pop"><option value="C-Pop">
580
- <option value="Anime"><option value="Game"><option value="Instrumental">
581
- </datalist>
582
  </div>
583
- <div class="form-group">
584
- <label>作曲</label>
585
- <input type="text" id="tag_composer" placeholder="作曲者">
 
 
 
586
  </div>
587
- <div class="form-group full">
588
- <label>注释</label>
589
- <textarea id="tag_comment" placeholder="注释信息"></textarea>
 
590
  </div>
591
  </div>
592
  </div>
593
 
594
  <div class="actions">
595
- <button class="btn btn-ghost" id="resetBtn" onclick="resetTags()">🔄 重置</button>
596
- <button class="btn btn-primary" id="saveBtn" onclick="saveTags()">💾 保存标签</button>
597
- <button class="btn btn-success" id="downloadBtn" onclick="downloadFile()" disabled>📥 下载文件</button>
598
  </div>
599
  </div>
600
  </div>
601
  </div>
602
  </div>
603
 
604
- <div class="footer">
605
- MP3 Tag Editor · 基于 Mutagen 构建 · 文件仅在服务端临时存储
606
- </div>
607
 
608
  <!-- Cover Modal -->
609
- <div class="modal-overlay" id="coverModal" style="display:none;" onclick="closeCoverModal(event)">
610
- <div class="modal-content" onclick="event.stopPropagation()">
611
- <div class="modal-header">
612
- <h3>封面预览</h3>
613
- <button class="btn-close-file" onclick="document.getElementById('coverModal').style.display='none'">✕</button>
614
- </div>
615
  <img id="coverModalImg">
616
  </div>
617
  </div>
618
 
619
  <script>
620
- let fileInfo = null;
621
- let originalTags = {};
622
- let coverFileData = null;
623
- let removeCoverFlag = false;
624
-
625
- const $ = id => document.getElementById(id);
626
- const tagFields = ['title','artist','album','album_artist','year','track','disc','genre','composer','comment'];
627
-
628
- // Toast
629
- function showToast(msg, type='info') {
630
- const t = document.createElement('div');
631
- t.className = 'toast ' + type;
632
- t.textContent = msg;
633
- document.body.appendChild(t);
634
- setTimeout(() => { t.style.opacity='0'; t.style.transition='opacity 0.3s'; setTimeout(()=>t.remove(),300); }, 3000);
635
  }
636
 
637
- // Upload zone drag & drop
638
- const uz = $('uploadZone');
639
- uz.addEventListener('dragover', e => { e.preventDefault(); uz.classList.add('dragover'); });
640
- uz.addEventListener('dragleave', () => uz.classList.remove('dragover'));
641
- uz.addEventListener('drop', e => {
642
- e.preventDefault();
643
- uz.classList.remove('dragover');
644
- const files = e.dataTransfer.files;
645
- if (files.length > 0 && files[0].name.toLowerCase().endsWith('.mp3')) {
646
- uploadFile(files[0]);
647
- } else {
648
- showToast('请上传 .mp3 文件', 'error');
649
- }
650
  });
651
 
652
- $('fileInput').addEventListener('change', e => {
653
- if (e.target.files.length > 0) uploadFile(e.target.files[0]);
654
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
 
656
- $('coverInput').addEventListener('change', e => {
657
- if (e.target.files.length > 0) {
658
- const file = e.target.files[0];
659
- coverFileData = file;
660
- removeCoverFlag = false;
661
- const reader = new FileReader();
662
- reader.onload = ev => {
663
- $('coverImg').src = ev.target.result;
664
- $('coverImg').style.display = 'block';
665
- $('coverPlaceholder').style.display = 'none';
666
- $('removeCoverBtn').style.display = 'block';
667
- $('coverBtnText').textContent = '更换封面';
668
- };
669
- reader.readAsDataURL(file);
670
- }
671
- });
672
 
673
- async function uploadFile(file) {
674
- $('uploadCard').style.display = 'none';
675
- $('loadingCard').style.display = 'block';
676
-
677
- const fd = new FormData();
678
- fd.append('file', file);
679
-
680
- try {
681
- const res = await fetch('/api/upload', { method: 'POST', body: fd });
682
- if (!res.ok) {
683
- const err = await res.json();
684
- throw new Error(err.detail || '上传失败');
685
- }
686
- fileInfo = await res.json();
687
-
688
- // Fill form
689
- tagFields.forEach(f => {
690
- const el = $('tag_' + f);
691
- if (el) el.value = fileInfo.tags[f] || '';
692
- });
693
- originalTags = { ...fileInfo.tags };
694
-
695
- // File info bar
696
- $('fileName').textContent = fileInfo.filename;
697
- $('fileBitrate').textContent = fileInfo.bitrate + ' kbps';
698
- $('fileSampleRate').textContent = fileInfo.sample_rate + ' Hz';
699
- const m = Math.floor(fileInfo.duration / 60);
700
- const s = Math.floor(fileInfo.duration % 60);
701
- $('fileDuration').textContent = m + ':' + String(s).padStart(2,'0');
702
-
703
- // Cover
704
- if (fileInfo.cover && fileInfo.cover.data) {
705
- $('coverImg').src = fileInfo.cover.data;
706
- $('coverImg').style.display = 'block';
707
- $('coverPlaceholder').style.display = 'none';
708
- $('removeCoverBtn').style.display = 'block';
709
- $('coverBtnText').textContent = '更换封面';
710
- } else {
711
- $('coverImg').style.display = 'none';
712
- $('coverPlaceholder').style.display = 'flex';
713
- $('removeCoverBtn').style.display = 'none';
714
- $('coverBtnText').textContent = '添加封面';
715
- }
716
-
717
- coverFileData = null;
718
- removeCoverFlag = false;
719
- $('downloadBtn').disabled = true;
720
-
721
- $('loadingCard').style.display = 'none';
722
- $('editorSection').style.display = 'block';
723
- showToast('文件解析成功', 'success');
724
-
725
- } catch (err) {
726
- $('loadingCard').style.display = 'none';
727
- $('uploadCard').style.display = 'block';
728
- showToast(err.message, 'error');
729
- }
730
  }
731
 
732
- function removeCover() {
733
- $('coverImg').style.display = 'none';
734
- $('coverPlaceholder').style.display = 'flex';
735
- $('removeCoverBtn').style.display = 'none';
736
- $('coverBtnText').textContent = '添加封面';
737
- coverFileData = null;
738
- removeCoverFlag = true;
 
 
 
 
 
 
 
 
 
 
 
 
739
  }
740
 
741
- function resetTags() {
742
- tagFields.forEach(f => {
743
- const el = $('tag_' + f);
744
- if (el) el.value = originalTags[f] || '';
745
- });
746
-
747
- // Restore cover
748
- if (fileInfo.cover && fileInfo.cover.data) {
749
- $('coverImg').src = fileInfo.cover.data;
750
- $('coverImg').style.display = 'block';
751
- $('coverPlaceholder').style.display = 'none';
752
- $('removeCoverBtn').style.display = 'block';
753
- $('coverBtnText').textContent = '更换封面';
754
- } else {
755
- $('coverImg').style.display = 'none';
756
- $('coverPlaceholder').style.display = 'flex';
757
- $('removeCoverBtn').style.display = 'none';
758
- $('coverBtnText').textContent = '添加封面';
759
- }
760
- coverFileData = null;
761
- removeCoverFlag = false;
762
- showToast('已重置为原始标签', 'info');
763
- }
764
 
765
- async function saveTags() {
766
- if (!fileInfo) return;
767
-
768
- $('saveBtn').disabled = true;
769
- $('saveBtn').textContent = '⏳ 保存中...';
770
-
771
- const fd = new FormData();
772
- fd.append('file_id', fileInfo.file_id);
773
-
774
- tagFields.forEach(f => {
775
- const el = $('tag_' + f);
776
- fd.append(f, el ? el.value : '');
777
- });
778
-
779
- fd.append('remove_cover', removeCoverFlag ? 'true' : 'false');
780
- if (coverFileData) {
781
- fd.append('cover', coverFileData);
782
- }
783
-
784
- try {
785
- const res = await fetch('/api/save', { method: 'POST', body: fd });
786
- if (!res.ok) {
787
- const err = await res.json();
788
- throw new Error(err.detail || '保存失败');
789
- }
790
- $('downloadBtn').disabled = false;
791
- showToast('标签保存成功!可以下载文件了', 'success');
792
- } catch (err) {
793
- showToast(err.message, 'error');
794
- } finally {
795
- $('saveBtn').disabled = false;
796
- $('saveBtn').textContent = '💾 保存标签';
797
- }
798
  }
799
 
800
- function downloadFile() {
801
- if (!fileInfo) return;
802
- const a = document.createElement('a');
803
- a.href = '/api/download/' + fileInfo.file_id;
804
- a.download = fileInfo.filename;
805
- a.click();
806
- }
807
 
808
- async function resetFile() {
809
- if (fileInfo) {
810
- try { await fetch('/api/file/' + fileInfo.file_id, { method: 'DELETE' }); } catch {}
811
- }
812
- fileInfo = null;
813
- coverFileData = null;
814
- removeCoverFlag = false;
815
- $('editorSection').style.display = 'none';
816
- $('uploadCard').style.display = 'block';
817
- $('fileInput').value = '';
818
- $('coverInput').value = '';
819
- tagFields.forEach(f => { const el=$('tag_'+f); if(el) el.value=''; });
820
  }
821
 
822
- function showCoverPreview() {
823
- if ($('coverImg').style.display !== 'none') {
824
- $('coverModalImg').src = $('coverImg').src;
825
- $('coverModal').style.display = 'flex';
826
- }
827
  }
828
 
829
- function closeCoverModal(e) {
830
- if (e.target === $('coverModal')) $('coverModal').style.display = 'none';
 
 
 
 
 
 
 
831
  }
832
  </script>
833
  </body>
834
- </html>
835
- """
836
 
837
 
838
  @app.get("/")
839
  async def index():
840
- return HTMLResponse(content=HTML_CONTENT)
 
1
  import os
 
2
  import base64
3
  import tempfile
4
  import uuid
5
+ import re
6
  from typing import Optional
7
 
8
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
9
+ from fastapi.responses import HTMLResponse, FileResponse
10
  from mutagen.mp3 import MP3
11
  from mutagen.id3 import (
12
+ TIT2, TPE1, TALB, TDRC, TRCK, TCON, COMM, APIC,
13
+ TPE2, TCOM, TPOS, USLT, SYLT
14
  )
15
 
16
  app = FastAPI()
 
17
  TEMP_DIR = tempfile.mkdtemp()
18
  file_store = {}
19
 
20
 
21
+ def safe_filename(name):
22
+ name = re.sub(r'[<>:"/\\|?*]', '_', name)
23
+ return name.strip('. ')
24
+
25
+
26
  def extract_cover(tags):
27
  if tags is None:
28
  return None
29
  for key in tags:
30
  if key.startswith("APIC"):
31
  apic = tags[key]
 
32
  data = base64.b64encode(apic.data).decode("utf-8")
33
+ return {"mime": apic.mime, "data": f"data:{apic.mime};base64,{data}"}
34
  return None
35
 
36
 
37
+ def extract_lyrics(tags):
38
+ if tags is None:
39
+ return ""
40
+ for key in tags:
41
+ if key.startswith("USLT"):
42
+ return str(tags[key])
43
+ return ""
44
+
45
+
46
  def get_tag_text(tags, key, default=""):
47
  if tags is None:
48
  return default
49
  tag = tags.get(key)
50
+ return str(tag) if tag else default
 
 
51
 
52
 
53
  @app.post("/api/upload")
 
67
  try:
68
  audio = MP3(file_path)
69
  tags = audio.tags
70
+ comment = ""
71
+ if tags:
72
+ for key in tags:
73
+ if key.startswith("COMM"):
74
+ comment = str(tags[key])
75
+ break
76
 
77
+ return {
78
  "file_id": file_id,
79
  "filename": file.filename,
80
+ "filename_noext": os.path.splitext(file.filename)[0],
81
  "duration": round(audio.info.length, 2),
82
  "bitrate": audio.info.bitrate // 1000,
83
  "sample_rate": audio.info.sample_rate,
 
92
  "disc": get_tag_text(tags, "TPOS"),
93
  "genre": get_tag_text(tags, "TCON"),
94
  "composer": get_tag_text(tags, "TCOM"),
95
+ "comment": comment,
96
+ "lyrics": extract_lyrics(tags),
97
  },
98
  "cover": extract_cover(tags),
99
  }
 
 
 
 
 
 
 
 
100
  except Exception as e:
101
  raise HTTPException(status_code=500, detail=f"读取失败: {str(e)}")
102
 
 
104
  @app.post("/api/save")
105
  async def save_tags(
106
  file_id: str = Form(...),
107
+ new_filename: str = Form(""),
108
  title: str = Form(""),
109
  artist: str = Form(""),
110
  album: str = Form(""),
 
115
  genre: str = Form(""),
116
  composer: str = Form(""),
117
  comment: str = Form(""),
118
+ lyrics: str = Form(""),
119
  cover: Optional[UploadFile] = File(None),
120
  remove_cover: str = Form("false"),
121
  ):
 
130
  audio.add_tags()
131
  except Exception:
132
  pass
 
133
  tags = audio.tags
134
 
135
  tag_map = {
 
143
  "TCON": (TCON, genre),
144
  "TCOM": (TCOM, composer),
145
  }
 
146
  for key, (cls, val) in tag_map.items():
147
  if val:
148
  tags[key] = cls(encoding=3, text=val)
 
150
  del tags[key]
151
 
152
  # Comment
153
+ for k in [k for k in tags if k.startswith("COMM")]:
 
154
  del tags[k]
155
  if comment:
156
  tags["COMM::eng"] = COMM(encoding=3, lang="eng", desc="", text=comment)
157
 
158
+ # Lyrics
159
+ for k in [k for k in tags if k.startswith("USLT")]:
160
+ del tags[k]
161
+ if lyrics:
162
+ tags["USLT::eng"] = USLT(encoding=3, lang="eng", desc="", text=lyrics)
163
+
164
  # Cover
165
  if remove_cover == "true":
166
+ for k in [k for k in tags if k.startswith("APIC")]:
 
167
  del tags[k]
168
  elif cover:
169
  cover_data = await cover.read()
170
  mime = cover.content_type or "image/jpeg"
171
+ for k in [k for k in tags if k.startswith("APIC")]:
 
172
  del tags[k]
173
+ tags["APIC:"] = APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data)
 
 
174
 
175
  audio.save()
176
+
177
+ # Update filename
178
+ if new_filename:
179
+ clean = safe_filename(new_filename)
180
+ if clean:
181
+ file_store[file_id]["original_name"] = clean + ".mp3"
182
+
183
+ return {"success": True, "message": "保存成功", "filename": file_store[file_id]["original_name"]}
184
  except Exception as e:
185
  raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
186
 
 
189
  async def download_file(file_id: str):
190
  if file_id not in file_store:
191
  raise HTTPException(status_code=404, detail="文件不存在")
192
+ return FileResponse(
193
+ file_store[file_id]["path"],
194
+ media_type="audio/mpeg",
195
+ filename=file_store[file_id]["original_name"],
196
+ )
197
 
198
 
199
  @app.delete("/api/file/{file_id}")
 
207
  return {"success": True}
208
 
209
 
210
+ HTML = """<!DOCTYPE html>
 
211
  <html lang="zh-CN">
212
  <head>
213
  <meta charset="UTF-8">
214
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
215
  <title>MP3 Tag Editor</title>
216
  <style>
217
+ *{margin:0;padding:0;box-sizing:border-box}
218
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f0f13;color:#e0e0e0;min-height:100vh}
219
+ .header{background:#1a1a24;border-bottom:1px solid #2a2a3a;padding:14px 24px;display:flex;align-items:center;gap:12px}
220
+ .header h1{font-size:20px;font-weight:700;color:#63e2b7}
221
+ .header span{color:#888;font-size:13px}
222
+ .container{max-width:960px;margin:20px auto;padding:0 16px}
223
+ .card{background:#1a1a24;border:1px solid #2a2a3a;border-radius:10px;padding:20px;margin-bottom:14px}
224
+ .card-title{font-size:14px;font-weight:600;margin-bottom:14px;color:#999;text-transform:uppercase;letter-spacing:.5px}
225
+
226
+ .upload-zone{border:2px dashed #3a3a4a;border-radius:10px;padding:48px 24px;text-align:center;cursor:pointer;transition:all .2s}
227
+ .upload-zone:hover,.upload-zone.dragover{border-color:#63e2b7;background:rgba(99,226,183,.05)}
228
+ .upload-zone svg{width:48px;height:48px;color:#555;margin-bottom:12px}
229
+ .upload-zone .main-text{font-size:16px;color:#ccc}
230
+ .upload-zone p{color:#888;margin-top:6px}
231
+
232
+ .file-bar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}
233
+ .file-bar-left{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
234
+ .file-name{font-weight:600;color:#63e2b7}
235
+ .badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:#2a2a3a;color:#aaa}
236
+ .badge.b{background:#1a3a5c;color:#7ec8e3}
237
+ .badge.g{background:#1a3c2a;color:#63e2b7}
238
+
239
+ .btn-text{background:none;border:none;color:#e06c75;cursor:pointer;font-size:13px;padding:4px 8px;border-radius:4px}
240
+ .btn-text:hover{background:rgba(224,108,117,.1)}
241
+
242
+ .editor-grid{display:grid;grid-template-columns:220px 1fr;gap:14px}
243
+ @media(max-width:700px){.editor-grid{grid-template-columns:1fr}}
244
+
245
+ .cover-box{width:100%;aspect-ratio:1;border-radius:8px;overflow:hidden;background:#12121a;border:1px dashed #2a2a3a;display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative}
246
+ .cover-box img{width:100%;height:100%;object-fit:cover}
247
+ .cover-ph{text-align:center;color:#444}
248
+ .cover-ph svg{width:40px;height:40px}
249
+ .cover-actions{display:flex;flex-direction:column;gap:6px;margin-top:10px}
250
+
251
+ .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
252
+ .form-grid .full{grid-column:1/-1}
253
+ .fg{display:flex;flex-direction:column;gap:3px}
254
+ .fg label{font-size:11px;color:#777;font-weight:500;text-transform:uppercase;letter-spacing:.3px}
255
+ .fg input,.fg textarea,.fg select{background:#12121a;border:1px solid #2a2a3a;border-radius:6px;padding:8px 10px;color:#e0e0e0;font-size:13px;outline:none;transition:border-color .2s;width:100%;font-family:inherit}
256
+ .fg input:focus,.fg textarea:focus{border-color:#63e2b7}
257
+ .fg textarea{resize:vertical;min-height:60px;font-size:13px}
258
+ .fg .lyrics-area{min-height:140px;font-family:'Courier New',monospace;font-size:12px;line-height:1.6}
259
+
260
+ .btn{padding:7px 18px;border-radius:6px;border:1px solid transparent;cursor:pointer;font-size:13px;font-weight:500;transition:all .2s;display:inline-flex;align-items:center;gap:5px}
261
+ .btn:disabled{opacity:.35;cursor:not-allowed}
262
+ .btn-p{background:#63e2b7;color:#0f0f13;border-color:#63e2b7}
263
+ .btn-p:hover:not(:disabled){background:#7aebb0}
264
+ .btn-s{background:#2a6b4a;color:#e0e0e0;border-color:#3a8a5a}
265
+ .btn-s:hover:not(:disabled){background:#3a8a5a}
266
+ .btn-g{background:transparent;color:#aaa;border-color:#3a3a4a}
267
+ .btn-g:hover:not(:disabled){border-color:#63e2b7;color:#63e2b7}
268
+ .btn-d{background:transparent;color:#e06c75;border-color:#5a2a2a}
269
+ .btn-d:hover:not(:disabled){background:rgba(224,108,117,.1)}
270
+ .btn-block{width:100%;justify-content:center}
271
+
272
+ .actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap}
273
+
274
+ .loading{text-align:center;padding:48px;color:#888}
275
+ .spinner{width:32px;height:32px;border:3px solid #2a2a3a;border-top-color:#63e2b7;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
276
+ @keyframes spin{to{transform:rotate(360deg)}}
277
+
278
+ .footer{text-align:center;padding:16px;color:#444;font-size:11px;border-top:1px solid #1a1a24;margin-top:20px}
279
+
280
+ .toast{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;font-size:13px;z-index:9999;animation:slideIn .3s ease;max-width:340px}
281
+ .toast.ok{background:#1a3c2a;color:#63e2b7;border:1px solid #2a5a3a}
282
+ .toast.err{background:#3c1a1a;color:#e06c75;border:1px solid #5a2a2a}
283
+ .toast.info{background:#1a2a3c;color:#7ec8e3;border:1px solid #2a3a5a}
284
+ @keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
285
+
286
+ .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.75);display:flex;align-items:center;justify-content:center;z-index:9998}
287
+ .modal-box{background:#1a1a24;border:1px solid #2a2a3a;border-radius:12px;padding:20px;max-width:500px;width:90%}
288
+ .modal-box img{width:100%;border-radius:8px}
289
+ .modal-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
290
+ .modal-hd h3{font-size:15px;color:#ccc}
291
+
292
+ input[type="file"]{display:none}
293
+
294
+ .tabs{display:flex;gap:0;margin-bottom:14px;border-bottom:1px solid #2a2a3a}
295
+ .tab{padding:8px 18px;cursor:pointer;font-size:13px;color:#888;border-bottom:2px solid transparent;transition:all .2s;user-select:none}
296
+ .tab:hover{color:#ccc}
297
+ .tab.active{color:#63e2b7;border-bottom-color:#63e2b7}
298
+ .tab-panel{display:none}
299
+ .tab-panel.active{display:block}
300
+
301
+ .filename-row{display:flex;gap:8px;align-items:flex-end}
302
+ .filename-row .fg{flex:1}
303
+ .filename-ext{color:#666;font-size:14px;padding:8px 0;white-space:nowrap}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  </style>
305
  </head>
306
  <body>
307
 
308
  <div class="header">
309
+ <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#63e2b7" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
310
  <h1>MP3 Tag Editor</h1>
311
+ <span>在线编辑 MP3 标签 / 歌词 / 封面 / 文件名</span>
312
  </div>
313
 
314
+ <div class="container">
315
 
316
  <!-- Upload -->
317
  <div class="card" id="uploadCard">
318
+ <div class="upload-zone" id="uploadZone" onclick="$('fileInput').click()">
 
319
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 16V4m0 0L8 8m4-4l4 4"/><path d="M20 16v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2"/></svg>
320
  <p class="main-text">点击或拖拽 MP3 文件到此处</p>
321
+ <p>支持 .mp3 格式</p>
322
  </div>
323
  <input type="file" id="fileInput" accept=".mp3">
324
  </div>
325
 
326
  <!-- Loading -->
327
+ <div class="card" id="loadingCard" style="display:none">
328
+ <div class="loading"><div class="spinner"></div><p>正在解析文件...</p></div>
 
 
 
329
  </div>
330
 
331
  <!-- Editor -->
332
+ <div id="editor" style="display:none">
333
 
334
+ <div class="card">
 
335
  <div class="file-bar">
336
  <div class="file-bar-left">
337
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#63e2b7" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
338
+ <span class="file-name" id="dispName"></span>
339
+ <span class="badge b" id="dispBit"></span>
340
+ <span class="badge g" id="dispSR"></span>
341
+ <span class="badge" id="dispDur"></span>
342
+ <span class="badge" id="dispCh"></span>
343
  </div>
344
+ <button class="btn-text" onclick="resetFile()">✕ 关闭</button>
345
  </div>
346
  </div>
347
 
348
  <div class="editor-grid">
349
+ <!-- Left -->
350
  <div>
351
  <div class="card">
352
+ <div class="card-title">封面</div>
353
+ <div class="cover-box" id="coverBox" onclick="previewCover()">
354
+ <img id="coverImg" style="display:none">
355
+ <div class="cover-ph" id="coverPH">
356
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
357
+ <p style="margin-top:6px;font-size:12px">无封面</p>
358
  </div>
359
  </div>
360
  <div class="cover-actions">
361
+ <button class="btn btn-g btn-block" onclick="$('coverInput').click()"><span id="coverBtnT">添加封面</span></button>
362
+ <button class="btn btn-d btn-block" id="rmCoverBtn" style="display:none" onclick="removeCover()">移除封面</button>
 
 
 
 
363
  </div>
364
  <input type="file" id="coverInput" accept="image/*">
365
  </div>
366
  </div>
367
 
368
+ <!-- Right -->
369
  <div>
370
  <div class="card">
371
+ <!-- Tabs -->
372
+ <div class="tabs">
373
+ <div class="tab active" onclick="switchTab('basic',this)">基本信息</div>
374
+ <div class="tab" onclick="switchTab('lyrics',this)">歌词</div>
375
+ <div class="tab" onclick="switchTab('file',this)">文件名</div>
376
+ </div>
377
+
378
+ <!-- Tab: Basic -->
379
+ <div class="tab-panel active" id="panel_basic">
380
+ <div class="form-grid">
381
+ <div class="fg full"><label>标题 Title</label><input id="t_title" placeholder="歌曲标题"></div>
382
+ <div class="fg"><label>艺术家 Artist</label><input id="t_artist" placeholder="表演者"></div>
383
+ <div class="fg"><label>专辑艺术家</label><input id="t_album_artist" placeholder="Album Artist"></div>
384
+ <div class="fg"><label>专辑 Album</label><input id="t_album" placeholder="专辑名称"></div>
385
+ <div class="fg"><label>年份 Year</label><input id="t_year" placeholder="发行年份"></div>
386
+ <div class="fg"><label>音轨 Track</label><input id="t_track" placeholder="1 或 1/12"></div>
387
+ <div class="fg"><label>碟片 Disc</label><input id="t_disc" placeholder="1 或 1/2"></div>
388
+ <div class="fg"><label>流派 Genre</label><input id="t_genre" placeholder="流派" list="gl">
389
+ <datalist id="gl">
390
+ <option value="Pop"><option value="Rock"><option value="Hip-Hop"><option value="R&B">
391
+ <option value="Jazz"><option value="Blues"><option value="Classical"><option value="Country">
392
+ <option value="Electronic"><option value="Dance"><option value="Folk"><option value="Indie">
393
+ <option value="Metal"><option value="Punk"><option value="Soul"><option value="Alternative">
394
+ <option value="Ambient"><option value="Funk"><option value="Latin"><option value="Soundtrack">
395
+ <option value="J-Pop"><option value="K-Pop"><option value="C-Pop"><option value="Anime">
396
+ <option value="Game"><option value="Instrumental"><option value="New Age"><option value="Reggae">
397
+ </datalist>
398
+ </div>
399
+ <div class="fg"><label>作曲 Composer</label><input id="t_composer" placeholder="作曲者"></div>
400
+ <div class="fg full"><label>注释 Comment</label><textarea id="t_comment" placeholder="注释信息"></textarea></div>
401
  </div>
402
+ </div>
403
+
404
+ <!-- Tab: Lyrics -->
405
+ <div class="tab-panel" id="panel_lyrics">
406
+ <div class="fg">
407
+ <label>歌词 Lyrics(支持 LRC 时间轴格式)</label>
408
+ <textarea id="t_lyrics" class="lyrics-area" placeholder="[00:00.00] 在此粘贴歌词...&#10;[00:05.20] 支持 LRC 格式&#10;&#10;也可以输入纯文本歌词"></textarea>
409
  </div>
410
+ <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
411
+ <button class="btn btn-g" onclick="clearLyrics()">清空歌词</button>
412
+ <label class="btn btn-g" style="cursor:pointer">
413
+ 导入 .lrc 文件
414
+ <input type="file" accept=".lrc,.txt" onchange="importLrc(event)" style="display:none">
415
+ </label>
416
+ <button class="btn btn-g" onclick="exportLrc()">导出 .lrc</button>
417
  </div>
418
+ </div>
419
+
420
+ <!-- Tab: Filename -->
421
+ <div class="tab-panel" id="panel_file">
422
+ <div class="fg" style="margin-bottom:12px">
423
+ <label>原始文件名</label>
424
+ <input id="origName" disabled style="opacity:.5">
 
 
 
 
 
 
 
425
  </div>
426
+ <div class="filename-row">
427
+ <div class="fg">
428
+ <label>新文件名(不含扩展名)</label>
429
+ <input id="t_filename" placeholder="输入新文件名">
430
+ </div>
431
+ <div class="filename-ext">.mp3</div>
432
  </div>
433
+ <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
434
+ <button class="btn btn-g" onclick="autoFilename('ta')" title="艺术家 - 标题">套用: 艺术家 - 标题</button>
435
+ <button class="btn btn-g" onclick="autoFilename('tta')" title="音轨. 标题 - 艺术家">套用: 音轨. 标题</button>
436
+ <button class="btn btn-g" onclick="autoFilename('t')" title="标题">套用: 标题</button>
437
  </div>
438
  </div>
439
  </div>
440
 
441
  <div class="actions">
442
+ <button class="btn btn-g" id="resetBtn" onclick="resetTags()">🔄 重置</button>
443
+ <button class="btn btn-p" id="saveBtn" onclick="saveTags()">💾 保存</button>
444
+ <button class="btn btn-s" id="dlBtn" onclick="downloadFile()" disabled>📥 下载</button>
445
  </div>
446
  </div>
447
  </div>
448
  </div>
449
  </div>
450
 
451
+ <div class="footer">MP3 Tag Editor · Mutagen + FastAPI · 文件仅临时存储于服务端内存</div>
 
 
452
 
453
  <!-- Cover Modal -->
454
+ <div class="modal-overlay" id="coverModal" style="display:none" onclick="if(event.target===this)this.style.display='none'">
455
+ <div class="modal-box" onclick="event.stopPropagation()">
456
+ <div class="modal-hd"><h3>封面预览</h3><button class="btn-text" onclick="$('coverModal').style.display='none'">✕</button></div>
 
 
 
457
  <img id="coverModalImg">
458
  </div>
459
  </div>
460
 
461
  <script>
462
+ const $=id=>document.getElementById(id);
463
+ const TAGS=['title','artist','album','album_artist','year','track','disc','genre','composer','comment'];
464
+ let info=null, origTags={}, coverFile=null, rmCover=false;
465
+
466
+ function toast(m,t='info'){const d=document.createElement('div');d.className='toast '+(t==='ok'?'ok':t==='err'?'err':'info');d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.remove(),300)},3000)}
467
+
468
+ // Tab switching
469
+ function switchTab(name,el){
470
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
471
+ document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
472
+ el.classList.add('active');
473
+ $('panel_'+name).classList.add('active');
 
 
 
474
  }
475
 
476
+ // Upload
477
+ const uz=$('uploadZone');
478
+ uz.addEventListener('dragover',e=>{e.preventDefault();uz.classList.add('dragover')});
479
+ uz.addEventListener('dragleave',()=>uz.classList.remove('dragover'));
480
+ uz.addEventListener('drop',e=>{e.preventDefault();uz.classList.remove('dragover');const f=e.dataTransfer.files;if(f.length&&f[0].name.toLowerCase().endsWith('.mp3'))upload(f[0]);else toast('请上传 .mp3 文件','err')});
481
+ $('fileInput').addEventListener('change',e=>{if(e.target.files.length)upload(e.target.files[0])});
482
+
483
+ $('coverInput').addEventListener('change',e=>{
484
+ if(!e.target.files.length)return;
485
+ coverFile=e.target.files[0];rmCover=false;
486
+ const r=new FileReader();
487
+ r.onload=ev=>{$('coverImg').src=ev.target.result;$('coverImg').style.display='block';$('coverPH').style.display='none';$('rmCoverBtn').style.display='block';$('coverBtnT').textContent='更换封面'};
488
+ r.readAsDataURL(coverFile);
489
  });
490
 
491
+ async function upload(file){
492
+ $('uploadCard').style.display='none';$('loadingCard').style.display='block';
493
+ const fd=new FormData();fd.append('file',file);
494
+ try{
495
+ const r=await fetch('/api/upload',{method:'POST',body:fd});
496
+ if(!r.ok){const e=await r.json();throw new Error(e.detail||'上传失败')}
497
+ info=await r.json();
498
+
499
+ TAGS.forEach(f=>{const el=$('t_'+f);if(el)el.value=info.tags[f]||''});
500
+ $('t_lyrics').value=info.tags.lyrics||'';
501
+ origTags={...info.tags};
502
+
503
+ $('dispName').textContent=info.filename;
504
+ $('dispBit').textContent=info.bitrate+' kbps';
505
+ $('dispSR').textContent=info.sample_rate+' Hz';
506
+ $('dispCh').textContent=info.channels+'ch';
507
+ const mm=Math.floor(info.duration/60),ss=Math.floor(info.duration%60);
508
+ $('dispDur').textContent=mm+':'+String(ss).padStart(2,'0');
509
+
510
+ $('origName').value=info.filename;
511
+ $('t_filename').value=info.filename_noext;
512
+
513
+ if(info.cover&&info.cover.data){$('coverImg').src=info.cover.data;$('coverImg').style.display='block';$('coverPH').style.display='none';$('rmCoverBtn').style.display='block';$('coverBtnT').textContent='更换封面'}
514
+ else{$('coverImg').style.display='none';$('coverPH').style.display='flex';$('rmCoverBtn').style.display='none';$('coverBtnT').textContent='添加封面'}
515
+
516
+ coverFile=null;rmCover=false;$('dlBtn').disabled=true;
517
+ $('loadingCard').style.display='none';$('editor').style.display='block';
518
+ toast('文件解析成功','ok');
519
+ }catch(e){$('loadingCard').style.display='none';$('uploadCard').style.display='block';toast(e.message,'err')}
520
+ }
521
 
522
+ function removeCover(){$('coverImg').style.display='none';$('coverPH').style.display='flex';$('rmCoverBtn').style.display='none';$('coverBtnT').textContent='添加封面';coverFile=null;rmCover=true}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
+ function resetTags(){
525
+ TAGS.forEach(f=>{const el=$('t_'+f);if(el)el.value=origTags[f]||''});
526
+ $('t_lyrics').value=origTags.lyrics||'';
527
+ $('t_filename').value=info.filename_noext;
528
+ if(info.cover&&info.cover.data){$('coverImg').src=info.cover.data;$('coverImg').style.display='block';$('coverPH').style.display='none';$('rmCoverBtn').style.display='block';$('coverBtnT').textContent='更换封面'}
529
+ else{$('coverImg').style.display='none';$('coverPH').style.display='flex';$('rmCoverBtn').style.display='none';$('coverBtnT').textContent='添加封面'}
530
+ coverFile=null;rmCover=false;toast('已重置','info');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  }
532
 
533
+ async function saveTags(){
534
+ if(!info)return;
535
+ $('saveBtn').disabled=true;$('saveBtn').textContent='⏳ 保存中...';
536
+ const fd=new FormData();
537
+ fd.append('file_id',info.file_id);
538
+ fd.append('new_filename',$('t_filename').value);
539
+ TAGS.forEach(f=>fd.append(f,$('t_'+f)?$('t_'+f).value:''));
540
+ fd.append('lyrics',$('t_lyrics').value);
541
+ fd.append('remove_cover',rmCover?'true':'false');
542
+ if(coverFile)fd.append('cover',coverFile);
543
+ try{
544
+ const r=await fetch('/api/save',{method:'POST',body:fd});
545
+ if(!r.ok){const e=await r.json();throw new Error(e.detail||'保存失败')}
546
+ const res=await r.json();
547
+ $('dlBtn').disabled=false;
548
+ if(res.filename){$('dispName').textContent=res.filename;info.original_name=res.filename}
549
+ toast('保存成功!可以下载了','ok');
550
+ }catch(e){toast(e.message,'err')}
551
+ finally{$('saveBtn').disabled=false;$('saveBtn').textContent='💾 保存'}
552
  }
553
 
554
+ function downloadFile(){if(!info)return;const a=document.createElement('a');a.href='/api/download/'+info.file_id;a.download=info.original_name||info.filename;a.click()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
 
556
+ async function resetFile(){
557
+ if(info){try{await fetch('/api/file/'+info.file_id,{method:'DELETE'})}catch{}}
558
+ info=null;coverFile=null;rmCover=false;
559
+ $('editor').style.display='none';$('uploadCard').style.display='block';
560
+ $('fileInput').value='';$('coverInput').value='';
561
+ TAGS.forEach(f=>{const el=$('t_'+f);if(el)el.value=''});
562
+ $('t_lyrics').value='';$('t_filename').value='';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  }
564
 
565
+ function previewCover(){if($('coverImg').style.display!=='none'){$('coverModalImg').src=$('coverImg').src;$('coverModal').style.display='flex'}}
566
+
567
+ // Lyrics helpers
568
+ function clearLyrics(){$('t_lyrics').value='';toast('歌词已清空','info')}
 
 
 
569
 
570
+ function importLrc(e){
571
+ const file=e.target.files[0];if(!file)return;
572
+ const r=new FileReader();
573
+ r.onload=ev=>{$('t_lyrics').value=ev.target.result;toast('歌词已导入','ok')};
574
+ r.readAsText(file,'utf-8');
575
+ e.target.value='';
 
 
 
 
 
 
576
  }
577
 
578
+ function exportLrc(){
579
+ const text=$('t_lyrics').value;if(!text.trim()){toast('没有歌词可导出','err');return}
580
+ const title=$('t_title').value||'lyrics';
581
+ const blob=new Blob([text],{type:'text/plain;charset=utf-8'});
582
+ const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=title+'.lrc';a.click();URL.revokeObjectURL(a.href);
583
  }
584
 
585
+ // Auto filename
586
+ function autoFilename(mode){
587
+ const t=$('t_title').value,a=$('t_artist').value,tr=$('t_track').value;
588
+ let name='';
589
+ if(mode==='ta')name=(a&&t)?a+' - '+t:(t||a||'');
590
+ else if(mode==='tta')name=(tr?tr.split('/')[0].padStart(2,'0')+'. ':'')+(t||'');
591
+ else if(mode==='t')name=t||'';
592
+ if(name)$('t_filename').value=name;
593
+ else toast('请先填写对应标签','err');
594
  }
595
  </script>
596
  </body>
597
+ </html>"""
 
598
 
599
 
600
  @app.get("/")
601
  async def index():
602
+ return HTMLResponse(content=HTML)