Integrate React frontend with backend API

This commit is contained in:
Koha9
2025-11-28 03:00:02 +09:00
parent 4c6af7115e
commit 8d358a1de2
14 changed files with 2608 additions and 150 deletions
+306 -36
View File
@@ -1,26 +1,38 @@
import os
from app.utils.config import server_config
from app.utils.playlist_merge import SyncMode, sync_all_playlists, TEST_PLAYLIST_DIR
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.utils.plex_client import plex_client
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, ConfigDict, Field
from app.utils.config import server_config
from app.utils.logger import logger
from app.utils.local_playlist import scan_local_playlists
from app.utils.playlist_merge import SyncMode, TEST_PLAYLIST_DIR, sync_all_playlists
from app.utils.plex_client import MUSIC_LIBRARY_TYPE, plex_client
BASE_DIR = Path(__file__).resolve().parent
FRONTEND_DIST = BASE_DIR / "static" / "frontend"
FRONTEND_INDEX = FRONTEND_DIST / "index.html"
DEFAULT_STATUS_INTERVAL = max(
15, int(os.getenv("STATUS_CHECK_INTERVAL_SECONDS", "45"))
)
app = FastAPI()
templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "templates")
)
templates = Jinja2Templates(directory=BASE_DIR / "templates")
# mount static files
# 这里的路径是相对于 main.py 文件所在的目录
app.mount(
"/static",
StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")),
name="static",
)
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
if (FRONTEND_DIST / "assets").exists():
app.mount(
"/assets",
StaticFiles(directory=FRONTEND_DIST / "assets"),
name="assets",
)
SYNC_MODE_OPTIONS = [
@@ -47,7 +59,70 @@ SYNC_MODE_OPTIONS = [
]
def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]:
class PlexConnectionSettings(BaseModel):
protocol: Literal["http", "https"] = Field("https", description="Connection protocol")
address: str = Field(..., description="Plex server domain or IP")
port: str = Field("32400", description="Plex server port")
token: str = ""
username: str | None = ""
password: str | None = ""
library: str | None = None
class RegexRule(BaseModel):
pattern: str
replacement: str = ""
model_config = ConfigDict(extra="ignore")
class StrategyPayload(BaseModel):
strategy: str
class LibrarySelection(BaseModel):
library: str
def _format_regex_rules(rules: list[dict]) -> list[dict]:
formatted: list[dict] = []
for idx, rule in enumerate(rules or []):
pattern = (rule.get("pattern") or "").strip()
replacement = rule.get("replacement") or ""
if not pattern:
continue
formatted.append(
{
"id": f"rule-{idx}",
"pattern": pattern,
"replacement": replacement,
}
)
return formatted
def _list_music_libraries() -> list[dict]:
libraries: list[dict] = []
if not plex_client.connected or not plex_client.server:
return libraries
try:
for lib in plex_client.server.library.sections():
if getattr(lib, "type", None) != MUSIC_LIBRARY_TYPE:
continue
libraries.append(
{
"id": getattr(lib, "uuid", getattr(lib, "key", lib.title)),
"title": lib.title,
"type": getattr(lib, "type", ""),
}
)
except Exception as exc:
logger.warning(f"Unable to fetch music library metadata: {exc}")
return libraries
def _get_cloud_playlists(
include_playlists: bool = True,
) -> tuple[list[dict], str, dict, str, list[str]]:
"""Fetch playlists and connection state from the remote Plex server."""
server_config.load()
@@ -89,24 +164,30 @@ def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]:
selected_library = music_libraries[0]
server_config.set_and_save_config(library_name=selected_library)
for playlist in plex_client.get_lib_playlists(selected_library) or []:
track_count = getattr(playlist, "itemCount", None)
if track_count is None:
try:
track_count = len(playlist.items())
except Exception:
track_count = 0
playlists.append(
{
"name": playlist.title,
"track_count": track_count,
}
)
if include_playlists:
for playlist in plex_client.get_lib_playlists(selected_library) or []:
track_count = getattr(playlist, "itemCount", None)
if track_count is None:
try:
track_count = len(playlist.items())
except Exception:
track_count = 0
playlists.append(
{
"name": playlist.title,
"track_count": track_count,
"rating_key": getattr(playlist, "ratingKey", playlist.title),
"last_modified": getattr(
getattr(playlist, "updatedAt", None), "isoformat", lambda: None
)(),
}
)
except Exception as exc:
logger.warning(f"Failed to fetch cloud playlists: {exc}")
status = "failed"
playlists.sort(key=lambda item: item["name"].lower())
if include_playlists:
playlists.sort(key=lambda item: item["name"].lower())
return playlists, status, server_info, selected_library, music_libraries
@@ -118,6 +199,7 @@ def _build_home_context(
sync_result: dict | None = None,
selected_mode: str | None = None,
):
local_path = local_path or server_config.local_playlist_dir
server_config.load()
local_playlists = scan_local_playlists(local_path)
(
@@ -148,16 +230,203 @@ def _build_home_context(
}
# 显示主页
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, local_path: str = "playlist"):
context = _build_home_context(request, local_path)
def _format_local_playlists_response(playlists: list[dict]) -> list[dict]:
formatted: list[dict] = []
for item in playlists:
formatted.append(
{
"id": f"local::{item.get('name', '')}",
"title": item.get("name", ""),
"trackCount": item.get("track_count", 0),
"lastUpdated": item.get("last_modified"),
"path": item.get("path"),
}
)
return formatted
def _format_cloud_playlists_response(playlists: list[dict]) -> list[dict]:
formatted: list[dict] = []
for item in playlists:
formatted.append(
{
"id": str(item.get("rating_key") or item.get("name")),
"title": item.get("name", ""),
"trackCount": item.get("track_count", 0),
"lastUpdated": item.get("last_modified"),
}
)
return formatted
# 显示前端(React 构建后的静态文件),若缺失则回退到旧版模板
@app.get("/", response_class=HTMLResponse)
async def serve_frontend(request: Request, local_path: str | None = None):
if FRONTEND_INDEX.exists():
return FileResponse(FRONTEND_INDEX)
context = _build_home_context(
request,
local_path or server_config.local_playlist_dir,
message="前端静态文件未构建,已回退到旧版页面。",
message_type="warning",
)
return templates.TemplateResponse("home.html", context)
@app.get("/api/ui-config")
async def get_ui_config():
"""Expose UI-related settings for the front-end."""
server_config.load()
return {
"status": "success",
"data": {
"statusCheckIntervalSeconds": DEFAULT_STATUS_INTERVAL,
"localPlaylistDir": server_config.local_playlist_dir,
},
}
@app.get("/api/settings")
async def get_settings():
server_config.load()
return {
"status": "success",
"data": {
"syncStrategy": server_config.sync_strategy,
"regexRules": _format_regex_rules(server_config.path_rules),
},
}
@app.post("/api/settings/strategy")
async def save_strategy(payload: StrategyPayload):
server_config.set_and_save_config(sync_strategy=payload.strategy)
return {"status": "success", "data": {"syncStrategy": payload.strategy}}
@app.get("/api/regex-rules")
async def get_regex_rules():
server_config.load()
return {"status": "success", "data": _format_regex_rules(server_config.path_rules)}
@app.post("/api/regex-rules")
async def save_regex_rules(rules: list[RegexRule]):
cleaned = [
{"pattern": rule.pattern.strip(), "replacement": rule.replacement or ""}
for rule in rules
if rule.pattern.strip()
]
server_config.set_and_save_config(path_rules=cleaned)
return {"status": "success", "data": _format_regex_rules(cleaned)}
@app.get("/api/playlists/{server_type}")
async def api_get_playlists(
server_type: Literal["local", "cloud"], local_path: str | None = None
):
if server_type == "local":
path = local_path or server_config.local_playlist_dir
server_config.set_and_save_config(local_playlist_dir=path)
playlists = scan_local_playlists(path)
return {"status": "success", "data": _format_local_playlists_response(playlists)}
playlists, status, _, _, _ = _get_cloud_playlists()
if status != "connected":
return {
"status": "error",
"message": "未连接到云端服务器,无法获取播放列表。",
"data": [],
}
return {"status": "success", "data": _format_cloud_playlists_response(playlists)}
@app.get("/api/server/status")
async def api_server_status():
_, status, server_info, selected_library, _ = _get_cloud_playlists(
include_playlists=False
)
is_connected = status == "connected"
libraries = _list_music_libraries() if is_connected else []
return {
"status": "success",
"data": {
"isConnected": is_connected,
"name": server_info.get("name"),
"ip": server_config.url or None,
"port": int(server_config.port) if server_config.port else None,
"libraryName": selected_library or None,
"libraries": libraries,
},
}
@app.post("/api/server/connect")
async def api_server_connect(payload: PlexConnectionSettings):
try:
_, token_success = plex_client.connect(
username=payload.username or "",
password=payload.password or "",
token=payload.token or "",
scheme=payload.protocol,
url=payload.address,
port=payload.port,
)
except Exception as exc:
return JSONResponse(
{
"status": "error",
"message": f"连接失败:{exc}",
"data": {"token": "", "serverInfo": {"isConnected": False}},
},
status_code=400,
)
library_names = plex_client.get_libs_name_list() or []
selected_library = payload.library if payload.library in library_names else ""
if not selected_library and library_names:
selected_library = library_names[0]
libraries = _list_music_libraries()
server_config.set_and_save_config(
token=token_success,
scheme=payload.protocol,
url=payload.address,
port=payload.port,
library_name=selected_library,
)
return {
"status": "success",
"message": "连接成功",
"data": {
"token": token_success,
"serverInfo": {
"isConnected": True,
"name": getattr(plex_client.server, "friendlyName", payload.address),
"ip": payload.address,
"port": int(payload.port) if payload.port else None,
"libraryName": selected_library,
"libraries": libraries,
},
},
}
@app.post("/api/server/library")
async def api_select_library(selection: LibrarySelection):
server_config.set_and_save_config(library_name=selection.library)
return {"status": "success", "data": {"libraryName": selection.library}}
@app.post("/sync", response_class=HTMLResponse)
async def trigger_sync(request: Request, mode: str = Form(...), local_path: str = Form("playlist")):
async def trigger_sync(
request: Request, mode: str = Form(...), local_path: str = Form(None)
):
local_path = local_path or server_config.local_playlist_dir
try:
sync_mode = SyncMode(mode)
except ValueError:
@@ -214,10 +483,11 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
@app.post("/path-rules", response_class=HTMLResponse)
async def save_path_rules(
request: Request,
local_path: str = Form("playlist"),
local_path: str = Form(None),
pattern: list[str] | None = Form(None),
replacement: list[str] | None = Form(None),
):
local_path = local_path or server_config.local_playlist_dir
patterns = pattern or []
replacements = replacement or []