import os import base64 import hashlib import uuid import asyncio import json import tempfile import shutil from typing import List, Optional from datetime import datetime from fastapi import APIRouter, UploadFile, File, Form from fastapi.responses import FileResponse, JSONResponse from huggingface_hub import hf_hub_download from pydantic import BaseModel from mutagen import File as MutagenFile from mutagen.id3 import ID3 from mutagen.mp4 import MP4 from core import get_db, save_db, _get_today_comment_count, api, REPO_ID, TOKEN, BasicReq, LikeReq, LikeReplyReq, CommentReq router = APIRouter() class CancelUploadReq(BaseModel): username: str song_name: str @router.post("/api/hub/upload_cancel") async def cancel_upload(req: CancelUploadReq): try: safe_song = req.song_name.replace("_", "-") prefix = f"data/{req.username}_" all_files = await asyncio.to_thread(api.list_repo_files, repo_id=REPO_ID, repo_type="dataset") for f in all_files: if f.startswith(prefix) and f"_{safe_song}." in f: await asyncio.to_thread(api.delete_file, path_in_repo=f, repo_id=REPO_ID, token=TOKEN) return {"status": "success"} except: return {"status": "error"} @router.post("/api/hub/upload_batch") async def upload_batch( username: str = Form(...), song_name: Optional[str] = Form(None), song_names: Optional[str] = Form(None), files: List[UploadFile] = File(...) ): uploaded_hf_paths = [] temp_files = [] try: audio_files = [f for f in files if f.filename.lower().endswith(('.mp3', '.wav', '.flac', '.ogg', '.aac', '.m4a'))] if not audio_files: return {"status": "error", "message": "必须包含音频"} names_mapping = [] if song_names: try: names_mapping = json.loads(song_names) except: names_mapping = [song_names] elif song_name: names_mapping = [song_name] primary_remote_path = "" is_all_duplicate = True # 跟踪当前批次是否触发了全部秒传 # 【注音引用:构建查重基准】一次性拉取整个云端文件目录,存储为内存集合(Set),后续查重将是 O(1) 的光速 existing_files = await asyncio.to_thread(api.list_repo_files, repo_id=REPO_ID, repo_type="dataset") existing_files_set = set(existing_files) for idx, audio_file in enumerate(audio_files): audio_content = await audio_file.read() # 【注音引用:指纹提取】通过 SHA256 哈希提取文件特征。只要文件大小和格式完全一致,生成的哈希就绝不改变 file_hash = hashlib.sha256(audio_content).hexdigest()[:16] if idx < len(names_mapping): current_song_name = names_mapping[idx] elif len(names_mapping) == 1: current_song_name = names_mapping[0] else: current_song_name = os.path.splitext(audio_file.filename)[0] safe_song_name = current_song_name.replace("_", "-") base_remote = f"data/{username}_{file_hash}_{safe_song_name}" audio_base = os.path.splitext(audio_file.filename)[0] associated_files = [audio_file] for f in files: if f != audio_file and os.path.splitext(f.filename)[0] == audio_base: associated_files.append(f) for f in associated_files: ext = os.path.splitext(f.filename)[1].lower() remote_path = base_remote + ext # 【注音引用:防重复核心】检查云端是否已经存在这首同名、同大小的歌曲 if remote_path in existing_files_set: if f == audio_file and not primary_remote_path: primary_remote_path = remote_path # 发现重复,直接 continue 跳过物理临时文件的创建和云端上传,节约带宽与时间 continue # 如果代码运行到这里,说明文件不存在,是新文件,重置全秒传标记 is_all_duplicate = False temp_path = f"temp_up_{uuid.uuid4().hex}{ext}" temp_files.append(temp_path) content = audio_content if f == audio_file else await f.read() with open(temp_path, "wb") as tmp: tmp.write(content) await asyncio.to_thread(api.upload_file, path_or_fileobj=temp_path, path_in_repo=remote_path, repo_id=REPO_ID, repo_type="dataset", token=TOKEN) uploaded_hf_paths.append(remote_path) if f == audio_file and not primary_remote_path: primary_remote_path = remote_path for tmp in temp_files: if os.path.exists(tmp): os.remove(tmp) # 【注音引用:骗过前端机制】无论是否跳过上传,都会返回 success 和路径。前端接收后会自动流转到“加歌单”逻辑 return {"status": "success", "remote_path": primary_remote_path, "is_duplicate": is_all_duplicate} except Exception as e: for path in uploaded_hf_paths: try: await asyncio.to_thread(api.delete_file, path_in_repo=path, repo_id=REPO_ID, token=TOKEN) except: pass for tmp in temp_files: if os.path.exists(tmp): os.remove(tmp) return {"status": "error", "message": f"上传异常: {str(e)}"} @router.post("/api/cloud_list") async def get_cloud_list(req: BasicReq): try: valid_exts = ('.mp3', '.wav', '.flac', '.ogg', '.aac', '.m4a') all_files = await asyncio.to_thread(api.list_repo_files, repo_id=REPO_ID, repo_type="dataset") audio_files = [f for f in all_files if f.lower().endswith(valid_exts)] return {"status": "success", "data": audio_files} except Exception as e: return {"status": "error", "message": str(e)} @router.get("/api/download_file") async def download_file(filename: str): try: path = await asyncio.to_thread(hf_hub_download, repo_id=REPO_ID, filename=filename, repo_type="dataset", token=TOKEN) return FileResponse(path, filename=os.path.basename(filename)) except Exception as e: return JSONResponse(status_code=404, content={"status": "error", "message": str(e)}) @router.post("/api/hub/interactions") async def get_interactions(req: BasicReq): try: return {"status": "success", "data": get_db("interaction.json")} except Exception as e: return {"status": "error", "message": str(e)} @router.post("/api/hub/like") async def add_like(req: LikeReq): try: db_data = get_db("interaction.json") if req.song_hash not in db_data or not isinstance(db_data[req.song_hash], dict): db_data[req.song_hash] = {"likes": 0, "liked_by": [], "comments": []} liked_by = db_data[req.song_hash].get("liked_by", []) if req.username in liked_by: liked_by.remove(req.username); db_data[req.song_hash]["likes"] = max(0, db_data[req.song_hash].get("likes", 1) - 1); action = "unliked" else: liked_by.append(req.username); db_data[req.song_hash]["likes"] = db_data[req.song_hash].get("likes", 0) + 1; action = "liked" db_data[req.song_hash]["liked_by"] = liked_by save_db(db_data, "interaction.json") return {"status": "success", "likes": db_data[req.song_hash]["likes"], "action": action} except Exception as e: return {"status": "error", "message": str(e)} @router.post("/api/hub/like_reply") async def like_reply(req: LikeReplyReq): try: db_data = get_db("interaction.json") target_likes = 0; action = ""; found = False for c in db_data.get(req.song_hash, {}).get("comments", []): if isinstance(c, dict): for r in c.get("replies", []): if isinstance(r, dict) and str(r.get("id")) == str(req.reply_id): if "liked_by" not in r: r["liked_by"] = [] if req.username in r["liked_by"]: r["liked_by"].remove(req.username); r["likes"] = max(0, r.get("likes", 1) - 1); action = "unliked" else: r["liked_by"].append(req.username); r["likes"] = r.get("likes", 0) + 1; action = "liked" target_likes = r["likes"]; found = True; break if found: break if not found: return {"status": "error", "message": "目标不存在"} save_db(db_data, "interaction.json") return {"status": "success", "likes": target_likes, "action": action} except Exception as e: return {"status": "error", "message": str(e)} @router.post("/api/hub/comment") async def add_comment(req: CommentReq): try: if len(req.content) > 99: return {"status": "error", "message": "评论超长!"} db_data = get_db("interaction.json") if _get_today_comment_count(db_data, req.username) >= 3: return {"status": "error", "message": "今日评论达上限!"} if req.song_hash not in db_data or not isinstance(db_data[req.song_hash], dict): db_data[req.song_hash] = {"likes": 0, "liked_by": [], "comments": []} new_item = {"id": uuid.uuid4().hex, "user": req.username, "text": req.content, "time": datetime.now().strftime("%Y-%m-%d %H:%M")} if req.parent_id or req.parent_user: new_item["likes"] = 0; new_item["liked_by"] = []; parent_found = False for c in db_data[req.song_hash].get("comments", []): if not isinstance(c, dict): continue if c.get("id") and str(c.get("id")) == str(req.parent_id): parent_found = True elif req.parent_user and c.get("user") == req.parent_user and c.get("text") == req.parent_text: c["id"] = str(req.parent_id) if req.parent_id else uuid.uuid4().hex; parent_found = True if parent_found: if "replies" not in c: c["replies"] = [] c["replies"].append(new_item); break if not parent_found: return {"status": "error", "message": "原评论不存在"} else: new_item["replies"] = [] if "comments" not in db_data[req.song_hash]: db_data[req.song_hash]["comments"] = [] db_data[req.song_hash]["comments"].append(new_item) save_db(db_data, "interaction.json") return {"status": "success", "comment": new_item} except Exception as e: return {"status": "error", "message": str(e)} @router.get("/api/audio_metadata") async def extract_audio_metadata(filename: str): temp_dir = None try: temp_dir = tempfile.mkdtemp() filepath = await asyncio.to_thread( hf_hub_download, repo_id=REPO_ID, filename=filename, repo_type="dataset", token=TOKEN, local_dir=temp_dir ) result = { "status": "success", "embedded_cover": "", "embedded_lyrics": "", "title": "", "artist": "", "album": "", "duration": None } audio = MutagenFile(filepath) if audio is None: return {"status": "error", "message": "无法解析音频文件"} if audio.info: result["duration"] = round(audio.info.length, 2) ext = os.path.splitext(filepath)[1].lower() if ext == ".mp3": try: id3 = ID3(filepath) result["title"] = str(id3.get("TIT2", "")) or "" result["artist"] = str(id3.get("TPE1", "")) or "" result["album"] = str(id3.get("TALB", "")) or "" for key, val in id3.items(): if key.startswith("USLT"): result["embedded_lyrics"] = str(val) break for key, val in id3.items(): if key.startswith("APIC"): cover_data = val.data mime = val.mime or "image/jpeg" result["embedded_cover"] = f"data:{mime};base64,{base64.b64encode(cover_data).decode()}" break except Exception as e: print(f"[ID3提取] {filepath}: {e}") elif ext == ".flac": tags = audio.tags or {} result["title"] = (tags.get("title") or [""])[0] or "" result["artist"] = (tags.get("artist") or [""])[0] or "" result["album"] = (tags.get("album") or [""])[0] or "" lyrics_list = tags.get("lyrics") or tags.get("LYRICS") result["embedded_lyrics"] = lyrics_list[0] if lyrics_list else "" if hasattr(audio, 'pictures') and audio.pictures: pic = audio.pictures[0] mime = pic.mime or "image/jpeg" result["embedded_cover"] = f"data:{mime};base64,{base64.b64encode(pic.data).decode()}" elif ext in [".ogg", ".oga"]: tags = audio.tags or {} result["title"] = (tags.get("title") or [""])[0] or "" result["artist"] = (tags.get("artist") or [""])[0] or "" result["album"] = (tags.get("album") or [""])[0] or "" lyrics_list = tags.get("lyrics") or tags.get("LYRICS") result["embedded_lyrics"] = lyrics_list[0] if lyrics_list else "" elif ext in [".m4a", ".aac"]: if isinstance(audio, MP4): if audio.tags: result["title"] = (audio.tags.get("\xa9nam") or [""])[0] or "" result["artist"] = (audio.tags.get("\xa9ART") or [""])[0] or "" result["album"] = (audio.tags.get("\xa9alb") or [""])[0] or "" if "covr" in audio.tags: cover = bytes(audio.tags["covr"][0]) result["embedded_cover"] = f"data:image/jpeg;base64,{base64.b64encode(cover).decode()}" if "\xa9lyr" in audio.tags: result["embedded_lyrics"] = audio.tags["\xa9lyr"][0] return result except Exception as e: return {"status": "error", "message": str(e)} finally: if temp_dir and os.path.exists(temp_dir): shutil.rmtree(temp_dir, ignore_errors=True)