Integrate React frontend with backend API
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}.")
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ const App: React.FC = () => {
|
|||||||
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
||||||
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
||||||
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
|
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
|
||||||
|
|
||||||
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,10 +47,10 @@ 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);
|
||||||
|
|
||||||
const lib = libraries.find(l => l.id === newId);
|
const lib = libraries.find(l => l.id === newId);
|
||||||
if (lib && connectedServerInfo) {
|
if (lib && connectedServerInfo) {
|
||||||
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
|
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+1771
File diff suppressed because it is too large
Load Diff
@@ -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,27 +93,48 @@ 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 },
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Failed to connect to server'
|
message: 'Failed to connect to server'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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: '无法切换媒体库' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,4 +61,14 @@ export interface ApiResponse<T> {
|
|||||||
data: T;
|
data: 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[];
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user