Update app.py
Browse files
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
|
| 10 |
from mutagen.mp3 import MP3
|
| 11 |
from mutagen.id3 import (
|
| 12 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 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="
|
| 469 |
<h1>MP3 Tag Editor</h1>
|
| 470 |
-
<span>在线编辑 MP3
|
| 471 |
</div>
|
| 472 |
|
| 473 |
-
<div class="container"
|
| 474 |
|
| 475 |
<!-- Upload -->
|
| 476 |
<div class="card" id="uploadCard">
|
| 477 |
-
<div class="
|
| 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 格式
|
| 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="
|
| 496 |
|
| 497 |
-
<
|
| 498 |
-
<div class="card" id="fileBarCard">
|
| 499 |
<div class="file-bar">
|
| 500 |
<div class="file-bar-left">
|
| 501 |
-
<svg width="
|
| 502 |
-
<span class="file-name" id="
|
| 503 |
-
<span class="
|
| 504 |
-
<span class="
|
| 505 |
-
<span class="
|
|
|
|
| 506 |
</div>
|
| 507 |
-
<button class="btn-
|
| 508 |
</div>
|
| 509 |
</div>
|
| 510 |
|
| 511 |
<div class="editor-grid">
|
| 512 |
-
<!-- Left
|
| 513 |
<div>
|
| 514 |
<div class="card">
|
| 515 |
-
<div class="card-title">封面
|
| 516 |
-
<div class="cover-box" id="coverBox" onclick="
|
| 517 |
-
<img id="coverImg" style="display:none
|
| 518 |
-
<div class="cover-
|
| 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:
|
| 521 |
</div>
|
| 522 |
</div>
|
| 523 |
<div class="cover-actions">
|
| 524 |
-
<button class="btn btn-
|
| 525 |
-
|
| 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
|
| 536 |
<div>
|
| 537 |
<div class="card">
|
| 538 |
-
<
|
| 539 |
-
<div class="
|
| 540 |
-
<div class="
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
<
|
| 548 |
-
|
| 549 |
-
<label>
|
| 550 |
-
<
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
<label>
|
| 554 |
-
<
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
</div>
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
</div>
|
| 564 |
-
<div
|
| 565 |
-
<
|
| 566 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
</div>
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 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="
|
| 584 |
-
<
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
| 586 |
</div>
|
| 587 |
-
<div
|
| 588 |
-
<
|
| 589 |
-
<
|
|
|
|
| 590 |
</div>
|
| 591 |
</div>
|
| 592 |
</div>
|
| 593 |
|
| 594 |
<div class="actions">
|
| 595 |
-
<button class="btn btn-
|
| 596 |
-
<button class="btn btn-
|
| 597 |
-
<button class="btn btn-
|
| 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
|
| 610 |
-
<div class="modal-
|
| 611 |
-
<div class="modal-
|
| 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 |
-
|
| 621 |
-
|
| 622 |
-
let
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 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
|
| 638 |
-
const uz
|
| 639 |
-
uz.addEventListener('dragover',
|
| 640 |
-
uz.addEventListener('dragleave',
|
| 641 |
-
uz.addEventListener('drop',
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
if
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
});
|
| 651 |
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
|
| 656 |
-
$('
|
| 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 |
-
|
| 674 |
-
$('
|
| 675 |
-
$('
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 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
|
| 733 |
-
|
| 734 |
-
$('
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
}
|
| 740 |
|
| 741 |
-
function
|
| 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
|
| 766 |
-
if
|
| 767 |
-
|
| 768 |
-
$('
|
| 769 |
-
$('
|
| 770 |
-
|
| 771 |
-
|
| 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
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
a.download = fileInfo.filename;
|
| 805 |
-
a.click();
|
| 806 |
-
}
|
| 807 |
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
}
|
| 812 |
-
|
| 813 |
-
|
| 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
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
}
|
| 828 |
|
| 829 |
-
|
| 830 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
}
|
| 832 |
</script>
|
| 833 |
</body>
|
| 834 |
-
</html>
|
| 835 |
-
"""
|
| 836 |
|
| 837 |
|
| 838 |
@app.get("/")
|
| 839 |
async def index():
|
| 840 |
-
return HTMLResponse(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] 在此粘贴歌词... [00:05.20] 支持 LRC 格式 也可以输入纯文本歌词"></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)
|