Flying-Music-API / api_playlist.py
ZHIWEI666's picture
新增排行榜
a10d0c2 verified
Raw History Blame Contribute Delete
15.7 kB
import os
import uuid
import time
import asyncio
import tempfile
import logging
from fastapi import APIRouter, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
from huggingface_hub import upload_file, hf_hub_download
from core import get_db, save_db, check_write_auth, is_admin, api, REPO_ID, TOKEN, PlaylistAddReq, PlaylistBatchAddReq, PlaylistDeleteReq, PlaylistLikeReq, PlaylistCommentReq
logger = logging.getLogger("飞行音乐云端")
router = APIRouter()
IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.gif', '.webp')
MAX_IMAGE_SIZE = 10 * 1024 * 1024
READ_CHUNK = 1024 * 1024
async def _save_upload_cover(image, temp_dir):
"""分块保存封面图片到临时目录,返回 (本地路径, 扩展名);超限抛 ValueError"""
ext = os.path.splitext(image.filename)[1].lower()
if ext not in IMAGE_EXTS:
raise ValueError("不支持的图片格式")
fd, temp_path = tempfile.mkstemp(suffix=ext, dir=temp_dir)
written = 0
with os.fdopen(fd, "wb") as f:
while True:
chunk = await image.read(READ_CHUNK)
if not chunk:
break
written += len(chunk)
if written > MAX_IMAGE_SIZE:
raise ValueError("图片过大(超过10MB)")
f.write(chunk)
return temp_path, ext
@router.get("/api/cloud_pl/get")
async def get_cloud_playlists():
db = get_db("cloud_playlists.json")
# 🌟 网络歌单统一收口 admin 区:存量 users 区数据一次性并入(同名自动改名避让),迁移后 users 区清空
users_area = db.get("users") or {}
if users_area:
admin_area = db.setdefault("admin", {})
for user, pls in users_area.items():
if not isinstance(pls, dict): continue
for name, pl in pls.items():
if not isinstance(pl, dict): continue
final_name = name
suffix = 2
while final_name in admin_area:
final_name = f"{name}({user}{suffix if suffix > 2 else ''})"
suffix += 1
pl.setdefault("creator", user)
admin_area[final_name] = pl
db["users"] = {}
save_db(db, "cloud_playlists.json")
return {"status": "success", "data": db}
@router.post("/api/cloud_pl/create")
async def create_cloud_playlist(username: str = Form(...), type: str = Form(...), name: str = Form(...), description: str = Form(""), image: UploadFile = File(None), token: str = Form("")):
ok, reason = check_write_auth({"username": username, "token": token})
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
if "admin" not in db: db["admin"] = {}
if "users" not in db: db["users"] = {}
# 🌟 分类歌单取消后,admin 区即网络歌单统一存储区:任何已登录用户均可创建,数量不设上限;
# type=="user" 仅为旧客户端兼容保留(仍有 3 个上限)
if type == "admin":
target_dict = db["admin"]
else:
target_dict = db["users"].setdefault(username, {})
if len(target_dict) >= 3: return {"status": "error", "message": "上限3个"}
if name in target_dict: return {"status": "error", "message": "歌单已存在"}
cover_name = ""
if image and image.filename:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path, ext = await _save_upload_cover(image, temp_dir)
cover_name = f"pl_{uuid.uuid4().hex}{ext}"
await asyncio.to_thread(upload_file, path_or_fileobj=temp_path, path_in_repo=f"community_images/{cover_name}", repo_id=REPO_ID, repo_type="dataset", token=TOKEN)
target_dict[name] = {
"description": description[:100], "cover": cover_name, "songs": [], "likes": 0, "liked_by": [], "comments": [],
"creator": username # admin 区扁平存储后靠 creator 保留归属,供编辑/删除鉴权与前端展示
}
save_db(db, "cloud_playlists.json")
return {"status": "success"}
except ValueError as e:
return {"status": "error", "message": str(e)}
except Exception as e: return {"status": "error", "message": str(e)}
@router.post("/api/cloud_pl/edit")
async def edit_cloud_playlist(username: str = Form(...), type: str = Form(...), old_name: str = Form(...), name: str = Form(...), description: str = Form(""), image: UploadFile = File(None), token: str = Form("")):
ok, reason = check_write_auth({"username": username, "token": token})
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
target_dict = db.get("admin", {}) if type == "admin" else db.get("users", {}).get(username, {})
if old_name not in target_dict: return {"status": "error", "message": "歌单不存在"}
# admin 区归属校验:创建者本人或管理员(无 creator 的存量歌单仅管理员可改)
if type == "admin":
creator = target_dict[old_name].get("creator") if isinstance(target_dict[old_name], dict) else None
if creator != username and not is_admin(username): return {"status": "error", "message": "权限不足"}
pl_data = target_dict.pop(old_name)
if image and image.filename:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path, ext = await _save_upload_cover(image, temp_dir)
cover_name = f"pl_{uuid.uuid4().hex}{ext}"
await asyncio.to_thread(upload_file, path_or_fileobj=temp_path, path_in_repo=f"community_images/{cover_name}", repo_id=REPO_ID, repo_type="dataset", token=TOKEN)
if pl_data.get("cover"):
try: asyncio.create_task(asyncio.to_thread(api.delete_file, path_in_repo=f"community_images/{pl_data['cover']}", repo_id=REPO_ID, token=TOKEN))
except Exception: pass
pl_data["cover"] = cover_name
pl_data["description"] = description[:100]
target_dict[name] = pl_data
save_db(db, "cloud_playlists.json")
return {"status": "success"}
except ValueError as e:
return {"status": "error", "message": str(e)}
except Exception as e: return {"status": "error", "message": str(e)}
@router.post("/api/cloud_pl/delete")
async def delete_cloud_playlist(req: PlaylistDeleteReq):
ok, reason = check_write_auth(req.dict())
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
target_dict = db.get("admin", {}) if req.type == "admin" else db.get("users", {}).get(req.username, {})
if req.name not in target_dict: return {"status": "error", "message": "不存在或无权限"}
# admin 区归属校验:创建者本人或管理员(无 creator 的存量歌单仅管理员可删)
if req.type == "admin":
creator = target_dict[req.name].get("creator") if isinstance(target_dict[req.name], dict) else None
if creator != req.username and not is_admin(req.username): return {"status": "error", "message": "权限不足"}
pl_data = target_dict.pop(req.name)
if pl_data.get("cover"):
try: asyncio.create_task(asyncio.to_thread(api.delete_file, path_in_repo=f"community_images/{pl_data['cover']}", repo_id=REPO_ID, token=TOKEN))
except Exception: pass
save_db(db, "cloud_playlists.json")
return {"status": "success"}
except Exception as e: return {"status": "error", "message": str(e)}
@router.post("/api/cloud_pl/add")
async def add_to_cloud_playlist(req: PlaylistAddReq):
ok, reason = check_write_auth(req.dict())
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
if req.type == "admin":
# admin 区即公共网络歌单区:任何已登录用户都可加歌(与旧 users 区语义一致)
target_dict = db.get("admin", {})
else:
# 使用 author 定位歌单所有者,支持所有已登录用户往任意用户歌单添加歌曲
author = req.author if req.author else req.username
target_dict = db.get("users", {}).get(author, {})
if req.playlist_name not in target_dict: return {"status": "error", "message": "歌单不存在"}
songs = target_dict[req.playlist_name].get("songs", [])
if req.filename in songs: return {"status": "error", "message": "歌曲已存在于歌单中"}
songs.append(req.filename)
target_dict[req.playlist_name]["songs"] = songs
save_db(db, "cloud_playlists.json")
return {"status": "success"}
except Exception as e: return {"status": "error", "message": str(e)}
# 🌟 批量添加歌曲到歌单接口
@router.post("/api/cloud_pl/batch_add")
async def batch_add_to_cloud_playlist(req: PlaylistBatchAddReq):
"""批量添加歌曲到歌单(一次请求处理多首)"""
ok, reason = check_write_auth(req.dict())
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
if req.type == "admin":
# admin 区即公共网络歌单区:任何已登录用户都可批量加歌(与旧 users 区语义一致)
target_dict = db.get("admin", {})
else:
author = req.author if req.author else req.username
target_dict = db.get("users", {}).get(author, {})
if req.playlist_name not in target_dict:
return {"status": "error", "message": "歌单不存在"}
songs = target_dict[req.playlist_name].get("songs", [])
existing = set(songs)
added_count = 0
for filename in req.filenames:
if filename not in existing:
songs.append(filename)
existing.add(filename)
added_count += 1
target_dict[req.playlist_name]["songs"] = songs
save_db(db, "cloud_playlists.json")
return {"status": "success", "added_count": added_count, "total": len(songs)}
except Exception as e:
return {"status": "error", "message": str(e)}
# 🌟 云端歌单移除单曲接口
@router.post("/api/cloud_pl/remove_song")
async def remove_from_cloud_playlist(req: PlaylistAddReq):
ok, reason = check_write_auth(req.dict())
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
target_dict = db.get("admin", {}) if req.type == "admin" else db.get("users", {}).get(req.username, {})
if req.playlist_name not in target_dict: return {"status": "error", "message": "歌单不存在"}
# admin 区移歌权限:创建者本人或管理员(无 creator 的存量歌单仅管理员可移)
if req.type == "admin":
creator = target_dict[req.playlist_name].get("creator") if isinstance(target_dict[req.playlist_name], dict) else None
if creator != req.username and not is_admin(req.username): return {"status": "error", "message": "权限不足"}
songs = target_dict[req.playlist_name].get("songs", [])
if req.filename in songs:
songs.remove(req.filename)
target_dict[req.playlist_name]["songs"] = songs
save_db(db, "cloud_playlists.json")
return {"status": "success"}
return {"status": "error", "message": "未在歌单中找到此歌曲"}
except Exception as e: return {"status": "error", "message": str(e)}
@router.post("/api/cloud_pl/like")
async def like_cloud_playlist(req: PlaylistLikeReq):
ok, reason = check_write_auth(req.dict())
if not ok:
return {"status": "error", "message": reason}
try:
db = get_db("cloud_playlists.json")
target_dict = db.get("admin", {}) if req.type == "admin" else db.get("users", {}).get(req.author, {})
pl = target_dict.get(req.name)
if not pl or isinstance(pl, list): return {"status": "error", "message": "格式异常或不存在"}
liked_by = pl.get("liked_by", [])
if req.username in liked_by:
liked_by.remove(req.username)
pl["likes"] = max(0, pl.get("likes", 1) - 1)
else:
liked_by.append(req.username)
pl["likes"] = pl.get("likes", 0) + 1
pl["liked_by"] = liked_by
save_db(db, "cloud_playlists.json")
return {"status": "success"}
except Exception as e: return {"status": "error", "message": str(e)}
@router.post("/api/cloud_pl/comment")
async def comment_cloud_playlist(req: PlaylistCommentReq):
ok, reason = check_write_auth(req.dict())
if not ok:
return {"status": "error", "message": reason}
try:
if len(req.content) > 99: return {"status": "error", "message": "评论过长"}
db = get_db("cloud_playlists.json")
target_dict = db.get("admin", {}) if req.type == "admin" else db.get("users", {}).get(req.author, {})
pl = target_dict.get(req.name)
if not pl or isinstance(pl, list): return {"status": "error", "message": "格式异常或不存在"}
if "comments" not in pl: pl["comments"] = []
new_comment = {"id": uuid.uuid4().hex, "user": req.username, "text": req.content, "time": int(time.time() * 1000)}
if req.parent_id:
parent_found = False
for c in pl["comments"]:
if c.get("id") == req.parent_id:
if "replies" not in c: c["replies"] = []
c["replies"].append(new_comment)
parent_found = True; break
if not parent_found: return {"status": "error", "message": "原评论不存在"}
else:
pl["comments"].append(new_comment)
save_db(db, "cloud_playlists.json")
return {"status": "success"}
except Exception as e: return {"status": "error", "message": str(e)}
# ==========================================
# 图片取回端点(自 api_share.py 迁入)
# 经验分享模块下线后,community_images/ 目录仍存放歌单封面,
# 路由路径保持 /api/share/image 不变以兼容存量封面数据与客户端旧地址
# ==========================================
def _safe_image_name(name):
"""图片名白名单校验:basename 化、禁止穿越、限定图片扩展名"""
if not name:
return None
name = os.path.basename(str(name))
if ".." in name or not name or name.startswith("."):
return None
if not name.lower().endswith(IMAGE_EXTS):
return None
return name
@router.get("/api/share/image")
async def get_playlist_cover_image(name: str):
safe_name = _safe_image_name(name)
if not safe_name:
return JSONResponse(status_code=403, content={"status": "error", "message": "非法的图片路径"})
try:
path = await asyncio.to_thread(hf_hub_download, repo_id=REPO_ID, filename=f"community_images/{safe_name}", repo_type="dataset", token=TOKEN)
return FileResponse(path)
except Exception: return {"status": "error"}