1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2026-03-15 08:13:16 +08:00
Files
xiaomusic/xiaomusic/api/routers/file.py
2026-01-15 14:24:51 +08:00

382 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""文件操作路由"""
import asyncio
import base64
import os
import shutil
from urllib.parse import urlparse
import aiohttp
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import (
FileResponse,
RedirectResponse,
Response,
StreamingResponse,
)
from starlette.background import BackgroundTask
from xiaomusic.api.dependencies import (
access_key_verification,
config,
log,
verification,
xiaomusic,
)
from xiaomusic.api.models import (
DownloadOneMusic,
DownloadPlayList,
UrlInfo,
)
from xiaomusic.utils.file_utils import (
chmoddir,
clean_temp_dir,
remove_common_prefix,
safe_join_path,
)
from xiaomusic.utils.music_utils import convert_file_to_mp3, is_mp3, remove_id3_tags
from xiaomusic.utils.network_utils import (
check_bili_fav_list,
download_one_music,
download_playlist,
downloadfile,
)
from xiaomusic.utils.system_utils import try_add_access_control_param
router = APIRouter()
@router.post("/api/file/cleantempdir")
async def cleantempdir(Verifcation=Depends(verification)):
await clean_temp_dir(xiaomusic.config)
log.info("clean_temp_dir ok")
return {"ret": "OK"}
@router.post("/downloadjson")
async def downloadjson(data: UrlInfo, Verifcation=Depends(verification)):
"""下载 JSON"""
log.info(data)
url = data.url
content = ""
try:
ret = "OK"
content = await downloadfile(url)
except Exception as e:
log.exception(f"Execption {e}")
ret = "Download JSON file failed."
return {
"ret": ret,
"content": content,
}
@router.post("/downloadplaylist")
async def downloadplaylist(data: DownloadPlayList, Verifcation=Depends(verification)):
"""下载歌单"""
try:
bili_fav_list = await check_bili_fav_list(data.url)
download_proc_list = []
if bili_fav_list:
for bvid, title in bili_fav_list.items():
bvurl = f"https://www.bilibili.com/video/{bvid}"
download_proc_list[title] = await download_one_music(
config, bvurl, os.path.join(data.dirname, title)
)
for title, download_proc_sigle in download_proc_list.items():
exit_code = await download_proc_sigle.wait()
log.info(f"Download completed {title} with exit code {exit_code}")
dir_path = safe_join_path(config.download_path, data.dirname)
log.debug(f"Download dir_path: {dir_path}")
# 可能只是部分失败,都需要整理下载目录
remove_common_prefix(dir_path)
chmoddir(dir_path)
return {"ret": "OK"}
else:
download_proc = await download_playlist(config, data.url, data.dirname)
async def check_download_proc():
# 等待子进程完成
exit_code = await download_proc.wait()
log.info(f"Download completed with exit code {exit_code}")
dir_path = safe_join_path(config.download_path, data.dirname)
log.debug(f"Download dir_path: {dir_path}")
# 可能只是部分失败,都需要整理下载目录
remove_common_prefix(dir_path)
chmoddir(dir_path)
asyncio.create_task(check_download_proc())
return {"ret": "OK"}
except Exception as e:
log.exception(f"Execption {e}")
return {"ret": "Failed download"}
@router.post("/downloadonemusic")
async def downloadonemusic(data: DownloadOneMusic, Verifcation=Depends(verification)):
"""下载单首歌曲"""
try:
download_proc = await download_one_music(config, data.url, data.name)
async def check_download_proc():
# 等待子进程完成
exit_code = await download_proc.wait()
log.info(f"Download completed with exit code {exit_code}")
chmoddir(config.download_path)
asyncio.create_task(check_download_proc())
return {"ret": "OK"}
except Exception as e:
log.exception(f"Execption {e}")
return {"ret": "Failed download"}
@router.post("/uploadytdlpcookie")
async def upload_yt_dlp_cookie(file: UploadFile = File(...)):
"""上传 yt-dlp cookies"""
with open(config.yt_dlp_cookies_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return {
"ret": "OK",
"filename": file.filename,
"file_location": config.yt_dlp_cookies_path,
}
@router.post("/uploadmusic")
async def upload_music(playlist: str = Form(...), file: UploadFile = File(...)):
"""上传音乐文件到当前播放列表对应的目录"""
try:
# 选择目标目录:优先尝试由播放列表中已有歌曲推断目录
dest_dir = xiaomusic.music_path
# 特殊歌单映射
if playlist == "下载":
dest_dir = xiaomusic.download_path
elif playlist == "其他":
dest_dir = xiaomusic.music_path
else:
# 如果播放列表中存在歌曲,从其中任意一首推断目录
musics = xiaomusic.music_list.get(playlist, [])
if musics and len(musics) > 0:
first = musics[0]
filepath = xiaomusic._music_library.all_music.get(first, "")
if filepath:
dest_dir = os.path.dirname(filepath)
# 确保目录存在
if not os.path.exists(dest_dir):
os.makedirs(dest_dir, exist_ok=True)
# 保存文件,避免路径穿越
filename = os.path.basename(file.filename)
if filename == "":
raise HTTPException(status_code=400, detail="Invalid filename")
dest_path = os.path.join(dest_dir, filename)
# 避免覆盖已有文件,简单地添加序号后缀
base, ext = os.path.splitext(filename)
counter = 1
while os.path.exists(dest_path):
filename = f"{base}_{counter}{ext}"
dest_path = os.path.join(dest_dir, filename)
counter += 1
with open(dest_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# 修复权限并刷新列表索引
try:
chmoddir(dest_dir)
except Exception:
pass
# 重新生成音乐列表索引
try:
xiaomusic._music_library.gen_all_music_list()
except Exception:
pass
return {"ret": "OK", "filename": filename}
except HTTPException:
raise
except Exception as e:
log.exception(f"upload music failed: {e}")
raise HTTPException(status_code=500, detail="Upload failed") from e
def safe_redirect(url):
"""安全重定向"""
url = try_add_access_control_param(config, url)
url = url.replace("\\", "")
if not urlparse(url).netloc and not urlparse(url).scheme:
log.debug(f"redirect to {url}")
return RedirectResponse(url=url)
return None
@router.get("/music/{file_path:path}")
async def music_file(request: Request, file_path: str, key: str = "", code: str = ""):
"""音乐文件访问"""
if not access_key_verification(f"/music/{file_path}", key, code):
raise HTTPException(status_code=404, detail="File not found")
absolute_path = os.path.abspath(config.music_path)
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
if not absolute_file_path.startswith(absolute_path):
raise HTTPException(status_code=404, detail="File not found")
if not os.path.exists(absolute_file_path):
raise HTTPException(status_code=404, detail="File not found")
# 移除MP3 ID3 v2标签和填充
if config.remove_id3tag and is_mp3(file_path):
log.info(f"remove_id3tag:{config.remove_id3tag}, is_mp3:True ")
temp_mp3_file = remove_id3_tags(absolute_file_path, config)
if temp_mp3_file:
log.info(f"ID3 tag removed {absolute_file_path} to {temp_mp3_file}")
redirect = safe_redirect(f"/music/{temp_mp3_file}")
if redirect:
return redirect
else:
log.info(f"No ID3 tag remove needed: {absolute_file_path}")
if config.convert_to_mp3 and not is_mp3(file_path):
temp_mp3_file = convert_file_to_mp3(absolute_file_path, config)
if temp_mp3_file:
log.info(f"Converted file: {absolute_file_path} to {temp_mp3_file}")
redirect = safe_redirect(f"/music/{temp_mp3_file}")
if redirect:
return redirect
else:
log.warning(f"Failed to convert file to MP3 format: {absolute_file_path}")
return FileResponse(absolute_file_path)
@router.options("/music/{file_path:path}")
async def music_options():
"""音乐文件 OPTIONS"""
headers = {
"Accept-Ranges": "bytes",
}
return Response(headers=headers)
@router.get("/picture/{file_path:path}")
async def get_picture(request: Request, file_path: str, key: str = "", code: str = ""):
"""图片文件访问"""
if not access_key_verification(f"/picture/{file_path}", key, code):
raise HTTPException(status_code=404, detail="File not found")
absolute_path = os.path.abspath(config.picture_cache_path)
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
if not absolute_file_path.startswith(absolute_path):
raise HTTPException(status_code=404, detail="File not found")
if not os.path.exists(absolute_file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(absolute_file_path)
@router.get("/proxy", summary="基于正常下载逻辑的代理接口")
async def proxy(urlb64: str):
"""代理接口"""
try:
# 将Base64编码的URL解码为字符串
url_bytes = base64.b64decode(urlb64)
url = url_bytes.decode("utf-8")
print(f"解码后的代理请求: {url}")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Base64解码失败: {str(e)}") from e
log.info(f"代理请求: {url}")
parsed_url = urlparse(url)
if not parsed_url.scheme or not parsed_url.netloc:
# Fixed: Use a new exception instance since 'e' from previous block is out of scope
invalid_url_exc = ValueError("URL缺少协议或域名")
raise HTTPException(
status_code=400, detail="无效的URL格式"
) from invalid_url_exc
# 创建会话并确保关闭
session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=600),
connector=aiohttp.TCPConnector(ssl=True),
)
# 复用经过验证的请求头配置
def gen_headers(parsed_url):
headers = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"cache-control": "no-cache",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
}
return headers
async def close_session():
if not session.closed:
await session.close()
try:
# 复用download_file中的请求逻辑
headers = gen_headers(parsed_url)
resp = await session.get(url, headers=headers, allow_redirects=True)
log.info(f"proxy status: {resp.status}")
if resp.status not in (200, 206):
await close_session()
status_exc = ValueError(f"服务器返回状态码: {resp.status}")
raise HTTPException(
status_code=resp.status, detail=f"下载失败,状态码: {resp.status}"
) from status_exc
# 流式生成器与download_file的分块逻辑一致
async def stream_generator():
try:
async for data in resp.content.iter_chunked(4096):
yield data
finally:
await close_session()
# 提取文件名
filename = parsed_url.path.split("/")[-1].split("?")[0] or "output.mp3"
return StreamingResponse(
stream_generator(),
media_type=resp.headers.get("Content-Type", "audio/mpeg"),
headers={"Content-Disposition": f'inline; filename="{filename}"'},
background=BackgroundTask(close_session),
)
except aiohttp.ClientConnectionError as e:
await close_session()
raise HTTPException(status_code=502, detail=f"连接错误: {str(e)}") from e
except asyncio.TimeoutError as e:
await close_session()
raise HTTPException(status_code=504, detail="下载超时") from e
except Exception as e:
await close_session()
raise HTTPException(status_code=500, detail=f"发生错误: {str(e)}") from e