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
+5
View File
@@ -29,3 +29,8 @@ docker compose up --build
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080` - 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。 - 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。 - 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
## 前端构建与配置
- `sample-front-end` 中提供了新的 React UI,运行 `npm install && npm run build` 会将打包产物输出到 `app/static/frontend`API 直接调用 FastAPI 后端。
- 环境变量 `STATUS_CHECK_INTERVAL_SECONDS` 用于控制前端自动刷新云端连接状态的轮询间隔(默认 45 秒,最小 15 秒),用于避免频繁请求导致的循环异常。
+6 -3
View File
@@ -2,7 +2,10 @@
"theme": "auto", "theme": "auto",
"token": "", "token": "",
"server_url": "", "server_url": "",
"server_port": "", "server_port": "32400",
"server_scheme": "", "server_scheme": "https",
"path_rules": [] "library_name": "",
"path_rules": [],
"local_playlist_dir": "playlist",
"sync_strategy": "LOCAL_OVERWRITE"
} }
+306 -36
View File
@@ -1,26 +1,38 @@
import os import os
from app.utils.config import server_config from pathlib import Path
from app.utils.playlist_merge import SyncMode, sync_all_playlists, TEST_PLAYLIST_DIR from typing import Literal
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi import FastAPI, Form, Request
from fastapi.templating import Jinja2Templates from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles 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.logger import logger
from app.utils.local_playlist import scan_local_playlists 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() app = FastAPI()
templates = Jinja2Templates( templates = Jinja2Templates(directory=BASE_DIR / "templates")
directory=os.path.join(os.path.dirname(__file__), "templates")
)
# mount static files # mount static files
# 这里的路径是相对于 main.py 文件所在的目录 app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
app.mount(
"/static", if (FRONTEND_DIST / "assets").exists():
StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), app.mount(
name="static", "/assets",
) StaticFiles(directory=FRONTEND_DIST / "assets"),
name="assets",
)
SYNC_MODE_OPTIONS = [ 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.""" """Fetch playlists and connection state from the remote Plex server."""
server_config.load() server_config.load()
@@ -89,24 +164,30 @@ def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]:
selected_library = music_libraries[0] selected_library = music_libraries[0]
server_config.set_and_save_config(library_name=selected_library) server_config.set_and_save_config(library_name=selected_library)
for playlist in plex_client.get_lib_playlists(selected_library) or []: if include_playlists:
track_count = getattr(playlist, "itemCount", None) for playlist in plex_client.get_lib_playlists(selected_library) or []:
if track_count is None: track_count = getattr(playlist, "itemCount", None)
try: if track_count is None:
track_count = len(playlist.items()) try:
except Exception: track_count = len(playlist.items())
track_count = 0 except Exception:
playlists.append( track_count = 0
{ playlists.append(
"name": playlist.title, {
"track_count": track_count, "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: except Exception as exc:
logger.warning(f"Failed to fetch cloud playlists: {exc}") logger.warning(f"Failed to fetch cloud playlists: {exc}")
status = "failed" 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 return playlists, status, server_info, selected_library, music_libraries
@@ -118,6 +199,7 @@ def _build_home_context(
sync_result: dict | None = None, sync_result: dict | None = None,
selected_mode: str | None = None, selected_mode: str | None = None,
): ):
local_path = local_path or server_config.local_playlist_dir
server_config.load() server_config.load()
local_playlists = scan_local_playlists(local_path) local_playlists = scan_local_playlists(local_path)
( (
@@ -148,16 +230,203 @@ def _build_home_context(
} }
# 显示主页 def _format_local_playlists_response(playlists: list[dict]) -> list[dict]:
@app.get("/", response_class=HTMLResponse) formatted: list[dict] = []
async def home(request: Request, local_path: str = "playlist"): for item in playlists:
context = _build_home_context(request, local_path) 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) 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) @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: try:
sync_mode = SyncMode(mode) sync_mode = SyncMode(mode)
except ValueError: 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) @app.post("/path-rules", response_class=HTMLResponse)
async def save_path_rules( async def save_path_rules(
request: Request, request: Request,
local_path: str = Form("playlist"), local_path: str = Form(None),
pattern: list[str] | None = Form(None), pattern: list[str] | None = Form(None),
replacement: list[str] | None = Form(None), replacement: list[str] | None = Form(None),
): ):
local_path = local_path or server_config.local_playlist_dir
patterns = pattern or [] patterns = pattern or []
replacements = replacement or [] replacements = replacement or []
File diff suppressed because one or more lines are too long
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
plex: {
orange: '#e5a00d',
dark: '#1f2937',
darker: '#111827',
card: '#374151'
}
}
}
}
}
</script>
<style>
/* Custom scrollbar for webkit to match dark theme */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #111827;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
}
}
</script>
<script type="module" crossorigin src="/assets/index-C8nziRPz.js"></script>
</head>
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
<div id="root"></div>
</body>
</html>
+22
View File
@@ -17,6 +17,9 @@ class ServerConfig:
self.port = "32400" self.port = "32400"
self.library_name = "" self.library_name = ""
self.path_rules: list[dict[str, str]] = [] self.path_rules: list[dict[str, str]] = []
# 新增:本地播放列表目录和默认同步策略(用于新的前端界面)
self.local_playlist_dir = os.getenv("LOCAL_PLAYLIST_DIR", "playlist")
self.sync_strategy = "LOCAL_OVERWRITE"
self.load() self.load()
def load(self) -> None: def load(self) -> None:
@@ -41,6 +44,10 @@ class ServerConfig:
self.port = config.get("server_port", "32400") self.port = config.get("server_port", "32400")
self.library_name = config.get("library_name", "") self.library_name = config.get("library_name", "")
self.path_rules = config.get("path_rules", []) or [] self.path_rules = config.get("path_rules", []) or []
self.local_playlist_dir = config.get(
"local_playlist_dir", self.local_playlist_dir
)
self.sync_strategy = config.get("sync_strategy", self.sync_strategy)
logger.info(f"Server config loaded: {self.__dict__}") logger.info(f"Server config loaded: {self.__dict__}")
def save(self): def save(self):
@@ -52,6 +59,8 @@ class ServerConfig:
"server_port": self.port, "server_port": self.port,
"library_name": self.library_name, "library_name": self.library_name,
"path_rules": self.path_rules, "path_rules": self.path_rules,
"local_playlist_dir": self.local_playlist_dir,
"sync_strategy": self.sync_strategy,
} }
with open(CONFIG_PATH, "w", encoding="utf-8") as f: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False) json.dump(config, f, indent=4, ensure_ascii=False)
@@ -82,6 +91,13 @@ class ServerConfig:
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None: def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
self.path_rules = path_rules or [] self.path_rules = path_rules or []
def set_local_playlist_dir(self, playlist_dir: str) -> None:
if playlist_dir:
self.local_playlist_dir = playlist_dir
def set_sync_strategy(self, sync_strategy: str) -> None:
self.sync_strategy = sync_strategy
def set_and_save_config( def set_and_save_config(
self, self,
theme: str = None, theme: str = None,
@@ -91,6 +107,8 @@ class ServerConfig:
port: str = None, port: str = None,
library_name: str | None = None, library_name: str | None = None,
path_rules: list[dict[str, str]] | None = None, path_rules: list[dict[str, str]] | None = None,
local_playlist_dir: str | None = None,
sync_strategy: str | None = None,
) -> None: ) -> None:
if theme is not None: if theme is not None:
self.set_theme(theme) self.set_theme(theme)
@@ -106,6 +124,10 @@ class ServerConfig:
self.set_library(library_name) self.set_library(library_name)
if path_rules is not None: if path_rules is not None:
self.set_path_rules(path_rules) self.set_path_rules(path_rules)
if local_playlist_dir is not None:
self.set_local_playlist_dir(local_playlist_dir)
if sync_strategy is not None:
self.set_sync_strategy(sync_strategy)
self.save() self.save()
+13 -2
View File
@@ -1,5 +1,6 @@
import os import os
from typing import List from typing import List
from datetime import datetime
from app.utils.logger import logger from app.utils.logger import logger
def load_local_playlist(playlist_path: str) -> List[str]: def load_local_playlist(playlist_path: str) -> List[str]:
@@ -42,7 +43,9 @@ def scan_local_playlists(base_path: str) -> list[dict]:
base_path: Directory that contains playlist files. base_path: Directory that contains playlist files.
Returns: Returns:
A list of dictionaries with ``name`` and ``track_count`` keys. A list of dictionaries with ``name`` and ``track_count`` keys, and
additional metadata (``path`` and ``last_modified``) for richer API
responses.
""" """
playlists: list[dict] = [] playlists: list[dict] = []
@@ -61,7 +64,15 @@ def scan_local_playlists(base_path: str) -> list[dict]:
if not entry.name.lower().endswith((".m3u", ".m3u8")): if not entry.name.lower().endswith((".m3u", ".m3u8")):
continue continue
tracks = load_local_playlist(entry.path) tracks = load_local_playlist(entry.path)
playlists.append({"name": entry.name, "track_count": len(tracks)}) last_modified = datetime.fromtimestamp(entry.stat().st_mtime)
playlists.append(
{
"name": entry.name,
"track_count": len(tracks),
"path": entry.path,
"last_modified": last_modified.isoformat(),
}
)
playlists.sort(key=lambda item: item["name"].lower()) playlists.sort(key=lambda item: item["name"].lower())
logger.info(f"Found {len(playlists)} playlists under {absolute_path}.") logger.info(f"Found {len(playlists)} playlists under {absolute_path}.")
+60 -7
View File
@@ -21,6 +21,7 @@ const App: React.FC = () => {
const [loadingLocal, setLoadingLocal] = useState(false); const [loadingLocal, setLoadingLocal] = useState(false);
const [loadingCloud, setLoadingCloud] = useState(false); const [loadingCloud, setLoadingCloud] = useState(false);
const [statusIntervalMs, setStatusIntervalMs] = useState<number>(60000);
// Connection Modal State // Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
@@ -115,12 +116,16 @@ const App: React.FC = () => {
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD); const playlistResult = await apiService.getPlaylists(ServerType.CLOUD);
if (playlistResult.status === 'success') { if (playlistResult.status === 'success') {
setCloudPlaylists(playlistResult.data); setCloudPlaylists(playlistResult.data);
} else {
setCloudPlaylists([]);
} }
// Fetch server info // Fetch server info
const infoResult = await apiService.getServerStatus(); const infoResult = await apiService.getServerStatus();
if (infoResult.status === 'success') { if (infoResult.status === 'success') {
setCloudServerInfo(infoResult.data); setCloudServerInfo(infoResult.data);
} else {
setCloudServerInfo({ isConnected: false });
} }
setLoadingCloud(false); setLoadingCloud(false);
@@ -128,20 +133,68 @@ const App: React.FC = () => {
// Initial Load // Initial Load
useEffect(() => { useEffect(() => {
refreshLocal(); const loadSettings = async () => {
refreshCloud(); const [settings, uiConfig] = await Promise.all([
apiService.getSettings(),
apiService.getUiConfig()
]);
if (settings.status === 'success') {
setCurrentStrategy(normalizeStrategy(settings.data.syncStrategy));
if (settings.data.regexRules) {
setRegexReplacements(settings.data.regexRules);
}
}
if (uiConfig.status === 'success') {
const ms = Math.max(10000, (uiConfig.data.statusCheckIntervalSeconds || 60) * 1000);
setStatusIntervalMs(ms);
}
refreshLocal();
refreshCloud();
};
loadSettings();
}, [refreshLocal, refreshCloud]); }, [refreshLocal, refreshCloud]);
// Periodically check cloud connection status to avoid stale UI loops
useEffect(() => {
const timer = window.setInterval(() => {
refreshCloud();
}, statusIntervalMs);
return () => window.clearInterval(timer);
}, [refreshCloud, statusIntervalMs]);
const normalizeStrategy = (value?: string): SyncStrategy => {
if (!value) return SyncStrategy.LOCAL_OVERWRITE;
const values = Object.values(SyncStrategy);
return values.includes(value as SyncStrategy)
? (value as SyncStrategy)
: SyncStrategy.LOCAL_OVERWRITE;
};
// Handle Strategy Change // Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => { const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy); setCurrentStrategy(strategy);
addToast(`Selected strategy "${label}" has been saved.`); const result = await apiService.saveStrategy(strategy);
if (result.status === 'success') {
addToast(`Selected strategy "${label}" has been saved.`);
} else {
addToast('Failed to save strategy to server.');
}
}; };
// Handle Regex Save // Handle Regex Save
const handleSaveRegex = (replacements: RegexReplacement[]) => { const handleSaveRegex = async (replacements: RegexReplacement[]) => {
setRegexReplacements(replacements); const result = await apiService.saveRegexRules(replacements);
addToast('Regex preprocessing rules have been saved.'); if (result.status === 'success') {
setRegexReplacements(result.data);
addToast('Regex preprocessing rules have been saved.');
} else {
addToast(result.message || 'Failed to save regex rules.');
}
}; };
const handleConnectSuccess = (serverInfo: PlexServerConnection) => { const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
@@ -47,7 +47,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));
}; };
const handleLibraryChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleLibraryChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newId = e.target.value; const newId = e.target.value;
setSelectedLibraryId(newId); setSelectedLibraryId(newId);
@@ -59,6 +59,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(updatedInfo); onConnectSuccess(updatedInfo);
// Show toast // Show toast
onShowMessage(`Library switched to ${lib.title}`); onShowMessage(`Library switched to ${lib.title}`);
await apiService.selectLibrary(lib.title);
} }
}; };
@@ -100,6 +101,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const defaultLib = libs[0]; const defaultLib = libs[0];
setSelectedLibraryId(defaultLib.id); setSelectedLibraryId(defaultLib.id);
// Pass connection info back with default library name explicitly set (though mock already does it) // Pass connection info back with default library name explicitly set (though mock already does it)
await apiService.selectLibrary(defaultLib.title);
onConnectSuccess({ onConnectSuccess({
...info, ...info,
libraryName: defaultLib.title libraryName: defaultLib.title
+1
View File
@@ -47,6 +47,7 @@
} }
} }
</script> </script>
<script type="module" src="/index.tsx"></script>
</head> </head>
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen"> <body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
<div id="root"></div> <div id="root"></div>
+1771
View File
File diff suppressed because it is too large Load Diff
+104 -91
View File
@@ -1,98 +1,90 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary } from '../types'; import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, RegexReplacement, UiConfig, SyncSettings } from '../types';
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
const SIMULATE_DELAY_MS = 800; const API_PREFIX = '/api';
// Mock available libraries on a server const parseJson = async <T>(response: Response): Promise<ApiResponse<T>> => {
const MOCK_LIBRARIES: PlexLibrary[] = [ try {
{ id: 'lib1', title: 'Music (Flac)', type: 'artist' }, const data = await response.json();
{ id: 'lib2', title: 'MP3 Collection', type: 'artist' }, return data as ApiResponse<T>;
{ id: 'lib3', title: 'Soundtracks', type: 'artist' }, } catch (error) {
{ id: 'lib4', title: 'Audiobooks', type: 'artist' } console.error('Failed to parse API response', error);
]; return { data: {} as T, status: 'error', message: 'Invalid response from server' };
}
// Helper to simulate network request or call actual API
const fetchPlaylists = async (type: ServerType): Promise<Playlist[]> => {
// In a real Docker environment with FastAPI, you would do:
// const response = await fetch(`/api/playlists/${type.toLowerCase()}`);
// const data = await response.json();
// return data;
// Mocking for UI demonstration
return new Promise((resolve) => {
setTimeout(() => {
if (type === ServerType.LOCAL) {
resolve([...MOCK_LOCAL_PLAYLISTS]);
} else {
resolve([...MOCK_CLOUD_PLAYLISTS]);
}
}, SIMULATE_DELAY_MS);
});
}; };
const fetchServerStatus = async (): Promise<PlexServerConnection> => { const mapServerType = (type: ServerType) => type === ServerType.LOCAL ? 'local' : 'cloud';
// Mocking server status
return new Promise((resolve) => {
setTimeout(() => {
// 90% chance of success for demo
const isSuccess = Math.random() > 0.1;
if (isSuccess) {
resolve({
isConnected: true,
name: 'Home Media Server',
ip: '192.168.1.105',
port: 32400,
libraryName: 'Music (Flac)'
});
} else {
resolve({
isConnected: false
});
}
}, SIMULATE_DELAY_MS);
});
};
const authenticatePlex = async (settings: PlexConnectionSettings): Promise<{ token: string, serverInfo: PlexServerConnection }> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Simulate validation
if (!settings.address) {
reject(new Error("Server address is required"));
return;
}
// If user provided username/password, mock a token generation
let token = settings.token;
if (!token && settings.username && settings.password) {
token = "MOCK_TOKEN_XYZ_999";
} else if (!token) {
reject(new Error("Token or Username/Password required"));
return;
}
// Success response with libraries
resolve({
token: token,
serverInfo: {
isConnected: true,
name: 'My Plex Server',
ip: settings.address,
port: parseInt(settings.port) || 32400,
libraryName: MOCK_LIBRARIES[0].title, // Default to first library
libraries: MOCK_LIBRARIES
}
});
}, 1500);
});
}
export const apiService = { export const apiService = {
getUiConfig: async (): Promise<ApiResponse<UiConfig>> => {
try {
const response = await fetch(`${API_PREFIX}/ui-config`);
const result = await parseJson<UiConfig>(response);
if (!response.ok || result.status !== 'success') {
return { data: { statusCheckIntervalSeconds: 60 }, status: 'error', message: result.message || '无法获取前端配置' };
}
return result;
} catch (error) {
return { data: { statusCheckIntervalSeconds: 60 }, status: 'error', message: '无法获取前端配置' };
}
},
getSettings: async (): Promise<ApiResponse<SyncSettings>> => {
try {
const response = await fetch(`${API_PREFIX}/settings`);
const result = await parseJson<SyncSettings>(response);
if (!response.ok || result.status !== 'success') {
return { data: { syncStrategy: 'LOCAL_OVERWRITE', regexRules: [] }, status: 'error', message: result.message || '无法获取设置' };
}
return result;
} catch (error) {
return { data: { syncStrategy: 'LOCAL_OVERWRITE', regexRules: [] }, status: 'error', message: '无法获取设置' };
}
},
saveStrategy: async (strategy: string): Promise<ApiResponse<SyncSettings>> => {
try {
const response = await fetch(`${API_PREFIX}/settings/strategy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy })
});
return await parseJson<SyncSettings>(response);
} catch (error) {
return { data: { syncStrategy: strategy, regexRules: [] }, status: 'error', message: '无法保存同步策略' };
}
},
getRegexRules: async (): Promise<ApiResponse<RegexReplacement[]>> => {
try {
const response = await fetch(`${API_PREFIX}/regex-rules`);
return await parseJson<RegexReplacement[]>(response);
} catch (error) {
return { data: [], status: 'error', message: '无法获取正则规则' };
}
},
saveRegexRules: async (rules: RegexReplacement[]): Promise<ApiResponse<RegexReplacement[]>> => {
try {
const response = await fetch(`${API_PREFIX}/regex-rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rules)
});
return await parseJson<RegexReplacement[]>(response);
} catch (error) {
return { data: [], status: 'error', message: '无法保存正则规则' };
}
},
getPlaylists: async (serverType: ServerType): Promise<ApiResponse<Playlist[]>> => { getPlaylists: async (serverType: ServerType): Promise<ApiResponse<Playlist[]>> => {
try { try {
const data = await fetchPlaylists(serverType); const response = await fetch(`${API_PREFIX}/playlists/${mapServerType(serverType)}`);
return { data, status: 'success' }; const result = await parseJson<Playlist[]>(response);
if (!response.ok || result.status !== 'success') {
return { data: [], status: 'error', message: result.message || 'Failed to fetch playlists' };
}
return result;
} catch (error) { } catch (error) {
console.error(`Error fetching ${serverType} playlists:`, error); console.error(`Error fetching ${serverType} playlists:`, error);
return { data: [], status: 'error', message: 'Failed to fetch playlists' }; return { data: [], status: 'error', message: 'Failed to fetch playlists' };
@@ -101,8 +93,12 @@ export const apiService = {
getServerStatus: async (): Promise<ApiResponse<PlexServerConnection>> => { getServerStatus: async (): Promise<ApiResponse<PlexServerConnection>> => {
try { try {
const data = await fetchServerStatus(); const response = await fetch(`${API_PREFIX}/server/status`);
return { data, status: 'success' }; const result = await parseJson<PlexServerConnection>(response);
if (!response.ok || result.status !== 'success') {
return { data: { isConnected: false }, status: 'error', message: result.message || 'Failed to connect to server' };
}
return result;
} catch (error) { } catch (error) {
return { return {
data: { isConnected: false }, data: { isConnected: false },
@@ -114,14 +110,31 @@ export const apiService = {
connectToPlex: async (settings: PlexConnectionSettings): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => { connectToPlex: async (settings: PlexConnectionSettings): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
try { try {
const data = await authenticatePlex(settings); const response = await fetch(`${API_PREFIX}/server/connect`, {
return { data, status: 'success', message: 'Connected successfully' }; method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
return await parseJson<{ token: string, serverInfo: PlexServerConnection }>(response);
} catch (error: any) { } catch (error: any) {
return { return {
data: { token: '', serverInfo: { isConnected: false } }, data: { token: '', serverInfo: { isConnected: false } },
status: 'error', status: 'error',
message: error.message || 'Connection failed' message: error?.message || 'Connection failed'
}; };
} }
},
selectLibrary: async (library: string): Promise<ApiResponse<{ libraryName: string }>> => {
try {
const response = await fetch(`${API_PREFIX}/server/library`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library })
});
return await parseJson<{ libraryName: string }>(response);
} catch (error) {
return { data: { libraryName: library }, status: 'error', message: '无法切换媒体库' };
}
} }
}; };
+10
View File
@@ -62,3 +62,13 @@ export interface ApiResponse<T> {
status: 'success' | 'error'; status: 'success' | 'error';
message?: string; message?: string;
} }
export interface UiConfig {
statusCheckIntervalSeconds: number;
localPlaylistDir?: string;
}
export interface SyncSettings {
syncStrategy: string;
regexRules?: RegexReplacement[];
}
+5
View File
@@ -18,6 +18,11 @@ export default defineConfig(({ mode }) => {
alias: { alias: {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),
} }
},
base: '/',
build: {
outDir: path.resolve(__dirname, '../app/static/frontend'),
emptyOutDir: true,
} }
}; };
}); });