Files
PlexPlaylistSync/app/main.py
T
2025-11-28 03:00:02 +09:00

664 lines
22 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 os
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 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=BASE_DIR / "templates")
# mount static files
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 = [
{
"value": SyncMode.LOCAL_FORCE.value,
"label": "完全本地优先(local_force",
"description": "单向同步,本地覆盖云端且顺序以本地为准。",
},
{
"value": SyncMode.REMOTE_FORCE.value,
"label": "完全云端优先(remote_force",
"description": "单向同步,云端覆盖本地且顺序以云端为准。",
},
{
"value": SyncMode.MERGE_LOCAL_PRIMARY.value,
"label": "双向合并(本地优先)",
"description": "三方合并,冲突时选择本地版本。",
},
{
"value": SyncMode.MERGE_REMOTE_PRIMARY.value,
"label": "双向合并(云端优先)",
"description": "三方合并,冲突时选择云端版本。",
},
]
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()
playlists: list[dict] = []
status = "unset"
server_info = {
"name": "未设置",
"domain": server_config.url or "未设置",
}
selected_library = server_config.library_name
music_libraries: list[str] = []
# no server url configured
if not server_config.url:
return playlists, status, server_info, selected_library, music_libraries
status = "failed"
try:
plex_client.connect(
token=server_config.token,
scheme=server_config.scheme,
url=server_config.url,
port=server_config.port,
)
status = "connected" if plex_client.connected else "failed"
server_info.update(
{
"name": getattr(plex_client.server, "friendlyName", "未命名服务器"),
"domain": server_config.url,
}
)
music_libraries = plex_client.get_libs_name_list()
if not music_libraries:
server_config.set_and_save_config(library_name="")
return playlists, status, server_info, "", music_libraries
if not selected_library or selected_library not in music_libraries:
selected_library = music_libraries[0]
server_config.set_and_save_config(library_name=selected_library)
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"
if include_playlists:
playlists.sort(key=lambda item: item["name"].lower())
return playlists, status, server_info, selected_library, music_libraries
def _build_home_context(
request: Request,
local_path: str,
message: str | None = None,
message_type: str | None = None,
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)
(
cloud_playlists,
connection_status,
server_info,
selected_library,
music_libraries,
) = _get_cloud_playlists()
return {
"request": request,
"theme": server_config.theme,
"path": "/",
"local_playlists": local_playlists,
"local_path": local_path,
"cloud_playlists": cloud_playlists,
"connection_status": connection_status,
"server_info": server_info,
"selected_library": selected_library,
"music_libraries": music_libraries,
"sync_modes": SYNC_MODE_OPTIONS,
"selected_mode": selected_mode,
"message": message,
"message_type": message_type,
"sync_result": sync_result,
"path_rules": server_config.path_rules,
}
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(None)
):
local_path = local_path or server_config.local_playlist_dir
try:
sync_mode = SyncMode(mode)
except ValueError:
context = _build_home_context(
request,
local_path,
message=f"未知的同步策略:{mode}",
message_type="danger",
selected_mode=mode,
)
return templates.TemplateResponse("home.html", context)
try:
results = sync_all_playlists(
local_dir=local_path,
mode=sync_mode,
test_folder=TEST_PLAYLIST_DIR,
)
merged_count = sum(len(item.merged_paths) for item in results)
conflict_count = sum(len(item.conflicts) for item in results)
deleted_count = sum(1 for item in results if item.action == "deleted")
context = _build_home_context(
request,
local_path,
message="同步完成,输出已写入测试目录用于验证。",
message_type="success",
sync_result={
"mode": sync_mode.value,
"mode_label": next(
(item["label"] for item in SYNC_MODE_OPTIONS if item["value"] == sync_mode.value),
sync_mode.value,
),
"merged_count": merged_count,
"conflict_count": conflict_count,
"delete_count": deleted_count,
"playlist_count": len(results),
"output_dir": TEST_PLAYLIST_DIR,
},
selected_mode=sync_mode.value,
)
return templates.TemplateResponse("home.html", context)
except Exception as exc:
logger.warning(f"Sync failed: {exc}")
context = _build_home_context(
request,
local_path,
message=f"同步失败:{exc}",
message_type="danger",
selected_mode=sync_mode.value,
)
return templates.TemplateResponse("home.html", context)
@app.post("/path-rules", response_class=HTMLResponse)
async def save_path_rules(
request: Request,
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 []
cleaned_rules: list[dict[str, str]] = []
for pat, repl in zip(patterns, replacements):
pat = pat.strip()
if not pat:
continue
cleaned_rules.append({"pattern": pat, "replacement": repl or ""})
server_config.set_and_save_config(path_rules=cleaned_rules)
context = _build_home_context(
request,
local_path,
message="正则规则已保存并会在同步前应用。",
message_type="success",
)
return templates.TemplateResponse("home.html", context)
# 登录页面和处理
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
server_config.load()
music_libraries = []
selected_library = server_config.library_name
if server_config.url and server_config.token:
try:
plex_client.connect(
token=server_config.token,
scheme=server_config.scheme,
url=server_config.url,
port=server_config.port,
)
music_libraries = plex_client.get_libs_name_list()
if music_libraries:
if selected_library not in music_libraries:
selected_library = music_libraries[0]
else:
selected_library = ""
except Exception:
selected_library = ""
music_libraries = []
return templates.TemplateResponse(
"login.html",
{
"request": request,
"theme": server_config.theme,
"path": "/login",
"token": server_config.token,
"scheme": server_config.scheme,
"server_url": server_config.url,
"port": server_config.port,
"music_libraries": music_libraries,
"selected_library": selected_library,
},
)
@app.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
user: str = Form(...),
pw: str = Form(...),
token: str = Form(...),
scheme: str = Form("https"),
url: str = Form(...),
port: str = Form("32400"),
library_name: str = Form(""),
):
# 尝试连接到 Plex 服务器
try:
# 优先使用 token 连接,如果 token 为空则使用用户名和密码连接
_, token_success = plex_client.connect(
username=user,
password=pw,
token=token,
scheme=scheme,
url=url,
port=port,
)
# 成功连接后保存配置到配置文件
music_libraries: list[str] = []
selected_library = ""
if plex_client.connected:
try:
music_libraries = plex_client.get_libs_name_list()
except Exception as exc:
logger.warning(f"Unable to fetch music libraries: {exc}")
music_libraries = []
if music_libraries:
if library_name and library_name in music_libraries:
selected_library = library_name
else:
selected_library = music_libraries[0]
server_config.set_and_save_config(
token=token_success,
scheme=scheme,
url=url,
port=port,
library_name=selected_library,
)
else:
music_libraries = []
server_config.set_and_save_config(
token=token_success, scheme=scheme, url=url, port=port, library_name=""
)
return templates.TemplateResponse(
"login.html",
{
"request": request,
"message": "连接成功",
"message_type": "success",
"theme": server_config.theme,
"path": "/login",
"token": server_config.token,
"scheme": server_config.scheme,
"server_url": server_config.url,
"port": server_config.port,
"music_libraries": music_libraries,
"selected_library": selected_library,
},
)
except Exception as e:
music_libraries = []
return templates.TemplateResponse(
"login.html",
{
"request": request,
"message": f"连接失败:{str(e)}",
"message_type": "danger",
"theme": server_config.theme,
"path": "/login",
"scheme": scheme,
"server_url": url,
"port": port,
"music_libraries": music_libraries,
"selected_library": "",
},
)
@app.get("/playlist", response_class=HTMLResponse)
async def get_playlist(request: Request):
return templates.TemplateResponse(
"playlist.html",
{"request": request, "theme": server_config.theme, "path": "/playlist"},
)
@app.post("/playlist", response_class=HTMLResponse)
async def set_playlist(
request: Request, address: str = Form(...), interval: str = Form(...)
):
# demo:返回提交的设置
return templates.TemplateResponse(
"playlist.html",
{
"request": request,
"message": f"设置成功:地址 {address},间隔 {interval} 分钟",
"message_type": "info",
"theme": server_config.theme,
"path": "/playlist",
},
)
@app.post("/set-theme")
async def set_theme(theme: str = Form(...)):
server_config.set_and_save_config(theme=theme)
return RedirectResponse("/login", status_code=303)