15 Commits

Author SHA1 Message Date
Koha9 96c853125c Fix watcher sync loop when auto-watch is enabled 2026-01-15 16:26:52 +09:00
Koha9 c00d6100c2 Merge branch 'feat/Identification_features' 2026-01-15 15:10:25 +09:00
Koha9 86f18cc410 feat: add safe filename handling for backup and sync processes to prevent invalid paths 2025-12-20 04:46:09 +09:00
Koha9 254c391c89 feat: enhance logging by redacting sensitive information and update .gitignore for runtime logs 2025-12-19 03:23:38 +09:00
Koha9 a0631c6280 feat: update .gitignore and add docker-compose sample for service configuration 2025-12-19 03:02:30 +09:00
Koha9 1806e0823f feat: add CORS configuration options to enhance API security 2025-12-19 02:33:00 +09:00
Koha9 ea5a0004da feat: add identification page 2025-12-18 06:46:27 +09:00
Koha9 e3d3df9ecb PlexPlaylist_UI subtree merge
feat: Implement user authentication and login screen

Merge commit 'a14210c458d5f6c6a4875ca8228db63c0b73cf75'
2025-12-17 20:25:06 +09:00
Koha9 a14210c458 Squashed 'sample-front-end/' changes from 32f6ed7..e855771
e855771 feat: Implement user authentication and login screen

git-subtree-dir: sample-front-end
git-subtree-split: e8557717c9397bb10286a61f1d29a9976c10ecba
2025-12-17 20:25:06 +09:00
Koha9 f0b129a27e feat: docker-compose update for enhance configuration flexibility 2025-12-15 10:47:51 +09:00
Koha9 9ddc0d9eb2 feat: Refactor backup and sync paths, enhance configuration flexibility 2025-12-15 10:45:28 +09:00
Koha9 834e21b331 Merge branch 'UI-internationalization' 2025-12-15 08:13:52 +09:00
Koha9 e1208420a0 feat: Add cht language support. 2025-12-14 03:52:35 +09:00
Koha9 575d1a7008 feat: Update week day representation and add localization support for weekdays 2025-12-14 03:14:58 +09:00
Koha9 4d3bb6cfd8 feat: Add chs language support. 2025-12-14 03:09:29 +09:00
34 changed files with 1784 additions and 231 deletions
+31
View File
@@ -0,0 +1,31 @@
# Timezone
TZ=Asia/Tokyo
# Enable authentication (required)
# 1 = enabled, 0 = disabled
PLEXPLAYLISTSYNC_AUTH_ENABLED=1
# Login username/password (required if auth enabled)
PLEXPLAYLISTSYNC_AUTH_USERNAME=USERNAME
PLEXPLAYLISTSYNC_AUTH_PASSWORD=CHANGE_PASSWORD
# Strongly recommended: stable token signing secret (or tokens will become invalid after container restart)
# Use a sufficiently long random string
PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET=REPLACE_WITH_A_RANDOM_STRING
# Token TTL seconds (optional)
PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS=86400
# CORS allowlist (optional)
# Default is empty (CORS disabled; same-origin only).
# Accepts comma-separated list or a JSON array.
# Example:
# PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# or
# PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=["https://your.domain"]
PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=
# Allow cookies/credentials for allowlisted origins (optional)
# 1 = enabled, 0 = disabled
# Note: if origins contains '*', credentials will be forced off.
PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS=0
+11
View File
@@ -62,6 +62,9 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
# App runtime logs
app/logs/
# Flask stuff:
instance/
.webassets-cache
@@ -176,3 +179,11 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
# data
data/*
playlists/*
docker-compose.yml
# Local dev config may contain Plex token
app/config.json
-28
View File
@@ -1,28 +0,0 @@
{
"theme": "auto",
"token": "",
"server_url": "",
"server_scheme": "http",
"server_port": "32400",
"timeout": 9,
"library_name": "",
"sync_mode": "local_force",
"local_path": "playlists",
"path_rules": [],
"path_mapping": {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
},
"schedule_mode": "DISABLED",
"schedule_cron": "",
"schedule_daily_time": "00:00",
"schedule_weekly_days": [0],
"schedule_weekly_time": "00:00",
"schedule_auto_watch": false
}
+147 -20
View File
@@ -1,11 +1,12 @@
import os
import json
from datetime import datetime
from typing import Sequence
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
import asyncio
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
@@ -13,13 +14,137 @@ from pydantic import BaseModel, Field
from app.utils.config import server_config
from app.utils.local_playlist import load_local_playlist, scan_local_playlists
from app.utils.logger import logger
from app.utils.playlist_merge import SyncMode, TEST_PLAYLIST_DIR, sync_all_playlists
from app.utils.playlist_merge import SyncMode, SYNC_ARTIFACTS_DIR, sync_all_playlists
from app.utils.plex_client import plex_client
from app.utils.scheduler import start_scheduler, update_scheduler_job, get_next_run_time, validate_cron_expression
from app.utils.sync_manager import sync_manager
from app.utils.auth import load_auth_config, issue_token, verify_token
app = FastAPI()
def _parse_cors_allowed_origins(raw: str | None) -> list[str]:
if not raw:
return []
value = raw.strip()
if not value:
return []
if value == "*":
return ["*"]
try:
if value.startswith("["):
parsed = json.loads(value)
if isinstance(parsed, list):
origins = [str(item).strip() for item in parsed]
else:
origins = [str(parsed).strip()]
else:
origins = [part.strip() for part in value.split(",")]
except Exception:
logger.warning(
"Invalid PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS; CORS will be disabled."
)
return []
return [origin for origin in origins if origin]
def _env_truthy(name: str, default: str = "0") -> bool:
value = os.getenv(name, default)
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
_CORS_ALLOWED_ORIGINS = _parse_cors_allowed_origins(
os.getenv("PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS", "")
)
_CORS_ALLOW_CREDENTIALS = (
False
if "*" in _CORS_ALLOWED_ORIGINS
else _env_truthy("PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS", "0")
)
# --- Optional API Authentication (username/password) ---
AUTH_CONFIG = load_auth_config()
class LoginPayload(BaseModel):
username: str
password: str
@app.get("/api/auth/config")
async def get_auth_config():
return {"enabled": AUTH_CONFIG.enabled}
@app.post("/api/auth/login")
async def api_login(payload: LoginPayload):
if not AUTH_CONFIG.enabled:
raise HTTPException(status_code=400, detail="Authentication is disabled")
if payload.username != AUTH_CONFIG.username or payload.password != AUTH_CONFIG.password:
raise HTTPException(status_code=401, detail="Invalid credentials")
token = issue_token(AUTH_CONFIG, payload.username)
return {
"token": token,
"username": payload.username,
"expires_in": AUTH_CONFIG.token_ttl_seconds,
}
@app.get("/api/auth/me")
async def api_me(request: Request):
if not AUTH_CONFIG.enabled:
return {"username": ""}
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
else:
token = ""
payload = verify_token(AUTH_CONFIG, token) if token else None
if not payload:
raise HTTPException(status_code=401, detail="Unauthorized")
return {"username": payload.get("u", "")}
@app.post("/api/auth/logout")
async def api_logout():
# Stateless token auth; client clears token.
return {"status": "success"}
_AUTH_API_WHITELIST = {
"/api/auth/config",
"/api/auth/login",
}
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if AUTH_CONFIG.enabled and request.url.path.startswith("/api"):
if request.method.upper() == "OPTIONS":
return await call_next(request)
if request.url.path not in _AUTH_API_WHITELIST:
auth_header = request.headers.get("authorization", "")
token = ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
# For endpoints like EventSource(SSE) where custom headers are not available.
if not token:
token = request.query_params.get("access_token", "").strip()
if not token or not verify_token(AUTH_CONFIG, token):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
return await call_next(request)
@app.on_event("startup")
async def startup_event():
sync_manager.set_event_loop(asyncio.get_running_loop())
@@ -27,8 +152,8 @@ async def startup_event():
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=_CORS_ALLOWED_ORIGINS,
allow_credentials=_CORS_ALLOW_CREDENTIALS,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -499,8 +624,8 @@ async def api_connect(payload: ConnectRequest):
async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"), local_path: str | None = None):
server_type = server.lower()
if server_type == "local":
resolved_path = local_path or server_config.local_path
server_config.set_and_save_config(local_path=resolved_path)
# local_path is intentionally fixed; ignore query overrides.
resolved_path = server_config.local_path
playlists = _scan_local_playlists_with_meta(resolved_path)
return {"playlists": [item.model_dump() for item in playlists]}
@@ -542,7 +667,8 @@ async def api_sync(payload: SyncRequest):
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
local_dir = payload.local_path or server_config.local_path
# local_path is intentionally fixed; ignore request overrides.
local_dir = server_config.local_path
# Use sync_manager to execute sync, ensuring state is updated
try:
@@ -567,7 +693,7 @@ async def api_sync(payload: SyncRequest):
"conflict_count": conflict_count,
"delete_count": deleted_count,
"playlist_count": len(results),
"output_dir": TEST_PLAYLIST_DIR,
"output_dir": SYNC_ARTIFACTS_DIR,
}
@@ -611,23 +737,24 @@ def _build_home_context(
# 显示主页
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, local_path: str = "playlist"):
async def home(request: Request, local_path: str = "playlists"):
index_path = os.path.join(FRONTEND_DIST_PATH, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
context = _build_home_context(request, local_path or server_config.local_path)
# local_path is intentionally fixed; ignore query overrides.
context = _build_home_context(request, server_config.local_path)
return templates.TemplateResponse("home.html", context)
@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("playlists")):
try:
sync_mode = SyncMode(mode)
except ValueError:
context = _build_home_context(
request,
local_path,
server_config.local_path,
message=f"未知的同步策略:{mode}",
message_type="danger",
selected_mode=mode,
@@ -636,17 +763,17 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
try:
results = sync_all_playlists(
local_dir=local_path,
local_dir=server_config.local_path,
mode=sync_mode,
test_folder=TEST_PLAYLIST_DIR,
test_folder=SYNC_ARTIFACTS_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="同步完成,输出已写入测试目录用于验证",
server_config.local_path,
message="同步完成,输出已写入同步工作目录(Artifacts",
message_type="success",
sync_result={
"mode": sync_mode.value,
@@ -658,7 +785,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
"conflict_count": conflict_count,
"delete_count": deleted_count,
"playlist_count": len(results),
"output_dir": TEST_PLAYLIST_DIR,
"output_dir": SYNC_ARTIFACTS_DIR,
},
selected_mode=sync_mode.value,
)
@@ -667,7 +794,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
logger.warning(f"Sync failed: {exc}")
context = _build_home_context(
request,
local_path,
server_config.local_path,
message=f"同步失败:{exc}",
message_type="danger",
selected_mode=sync_mode.value,
@@ -678,7 +805,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
@app.post("/path-rules", response_class=HTMLResponse)
async def save_path_rules(
request: Request,
local_path: str = Form("playlist"),
local_path: str = Form("playlists"),
pattern: list[str] | None = Form(None),
replacement: list[str] | None = Form(None),
):
@@ -696,7 +823,7 @@ async def save_path_rules(
context = _build_home_context(
request,
local_path,
server_config.local_path,
message="正则规则已保存并会在同步前应用。",
message_type="success",
)
+104
View File
@@ -0,0 +1,104 @@
import base64
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass
from app.utils.logger import logger
def _parse_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
def _b64url_decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode((data + padding).encode("utf-8"))
@dataclass(frozen=True)
class AuthConfig:
enabled: bool
username: str
password: str
token_secret: bytes
token_ttl_seconds: int
def load_auth_config() -> AuthConfig:
enabled = _parse_bool(os.environ.get("PLEXPLAYLISTSYNC_AUTH_ENABLED"), default=False)
username = os.environ.get("PLEXPLAYLISTSYNC_AUTH_USERNAME", "").strip()
password = os.environ.get("PLEXPLAYLISTSYNC_AUTH_PASSWORD", "")
ttl = int(os.environ.get("PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS", "86400"))
ttl = max(60, ttl)
secret_env = os.environ.get("PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET", "").strip()
if secret_env:
token_secret = secret_env.encode("utf-8")
elif enabled:
# If auth is enabled but no explicit secret is set, fall back to an ephemeral secret.
# This means tokens become invalid after restart, which is acceptable for this project.
token_secret = os.urandom(32)
logger.warning(
"PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET not set; using ephemeral secret (tokens reset on restart)."
)
else:
token_secret = b""
if enabled:
if not username or not password:
raise RuntimeError(
"Auth enabled but missing credentials: please set PLEXPLAYLISTSYNC_AUTH_USERNAME and PLEXPLAYLISTSYNC_AUTH_PASSWORD."
)
return AuthConfig(
enabled=enabled,
username=username,
password=password,
token_secret=token_secret,
token_ttl_seconds=ttl,
)
def issue_token(config: AuthConfig, username: str) -> str:
now = int(time.time())
payload = {"u": username, "exp": now + config.token_ttl_seconds}
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(config.token_secret, payload_b64.encode("utf-8"), hashlib.sha256).digest()
sig_b64 = _b64url_encode(sig)
return f"{payload_b64}.{sig_b64}"
def verify_token(config: AuthConfig, token: str) -> dict | None:
try:
payload_b64, sig_b64 = token.split(".", 1)
except ValueError:
return None
try:
expected_sig = hmac.new(
config.token_secret, payload_b64.encode("utf-8"), hashlib.sha256
).digest()
actual_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
return None
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
exp = int(payload.get("exp", 0))
if exp <= int(time.time()):
return None
if not payload.get("u"):
return None
return payload
except Exception:
return None
+34 -4
View File
@@ -1,5 +1,7 @@
import os
import zipfile
import hashlib
import re
from datetime import datetime
from typing import List
from app.utils.logger import logger
@@ -7,11 +9,39 @@ from app.utils.config import server_config
from app.utils.local_playlist import load_local_playlist
from app.utils.plex_client import plex_client
# Default backup directory
BACKUP_DIR = os.path.abspath(
# Default backup directory (repo root /backups)
DEFAULT_BACKUP_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "backups")
)
# Allow Docker / users to relocate backups for centralized host backup.
# Example: /app/data/backup
BACKUP_DIR = os.path.abspath(
os.environ.get("PLEXPLAYLISTSYNC_BACKUP_DIR", DEFAULT_BACKUP_DIR)
)
def _safe_zip_entry_name(name: str, extension: str = ".m3u8") -> str:
"""Return a safe zip entry filename.
Prevents zip-slip style paths and avoids problematic characters.
"""
original = (name or "").strip()
base = os.path.basename(original)
base = re.sub(r"[\x00-\x1f\x7f]", "_", base)
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in base).strip().strip(". ")
if not cleaned:
cleaned = "playlist"
cleaned = cleaned[:160].rstrip().strip(". ")
if cleaned != original:
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
cleaned = f"{cleaned}__{digest}"
return f"{cleaned}{extension}"
def ensure_backup_dir():
"""Ensure the backup directory exists."""
@@ -112,7 +142,7 @@ def backup_local_playlists(local_path: str) -> str | None:
# Get the playlist name without extension and add .m3u8 extension
playlist_name = os.path.splitext(entry.name)[0]
archive_name = f"{playlist_name}.m3u8"
archive_name = _safe_zip_entry_name(playlist_name)
# Write to zip
zipf.writestr(archive_name, content)
@@ -211,7 +241,7 @@ def backup_cloud_playlists(library_name: str) -> str | None:
if len(lines) > 1: # More than just #EXTM3U
content = "\n".join(lines)
archive_name = f"{playlist.title}.m3u8"
archive_name = _safe_zip_entry_name(getattr(playlist, "title", "playlist"))
zipf.writestr(archive_name, content)
playlist_count += 1
+45 -16
View File
@@ -2,7 +2,27 @@ import json
import os
from app.utils.logger import logger
def _redact_for_log(value: object) -> object:
if not isinstance(value, dict):
return value
redacted = dict(value)
for key in ("token", "password"):
if key in redacted and redacted.get(key):
redacted[key] = "***"
return redacted
def _redact_server_config_dict(state: dict) -> dict:
if not isinstance(state, dict):
return {}
redacted = dict(state)
if redacted.get("token"):
redacted["token"] = "***"
return redacted
DEFAULT_SYNC_MODE = "merge_local_primary"
LOCAL_PLAYLISTS_FOLDER = "playlists"
DEFAULT_PATH_MAPPING = {
"mode": "SIMPLE",
"simple": [],
@@ -14,10 +34,22 @@ DEFAULT_PATH_MAPPING = {
}
}
CONFIG_PATH = os.path.abspath(
DEFAULT_CONFIG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "config.json")
)
# Allow Docker / users to relocate config for backup convenience.
# Example: /app/data/config/config.json
CONFIG_PATH = os.path.abspath(
os.environ.get("PLEXPLAYLISTSYNC_CONFIG_PATH", DEFAULT_CONFIG_PATH)
)
def _ensure_parent_dir(file_path: str) -> None:
parent = os.path.dirname(os.path.abspath(file_path))
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
class ServerConfig:
@@ -30,16 +62,18 @@ class ServerConfig:
self.timeout = 9
self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE
self.local_path = "playlist"
# Local playlists folder is intentionally fixed and not part of config.
# Docker volume should mount host ./playlists -> container /app/playlists.
self.local_path = LOCAL_PLAYLISTS_FOLDER
self.path_rules: list[dict[str, str]] = [] # Legacy field for backward compatibility
self.path_mapping: dict = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = "DISABLED"
self.schedule_cron = ""
self.schedule_daily_time = "02:00"
self.schedule_daily_time = "00:00"
self.schedule_weekly_days = [0]
self.schedule_weekly_time = "03:00"
self.schedule_weekly_time = "00:00"
self.schedule_auto_watch = False
self.backup_enabled = False
self.backup_enabled = True
self.backup_retention_count = 5
self.load()
@@ -47,7 +81,7 @@ class ServerConfig:
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
logger.debug(f"Loaded server config: {config}")
logger.debug(f"Loaded server config: {_redact_for_log(config)}")
except FileNotFoundError:
# 如果配置文件不存在,使用默认值
self.save()
@@ -66,7 +100,8 @@ class ServerConfig:
self.timeout = config.get("timeout", 9)
self.library_name = config.get("library_name", "")
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
self.local_path = config.get("local_path", "playlist")
# local_path is fixed by design and not configurable.
self.local_path = LOCAL_PLAYLISTS_FOLDER
self.path_rules = config.get("path_rules", []) or []
# Load path_mapping with default fallback
@@ -94,9 +129,10 @@ class ServerConfig:
self.backup_enabled = config.get("backup_enabled", False)
self.backup_retention_count = config.get("backup_retention_count", 5)
logger.info(f"Server config loaded.")
logger.debug(f"Current server config: {self.__dict__}")
logger.debug(f"Current server config: {_redact_server_config_dict(self.__dict__)}")
def save(self):
_ensure_parent_dir(CONFIG_PATH)
config = {
"theme": self.theme,
"token": self.token,
@@ -106,7 +142,6 @@ class ServerConfig:
"timeout": self.timeout,
"library_name": self.library_name,
"sync_mode": self.sync_mode,
"local_path": self.local_path,
"path_rules": self.path_rules,
"path_mapping": self.path_mapping,
"schedule_mode": self.schedule_mode,
@@ -121,7 +156,7 @@ class ServerConfig:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved.")
logger.debug(f"Saved server config: {config}")
logger.debug(f"Saved server config: {_redact_for_log(config)}")
def set_url(self, url: str) -> None:
self.url = url
@@ -144,9 +179,6 @@ class ServerConfig:
def set_sync_mode(self, sync_mode: str) -> None:
self.sync_mode = sync_mode
def set_local_path(self, local_path: str) -> None:
self.local_path = local_path or "playlist"
def set_theme(self, theme: str) -> None:
# check theme is valid
if theme not in ["auto", "dark", "light"]:
@@ -208,7 +240,6 @@ class ServerConfig:
timeout: int | None = None,
library_name: str | None = None,
sync_mode: str | None = None,
local_path: str | None = None,
path_rules: list[dict[str, str]] | None = None,
path_mapping: dict | None = None,
) -> None:
@@ -228,8 +259,6 @@ class ServerConfig:
self.set_library(library_name)
if sync_mode is not None:
self.set_sync_mode(sync_mode)
if local_path is not None:
self.set_local_path(local_path)
if path_rules is not None:
self.set_path_rules(path_rules)
if path_mapping is not None:
+22 -4
View File
@@ -78,10 +78,28 @@ def write_local_playlist(playlist_path: str, tracks: List[str]) -> bool:
bool: True if successful, False otherwise.
"""
try:
with open(playlist_path, 'w', encoding="utf-8") as file:
file.write("#EXTM3U\n")
for track in tracks:
file.write(f"{track}\n")
desired_lines = ["#EXTM3U\n"] + [f"{track}\n" for track in tracks]
desired_content = "".join(desired_lines)
# Avoid rewriting identical content to prevent watcher feedback loops.
if os.path.exists(playlist_path):
try:
with open(playlist_path, 'r', encoding="utf-8") as existing_file:
existing_content = existing_file.read()
if existing_content == desired_content:
logger.debug(f"Playlist unchanged; skipping write: {playlist_path}")
return True
except Exception as e:
# If read fails, fall back to rewriting.
logger.debug(f"Failed to read existing playlist for comparison ({playlist_path}): {e}")
# Write via temp file then replace for safer updates.
os.makedirs(os.path.dirname(os.path.abspath(playlist_path)), exist_ok=True)
tmp_path = f"{playlist_path}.tmp"
with open(tmp_path, 'w', encoding="utf-8") as file:
file.write(desired_content)
os.replace(tmp_path, playlist_path)
logger.info(f"Written {len(tracks)} songs to the playlist: {playlist_path}")
return True
except Exception as e:
+89 -39
View File
@@ -12,10 +12,13 @@ from app.utils.plex_client import plex_client
from merge3 import Merge3
TEST_PLAYLIST_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "test_playlists")
SYNC_ARTIFACTS_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "data", "sync_artifacts")
)
# Backward-compat alias (older API / logs used this name).
TEST_PLAYLIST_DIR = SYNC_ARTIFACTS_DIR
class ConflictResolutionStrategy(str, Enum):
LOCAL_PRIORITY = "local_priority"
@@ -159,9 +162,37 @@ class MergeResult:
conflicts: list[dict]
def _ensure_test_dir(folder: str = TEST_PLAYLIST_DIR) -> str:
os.makedirs(folder, exist_ok=True)
return folder
def _ensure_dir(path: str) -> str:
os.makedirs(path, exist_ok=True)
return path
def _safe_folder_name(name: str, max_len: int = 120) -> str:
"""Make a filesystem-safe folder name (especially for Windows hosts).
This is used for artifacts folders persisted to the host.
"""
if not name:
return "(unnamed)"
# Windows-disallowed characters plus path separators.
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip()
if not cleaned:
cleaned = "(unnamed)"
if len(cleaned) > max_len:
cleaned = cleaned[:max_len].rstrip()
return cleaned
def _playlist_artifact_dir(artifacts_root: str, playlist_name: str) -> str:
return os.path.join(artifacts_root, _safe_folder_name(playlist_name))
def _artifact_file(playlist_folder: str, category: str, filename: str) -> str:
category_dir = _ensure_dir(os.path.join(playlist_folder, category))
return os.path.join(category_dir, filename)
def _read_text_if_exists(path: str) -> tuple[str, bool]:
@@ -174,26 +205,27 @@ def _read_text_if_exists(path: str) -> tuple[str, bool]:
return "", False
def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str:
_ensure_test_dir(folder)
file_path = os.path.join(folder, filename)
logger.info(f"Saving playlist to: {file_path}")
def _save_playlist_text(path: str, text: str) -> str:
"""Write text if changed (avoid triggering unnecessary file events)."""
new_content = save_paths(paths)
_ensure_dir(os.path.dirname(path))
# Check if content has changed before writing to avoid triggering unnecessary file events
if os.path.exists(file_path):
if os.path.exists(path):
try:
with open(file_path, "r", encoding="utf-8") as f:
current_content = f.read()
if current_content == new_content:
return file_path
with open(path, "r", encoding="utf-8") as file:
if file.read() == text:
return path
except OSError:
pass
with open(file_path, "w", encoding="utf-8") as file:
file.write(new_content)
return file_path
with open(path, "w", encoding="utf-8") as file:
file.write(text)
return path
def _save_playlist_paths(path: str, paths: Sequence[str]) -> str:
logger.info(f"Saving playlist to: {path}")
return _save_playlist_text(path, save_paths(paths))
def _normalize_inputs(
@@ -205,9 +237,9 @@ def _normalize_inputs(
local_paths = load_paths(local_text)
remote_paths = load_paths(remote_text)
_save_playlist_to_folder("base_playlist.m3u8", base_paths, folder)
_save_playlist_to_folder("local_input.m3u8", local_paths, folder)
_save_playlist_to_folder("remote_input.m3u8", remote_paths, folder)
_save_playlist_paths(_artifact_file(folder, "base", "base_prev.m3u8"), base_paths)
_save_playlist_paths(_artifact_file(folder, "inputs", "local_input.m3u8"), local_paths)
_save_playlist_paths(_artifact_file(folder, "inputs", "remote_input.m3u8"), remote_paths)
return base_paths, local_paths, remote_paths
@@ -275,14 +307,13 @@ def _write_results(
else:
remote_lines = list(merged_lines)
_save_playlist_to_folder("local_result.m3u8", local_lines, folder)
_save_playlist_to_folder("remote_result.m3u8", remote_lines, folder)
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder)
_save_playlist_paths(_artifact_file(folder, "outputs", "local_result.m3u8"), local_lines)
_save_playlist_paths(_artifact_file(folder, "outputs", "remote_result.m3u8"), remote_lines)
_save_playlist_paths(_artifact_file(folder, "base", "base_next.m3u8"), merged_lines)
def _write_delete_marker(playlist: str, folder: str) -> str:
_ensure_test_dir(folder)
marker_path = os.path.join(folder, "delete.txt")
marker_path = _artifact_file(folder, "meta", "delete.txt")
with open(marker_path, "w", encoding="utf-8") as file:
file.write(f"delete playlist {playlist}")
return marker_path
@@ -418,7 +449,7 @@ def merge_playlists(
local_text: str,
remote_text: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
test_folder: str = TEST_PLAYLIST_DIR,
test_folder: str = SYNC_ARTIFACTS_DIR,
compiled_rules: CompiledRegexRules | None = None,
) -> MergeResult:
"""Merge playlists using diff3 and resolve conflicts per strategy.
@@ -494,19 +525,42 @@ def _load_local_playlists(local_dir: str) -> dict[str, str]:
def _load_playlist_snapshots(playlist: str, folder: str) -> tuple[str, str, str, bool, bool]:
"""Load base/local/remote texts for a playlist from its test folder."""
playlist_folder = os.path.join(folder, playlist)
playlist_folder = _playlist_artifact_dir(folder, playlist)
# Prefer new organized layout.
base_text, base_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_next.m3u8")
os.path.join(playlist_folder, "base", "base_next.m3u8")
)
if not base_text:
alt_text, _ = _read_text_if_exists(
os.path.join(playlist_folder, "base_playlist.m3u8")
alt_text, alt_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base", "base_prev.m3u8")
)
base_text = base_text or alt_text
base_exists = base_exists or alt_exists
# Backward-compat: legacy flat file layout.
if not base_text:
legacy_text, legacy_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_next.m3u8")
)
base_text = base_text or legacy_text
base_exists = base_exists or legacy_exists
if not base_text:
legacy_text, legacy_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_playlist.m3u8")
)
base_text = base_text or legacy_text
base_exists = base_exists or legacy_exists
remote_text, remote_exists = _read_text_if_exists(
os.path.join(playlist_folder, "inputs", "remote_input.m3u8")
)
if not remote_text:
legacy_remote, legacy_exists = _read_text_if_exists(
os.path.join(playlist_folder, "remote_input.m3u8")
)
remote_text = remote_text or legacy_remote
remote_exists = remote_exists or legacy_exists
return base_text, remote_text, playlist_folder, remote_exists, base_exists
@@ -748,7 +802,7 @@ def _compile_simple_mapping_rules(simple_mappings: list[dict]) -> CompiledRegexR
def sync_all_playlists(
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR
local_dir: str, mode: SyncMode, test_folder: str = SYNC_ARTIFACTS_DIR
) -> list[PlaylistSyncResult]:
"""Synchronize all playlists that can be matched by name.
@@ -795,18 +849,14 @@ def sync_all_playlists(
legacy_compiled_rules = _compile_regex_rules(server_config.path_rules)
logger.info("Using legacy path_rules for preprocessing")
_ensure_test_dir(test_folder)
logger.info(f"Syncing playlists to test folder: {test_folder}")
_ensure_dir(test_folder)
logger.info(f"Sync artifacts folder: {test_folder}")
local_playlists = _load_local_playlists(local_dir)
remote_playlists = _fetch_remote_playlists()
playlist_names: set[str] = set(local_playlists.keys())
playlist_names.update(remote_playlists.keys())
for entry in os.scandir(test_folder):
if entry.is_dir():
playlist_names.add(entry.name)
results: list[PlaylistSyncResult] = []
for playlist in sorted(playlist_names):
+3 -7
View File
@@ -79,9 +79,7 @@ class PlexClient:
# Update the base URL and connection status
self.base_url = build_plex_url(scheme, url, port)
self.connected = True
logger.info(
f"Connected to Plex server at {self.base_url} with token: {self.token}"
)
logger.info(f"Connected to Plex server at {self.base_url}.")
return self.server, self.token
except Exception as e:
logger.warning(f"Failed to connect to Plex server: {str(e)}")
@@ -106,9 +104,7 @@ class PlexClient:
self.token = account.authenticationToken
self.server = PlexServer(self.base_url, self.token, timeout=timeout)
logger.debug(
f"Connected to Plex server with username: {username}, token: {self.token}"
)
logger.debug(f"Connected to Plex server with username: {username}.")
return self.server, self.token
def _connect_with_token(
@@ -124,7 +120,7 @@ class PlexClient:
self.base_url = build_plex_url(scheme, url, port)
self.server = PlexServer(self.base_url, token, timeout=timeout)
logger.debug(f"Connected to Plex server with token: {token}")
logger.debug("Connected to Plex server with token.")
return self.server, token
def _connect_check(self):
+88 -5
View File
@@ -2,6 +2,9 @@ import threading
import asyncio
import json
import os
import hashlib
import re
import time
from datetime import datetime
from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode
@@ -19,6 +22,23 @@ class SyncManager:
self._last_error = None
self._listeners = [] # List of asyncio.Queue
self._loop = None
# Suppress watcher events briefly after we write/delete local playlists.
# This prevents feedback loops where a sync triggers another sync.
self._watcher_suppress_until = 0.0
# Tunable defaults (seconds). Keep short to avoid missing real user edits.
self._watcher_suppress_after_write_seconds = 2.5
self._watcher_suppress_after_sync_seconds = 2.5
def suppress_watcher_events(self, seconds: float):
now = time.monotonic()
with self._lock:
self._watcher_suppress_until = max(self._watcher_suppress_until, now + float(seconds))
@property
def is_watcher_suppressed(self) -> bool:
with self._lock:
return time.monotonic() < self._watcher_suppress_until
def set_event_loop(self, loop):
self._loop = loop
@@ -76,6 +96,11 @@ class SyncManager:
self._is_syncing = True
self._last_status = "syncing"
self._last_error = None
# Preemptively suppress watcher in case the poller notices changes right after.
self._watcher_suppress_until = max(
self._watcher_suppress_until,
time.monotonic() + self._watcher_suppress_after_sync_seconds,
)
self._notify_listeners()
logger.info(f"Starting sync (Source: {trigger_source})...")
@@ -136,16 +161,17 @@ class SyncManager:
try:
if action == "synced":
# 1. Write Local
local_result_path = os.path.join(output_dir, "local_result.m3u8")
local_result_path = os.path.join(output_dir, "outputs", "local_result.m3u8")
if os.path.exists(local_result_path):
tracks = load_local_playlist(local_result_path)
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
# Ensure directory exists
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
write_local_playlist(dest_path, tracks)
# 2. Write Remote (Plex)
remote_result_path = os.path.join(output_dir, "remote_result.m3u8")
remote_result_path = os.path.join(output_dir, "outputs", "remote_result.m3u8")
if os.path.exists(remote_result_path):
tracks = load_local_playlist(remote_result_path)
if server_config.library_name:
@@ -156,10 +182,12 @@ class SyncManager:
elif action == "deleted":
# Delete Local
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
delete_local_playlist(dest_path)
# Also check for .m3u
dest_path_m3u = os.path.join(server_config.local_path, f"{playlist_name}.m3u")
dest_path_m3u = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u")
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
delete_local_playlist(dest_path_m3u)
# Delete Remote
@@ -167,8 +195,63 @@ class SyncManager:
except Exception as e:
logger.error(f"Error applying sync result for playlist {playlist_name}: {e}")
@staticmethod
def _safe_local_playlist_path(local_dir: str, playlist_name: str, extension: str) -> str:
base_dir = os.path.abspath(local_dir or "")
if not base_dir:
raise ValueError("Local playlist directory is not configured")
original = (playlist_name or "").strip()
# Drop any path components.
name = os.path.basename(original)
# Remove control chars.
name = re.sub(r"[\x00-\x1f\x7f]", "_", name)
# Replace path separators and Windows-invalid characters.
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip().strip(". ")
windows_reserved = {
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}
needs_hash = False
if not cleaned:
cleaned = "playlist"
needs_hash = True
if cleaned.upper() in windows_reserved:
needs_hash = True
if cleaned != original:
needs_hash = True
cleaned = cleaned[:160].rstrip().strip(". ")
if not cleaned:
cleaned = "playlist"
needs_hash = True
if needs_hash:
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
cleaned = f"{cleaned}__{digest}"
filename = f"{cleaned}{extension}"
candidate = os.path.abspath(os.path.join(base_dir, filename))
# Ensure the final path stays within base_dir.
if os.path.commonpath([base_dir, candidate]) != base_dir:
raise ValueError("Refusing to write outside local playlist directory")
return candidate
def _complete_sync(self, status, error=None):
now = time.monotonic()
with self._lock:
# Keep watcher suppression a bit after sync finishes, since PollingObserver
# may detect file changes on the next polling tick.
self._watcher_suppress_until = max(
self._watcher_suppress_until,
now + self._watcher_suppress_after_sync_seconds,
)
self._last_status = status
self._last_error = error
self._last_sync_time = datetime.now()
+17 -3
View File
@@ -22,22 +22,36 @@ class PlaylistEventHandler(FileSystemEventHandler):
if event.is_directory:
return
# For moved events, the interesting path is the destination.
event_path = getattr(event, "dest_path", None) if event.event_type == "moved" else event.src_path
if not event_path:
return
# Filter out noisy events. Only listen to actual changes.
# 'opened' and 'closed' (without write) are read events and should be ignored.
if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return
# Ignore temporary files or hidden files
filename = os.path.basename(event.src_path)
filename = os.path.basename(event_path)
if filename.startswith('.'):
return
# Only watch playlist files to avoid noisy triggers.
if not filename.lower().endswith((".m3u", ".m3u8")):
return
# Prevent feedback loops: if sync is in progress, ignore events
if sync_manager.is_syncing:
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event.src_path} because sync is in progress.")
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event_path} because sync is in progress.")
return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event.src_path}")
# Prevent feedback loops: ignore events right after sync writes/deletes.
if getattr(sync_manager, "is_watcher_suppressed", False):
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event_path} because watcher is suppressed.")
return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event_path}")
self.trigger_sync()
def trigger_sync(self):
-15
View File
@@ -1,15 +0,0 @@
services:
plex-playlist-sync:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
ports:
- "8888:8080"
volumes:
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
- PATH_TO_YOUR_BACKUP:/app/backup
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- LOG_LEVEL=INFO
- TZ=${TZ:-Asia/Tokyo}
restart: unless-stopped
+36
View File
@@ -0,0 +1,36 @@
services:
plex-playlist-sync:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --no-server-header
ports:
- "8888:8080"
volumes:
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
- PATH_TO_DATA/backup:/app/data/backup
- PATH_TO_DATA/config:/app/data/config
- PATH_TO_DATA/sync_artifacts:/app/data/sync_artifacts
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- PLEXPLAYLISTSYNC_CONFIG_PATH=/app/data/config/config.json
- PLEXPLAYLISTSYNC_BACKUP_DIR=/app/data/backup
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# CORS (default: disabled / same-origin only)
# Comma-separated list or JSON array. Example:
# - PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# - PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=["https://your.domain"]
# Use '*' only if you understand the risk (credentials will be forced off).
- PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=${PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS:-}
# Optional: allow cookies/credentials for allowed origins (ignored when origins contains '*')
- PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS=${PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS:-0}
# Optional API auth (protects /api/*).
# Set PLEXPLAYLISTSYNC_AUTH_ENABLED=1 to enable.
- PLEXPLAYLISTSYNC_AUTH_ENABLED=${PLEXPLAYLISTSYNC_AUTH_ENABLED:-0}
- PLEXPLAYLISTSYNC_AUTH_USERNAME=${PLEXPLAYLISTSYNC_AUTH_USERNAME:-}
- PLEXPLAYLISTSYNC_AUTH_PASSWORD=${PLEXPLAYLISTSYNC_AUTH_PASSWORD:-}
# Optional: stable signing secret for tokens (recommended if auth enabled).
- PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET=${PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET:-}
# Optional: token TTL seconds
- PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS=${PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS:-86400}
restart: unless-stopped
+140 -8
View File
@@ -15,8 +15,9 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal';
import LoginScreen from './components/LoginScreen';
import OverflowMarquee from './components/OverflowMarquee';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
@@ -115,6 +116,12 @@ const useStripeAnimation = (syncState: SyncState) => {
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
// Auth (optional; controlled by backend /api/auth/config)
const [authReady, setAuthReady] = useState(false);
const [authEnabled, setAuthEnabled] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState('');
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -171,6 +178,75 @@ const App: React.FC = () => {
retentionCount: 5
});
useEffect(() => {
let cancelled = false;
const initAuth = async () => {
try {
const cfg = await apiService.getAuthConfig();
const enabled = cfg.status === 'success' ? !!cfg.data.enabled : false;
if (cancelled) return;
setAuthEnabled(enabled);
if (!enabled) {
setIsAuthenticated(true);
setAuthReady(true);
return;
}
const savedToken = localStorage.getItem('plexsync-token');
const savedUser = localStorage.getItem('plexsync-username');
if (!savedToken || !savedUser) {
setIsAuthenticated(false);
setCurrentUser('');
setAuthReady(true);
return;
}
const me = await apiService.me();
if (me.status === 'success') {
setIsAuthenticated(true);
setCurrentUser(me.data.username || savedUser);
} else {
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
}
} catch {
// If auth discovery fails, fall back to no-auth to keep local dev workable.
setAuthEnabled(false);
setIsAuthenticated(true);
} finally {
if (!cancelled) setAuthReady(true);
}
};
initAuth();
return () => {
cancelled = true;
};
}, []);
const handleLoginSuccess = (token: string, username: string) => {
localStorage.setItem('plexsync-token', token);
localStorage.setItem('plexsync-username', username);
setCurrentUser(username);
setIsAuthenticated(true);
};
const handleLogout = async () => {
try {
await apiService.logout();
} catch {
// ignore
}
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
};
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -299,6 +375,7 @@ const App: React.FC = () => {
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
@@ -310,7 +387,7 @@ const App: React.FC = () => {
}
setLoadingLocal(false);
localAbortRef.current = null;
}, [localPath]);
}, [authReady, isAuthenticated, localPath]);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
@@ -323,6 +400,7 @@ const App: React.FC = () => {
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
@@ -344,7 +422,7 @@ const App: React.FC = () => {
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, []);
}, [authReady, isAuthenticated]);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
@@ -357,13 +435,15 @@ const App: React.FC = () => {
// Load persisted configuration
useEffect(() => {
if (!authReady || !isAuthenticated) return;
loadSettings();
loadSchedule();
loadBackupSettings();
}, [loadSettings, loadSchedule, loadBackupSettings]);
}, [authReady, isAuthenticated, loadSettings, loadSchedule, loadBackupSettings]);
// Initial Load
useEffect(() => {
if (!authReady || !isAuthenticated) return;
refreshLocal();
refreshCloud();
return () => {
@@ -371,7 +451,7 @@ const App: React.FC = () => {
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [refreshLocal, refreshCloud]);
}, [authReady, isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
@@ -397,6 +477,7 @@ const App: React.FC = () => {
// Handle Sync Trigger
const handleSyncTrigger = async () => {
if (!authReady || !isAuthenticated) return;
if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING);
@@ -423,7 +504,16 @@ const App: React.FC = () => {
// SSE for sync status
useEffect(() => {
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`);
if (!authReady || !isAuthenticated) return;
const base = `${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`;
const url = new URL(base, window.location.origin);
if (authEnabled) {
const token = localStorage.getItem('plexsync-token');
if (!token) return;
url.searchParams.set('access_token', token);
}
const eventSource = new EventSource(url.toString());
eventSource.onmessage = (event) => {
try {
@@ -490,7 +580,24 @@ const App: React.FC = () => {
return () => {
eventSource.close();
};
}, [syncState, refreshLocal, refreshCloud, addToast]);
}, [authReady, isAuthenticated, authEnabled, syncState, refreshLocal, refreshCloud, addToast]);
if (!authReady) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 text-gray-200">
{t('common.loading')}
</div>
);
}
if (authEnabled && !isAuthenticated) {
return (
<LoginScreen
onLoginSuccess={handleLoginSuccess}
onLoginError={(msg) => addToast(msg)}
/>
);
}
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo);
@@ -671,7 +778,7 @@ const App: React.FC = () => {
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
@@ -725,6 +832,7 @@ const App: React.FC = () => {
</div>
</div>
<div className="flex items-center gap-4">
{/* Language Switcher */}
<div className="relative">
<button
@@ -750,6 +858,18 @@ const App: React.FC = () => {
>
Español
</button>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</div>
</>
)}
@@ -767,6 +887,18 @@ const App: React.FC = () => {
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
{/* Logout (rightmost) */}
{authEnabled && isAuthenticated && (
<button
onClick={handleLogout}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('auth.logout')}
>
<LogOut size={18} />
</button>
)}
</div>
</div>
</>
) : (
+182
View File
@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { useLanguage } from '../LanguageContext';
import { apiService } from '../services/api';
import { Lock, User, Loader2, Languages, ArrowRight, ArrowLeftRight } from 'lucide-react';
import type { LoginCredentials } from '../types';
interface LoginScreenProps {
onLoginSuccess: (token: string, username: string) => void;
onLoginError: (msg: string) => void;
}
const LoginScreen: React.FC<LoginScreenProps> = ({ onLoginSuccess, onLoginError }) => {
const { t, language, setLanguage } = useLanguage();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLocalError(null);
try {
const creds: LoginCredentials = { username, password };
const response = await apiService.login(creds);
if (response.status === 'success') {
onLoginSuccess(response.data.token, response.data.username);
} else {
const errorMsg = response.message || t('auth.invalidCredentials');
setLocalError(errorMsg);
onLoginError(errorMsg);
}
} catch {
setLocalError(t('auth.invalidCredentials'));
} finally {
setIsLoading(false);
}
};
const currentLangLabel =
language === 'en' ? 'English'
: language === 'es' ? 'Español'
: language === 'chs' ? '简体中文'
: '繁體中文';
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black p-4">
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-plex-orange/10 rounded-full blur-[100px] opacity-20"></div>
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[100px] opacity-20"></div>
</div>
{/* Language Switcher (Top Right) */}
<div className="absolute top-6 right-6 z-20">
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center space-x-2 px-3 py-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 border border-gray-700 hover:border-gray-600 text-gray-300 transition-all backdrop-blur-sm"
>
<Languages size={16} />
<span className="text-sm font-medium">{currentLangLabel}</span>
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</div>
</>
)}
</div>
</div>
{/* Main Card */}
<div className="w-full max-w-md bg-gray-900/60 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-2xl p-8 z-10">
<div className="text-center mb-8 flex flex-col items-center">
<div className="inline-flex items-center justify-center p-3 rounded-xl bg-gradient-to-br from-plex-orange to-yellow-600 shadow-lg shadow-plex-orange/20 mb-4">
<ArrowLeftRight size={32} strokeWidth={2.5} className="text-gray-900" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
<form onSubmit={handleLogin} className="space-y-4">
{localError && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center justify-center">
{localError}
</div>
)}
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.username')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="username"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.password')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="password"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || !username || !password}
className={`w-full h-12 mt-6 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLoading
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-plex-orange text-gray-900 hover:bg-yellow-500 active:scale-[0.98]'
}`}
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
<span>{t('auth.loggingIn')}</span>
</>
) : (
<>
<span>{t('auth.loginBtn')}</span>
<ArrowRight size={18} />
</>
)}
</button>
</form>
<div className="mt-8 pt-6 border-t border-gray-700/50 text-center">
<p className="text-[10px] text-gray-600">© PMS Playlist Sync</p>
</div>
</div>
</div>
);
};
export default LoginScreen;
+6 -6
View File
@@ -78,7 +78,7 @@ const STRATEGIES: StrategyOption[] = [
}
];
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const WEEK_DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6];
// Color Theme Variables for Mapping Editors
const MAPPING_THEME = {
@@ -818,12 +818,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Middle Row: Full Width Capsules */}
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
{WEEK_DAYS.map((day, index) => {
const isSelected = localSchedule.weeklyDays.includes(index);
{WEEK_DAY_INDEXES.map((dayIndex) => {
const isSelected = localSchedule.weeklyDays.includes(dayIndex);
return (
<button
key={index}
onClick={() => toggleWeekDay(index)}
key={dayIndex}
onClick={() => toggleWeekDay(dayIndex)}
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
first:rounded-l-lg last:rounded-r-lg
${isSelected
@@ -832,7 +832,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
}
`}
>
{day}
{t(`schedule.weekdaysNarrow.${dayIndex}`)}
</button>
)
})}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title>
<title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
+172
View File
@@ -0,0 +1,172 @@
export const cht = {
app: {
title: 'PlexSync',
manager: '管理',
footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。',
},
auth: {
title: '登入',
subtitle: '登入後管理播放清單同步',
username: '使用者名稱',
password: '密碼',
loginBtn: '登入',
logout: '登出',
loggingIn: '驗證中…',
invalidCredentials: '使用者名稱或密碼錯誤',
welcome: '歡迎,{user}',
},
common: {
save: '儲存',
cancel: '取消',
revert: '還原',
delete: '刪除',
done: '完成',
loading: '載入中…',
refresh: '重新整理',
close: '關閉',
none: '無',
disabled: '已停用',
add: '新增',
switchLanguage: '切換語言',
},
server: {
local: '本機伺服器',
cloud: '雲端伺服器',
playlists: '{count} 個播放清單',
notConnected: '未連線',
connectionFailed: '連線失敗',
connecting: '連線中…',
waiting: '等待中…',
syncing: '同步中…',
noPlaylists: '找不到播放清單。',
cancelRefresh: '取消重新整理',
refreshPlaylists: '重新整理播放清單',
},
playlist: {
trackCount: '曲目數',
lastUpdated: '上次更新',
},
dashboard: {
mapping: '路徑對應',
backup: '備份',
autoSync: '自動同步',
watch: '監看',
watchModeActive: '監看模式:啟用',
watchModeDisabled: '監看模式:停用',
notSet: '未設定',
retain: '保留:{count}',
keep: '保留 {count}',
connected: '已連線至 Plex',
disconnected: '未連線',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: '同步策略',
localOverwrite: {
label: '本機覆寫',
desc: '本機播放清單完全覆寫雲端。(無 Diff)',
},
cloudOverwrite: {
label: '雲端覆寫',
desc: '雲端播放清單完全覆寫本機。(無 Diff)',
},
mergeLocal: {
label: '雙向合併(本機優先)',
desc: '合併兩端。衝突以本機版本為準。',
},
mergeCloud: {
label: '雙向合併(雲端優先)',
desc: '合併兩端。衝突以雲端版本為準。',
},
syncNow: '立即同步',
syncing: '同步進行中…',
saveWarning: '同步前請先儲存待處理的變更(備份/路徑對應)。',
},
mapping: {
title: '路徑對應',
simple: '簡易對應',
regex: 'Regex 規則',
simpleTitle: '路徑對應',
simpleSubtitle: '使用簡單字串比對將本機路徑對應到雲端路徑',
regexPre: '前處理(同步前)',
regexPost: '後處理(同步後 / 結果)',
localPath: '本機路徑',
cloudPath: '雲端路徑',
pattern: '模式',
replace: '取代',
saveRules: '儲存規則',
noRules: '尚未定義規則。',
},
backup: {
title: '備份保留',
enable: '啟用備份',
enableDesc: '變更前建立副本',
maxVersions: '保留的最大版本數:',
noAutoDelete: '不自動刪除',
autoDelete: '自動刪除最舊版本',
},
schedule: {
title: '排程任務',
cron: 'Cron',
daily: '每日',
weekly: '每週',
weekdaysNarrow: {
0: '日',
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
},
enableCron: '啟用 Cron 排程',
enableDaily: '啟用每日執行',
enableWeekly: '啟用每週執行',
watchLocal: '監看本機變更',
watchDesc: '本機播放清單更新時自動同步',
schedule: '排程',
notConfigured: '尚未設定',
today: '今天',
tomorrow: '明天',
},
connection: {
titleConnected: '伺服器已連線',
titleConnect: '連線 Plex 伺服器',
serverDetails: '伺服器詳細資訊',
authentication: '驗證',
protocol: '通訊協定',
address: 'IP 位址或網域',
port: '連接埠',
token: 'X-Plex-Token(選填)',
username: '使用者名稱 / 電子郵件',
password: '密碼',
advanced: '進階選項',
timeout: '連線逾時(秒)',
connectBtn: '連線伺服器',
connecting: '連線中…',
connectedSuccess: '連線成功',
selectLibrary: '選擇要同步的媒體庫',
},
toasts: {
localRefreshCancelled: '已取消本機重新整理。',
cloudRefreshCancelled: '已取消雲端重新整理。',
strategySaved: '已儲存選擇的策略「{strategy}」。',
strategySaveFailed: '儲存同步策略失敗。',
mappingSaved: '已儲存路徑對應規則。',
mappingSaveFailed: '儲存路徑對應規則失敗。',
backupSaved: '已儲存備份設定。',
backupFailed: '儲存備份設定失敗。',
scheduleDisabled: '已停用排程任務。',
scheduleEmpty: '已停用排程任務(Cron 為空)。',
scheduleStarted: '排程任務更新成功。',
scheduleFailed: '更新排程失敗。',
syncFailed: '同步失敗。請檢查連線。',
backgroundSyncSuccess: '背景同步已成功完成。',
backgroundSyncFailed: '背景同步失敗:{error}',
librarySwitched: '媒體庫已切換為 {library}',
connectedTo: '已成功連線到 {name}',
connectionCancelled: '使用者已取消連線。',
librarySaveFailed: '儲存媒體庫選擇失敗。',
},
};
+20
View File
@@ -4,6 +4,17 @@ export const en = {
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
auth: {
title: 'Login',
subtitle: 'Sign in to manage your playlist syncs',
username: 'Username',
password: 'Password',
loginBtn: 'Sign In',
logout: 'Logout',
loggingIn: 'Verifying...',
invalidCredentials: 'Invalid username or password',
welcome: 'Welcome, {user}',
},
common: {
save: 'Save',
cancel: 'Cancel',
@@ -100,6 +111,15 @@ export const en = {
cron: 'Cron',
daily: 'Daily',
weekly: 'Weekly',
weekdaysNarrow: {
0: 'S',
1: 'M',
2: 'T',
3: 'W',
4: 'T',
5: 'F',
6: 'S',
},
enableCron: 'Enable Cron Schedule',
enableDaily: 'Enable Daily Run',
enableWeekly: 'Enable Weekly Run',
+20
View File
@@ -4,6 +4,17 @@ export const es = {
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
auth: {
title: 'Iniciar Sesión',
subtitle: 'Ingrese para gestionar sus sincronizaciones',
username: 'Usuario',
password: 'Password',
loginBtn: 'Entrar',
logout: 'Salir',
loggingIn: 'Verificando...',
invalidCredentials: 'Usuario o contraseña incorrectos',
welcome: 'Bienvenido, {user}',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
@@ -100,6 +111,15 @@ export const es = {
cron: 'Cron',
daily: 'Diario',
weekly: 'Semanal',
weekdaysNarrow: {
0: 'D',
1: 'L',
2: 'M',
3: 'X',
4: 'J',
5: 'V',
6: 'S',
},
enableCron: 'Habilitar Cron',
enableDaily: 'Habilitar Ejecución Diaria',
enableWeekly: 'Habilitar Ejecución Semanal',
+172
View File
@@ -0,0 +1,172 @@
export const zh = {
app: {
title: 'PlexSync',
manager: '管理',
footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。',
},
auth: {
title: '登录',
subtitle: '登录后管理播放列表同步',
username: '用户名',
password: '密码',
loginBtn: '登录',
logout: '登出',
loggingIn: '验证中...',
invalidCredentials: '用户名或密码错误',
welcome: '欢迎,{user}',
},
common: {
save: '保存',
cancel: '取消',
revert: '恢复',
delete: '删除',
done: '完成',
loading: '加载中...',
refresh: '刷新',
close: '关闭',
none: '无',
disabled: '已禁用',
add: '添加',
switchLanguage: '切换语言',
},
server: {
local: '本地服务器',
cloud: '云端服务器',
playlists: '{count} 个播放列表',
notConnected: '未连接',
connectionFailed: '连接失败',
connecting: '正在连接...',
waiting: '等待中...',
syncing: '同步中...',
noPlaylists: '未找到播放列表。',
cancelRefresh: '取消刷新',
refreshPlaylists: '刷新播放列表',
},
playlist: {
trackCount: '曲目数',
lastUpdated: '最近更新',
},
dashboard: {
mapping: '路径映射',
backup: '备份',
autoSync: '自动同步',
watch: '监听',
watchModeActive: '监听模式:启用',
watchModeDisabled: '监听模式:禁用',
notSet: '未设置',
retain: '保留:{count}',
keep: '保留 {count}',
connected: '已连接 Plex',
disconnected: '未连接',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: '同步策略',
localOverwrite: {
label: '本地覆盖',
desc: '本地播放列表完全覆盖云端。(无 Diff)',
},
cloudOverwrite: {
label: '云端覆盖',
desc: '云端播放列表完全覆盖本地。(无 Diff)',
},
mergeLocal: {
label: '双向合并(本地优先)',
desc: '合并两端。冲突以本地版本为准。',
},
mergeCloud: {
label: '双向合并(云端优先)',
desc: '合并两端。冲突以云端版本为准。',
},
syncNow: '立即同步',
syncing: '同步进行中...',
saveWarning: '同步前请先保存待处理的更改(备份/路径映射)。',
},
mapping: {
title: '路径映射',
simple: '简单映射',
regex: '正则规则',
simpleTitle: '路径映射',
simpleSubtitle: '使用简单字符串匹配将本地路径映射到云端路径',
regexPre: '预处理(同步前)',
regexPost: '后处理(同步后 / 结果)',
localPath: '本地路径',
cloudPath: '云端路径',
pattern: '模式',
replace: '替换',
saveRules: '保存规则',
noRules: '尚未定义规则。',
},
backup: {
title: '备份保留',
enable: '启用备份',
enableDesc: '在更改前创建副本',
maxVersions: '保留的最大版本数:',
noAutoDelete: '不自动删除',
autoDelete: '自动删除最旧版本',
},
schedule: {
title: '定时任务',
cron: 'Cron',
daily: '每日',
weekly: '每周',
weekdaysNarrow: {
0: '日',
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
},
enableCron: '启用 Cron 计划',
enableDaily: '启用每日运行',
enableWeekly: '启用每周运行',
watchLocal: '监听本地更改',
watchDesc: '本地播放列表更新时自动同步',
schedule: '计划',
notConfigured: '未配置',
today: '今天',
tomorrow: '明天',
},
connection: {
titleConnected: '服务器已连接',
titleConnect: '连接 Plex 服务器',
serverDetails: '服务器详情',
authentication: '认证',
protocol: '协议',
address: 'IP 地址或域名',
port: '端口',
token: 'X-Plex-Token(可选)',
username: '用户名 / 邮箱',
password: '密码',
advanced: '高级选项',
timeout: '连接超时(秒)',
connectBtn: '连接服务器',
connecting: '连接中...',
connectedSuccess: '连接成功',
selectLibrary: '选择要同步的媒体库',
},
toasts: {
localRefreshCancelled: '本地刷新已取消。',
cloudRefreshCancelled: '云端刷新已取消。',
strategySaved: '已保存选择的策略“{strategy}”。',
strategySaveFailed: '保存同步策略失败。',
mappingSaved: '已保存路径映射规则。',
mappingSaveFailed: '保存路径映射规则失败。',
backupSaved: '已保存备份设置。',
backupFailed: '保存备份设置失败。',
scheduleDisabled: '已禁用定时任务。',
scheduleEmpty: '已禁用定时任务(Cron 为空)。',
scheduleStarted: '定时任务更新成功。',
scheduleFailed: '更新定时任务失败。',
syncFailed: '同步失败。请检查连接。',
backgroundSyncSuccess: '后台同步已成功完成。',
backgroundSyncFailed: '后台同步失败:{error}',
librarySwitched: '媒体库已切换为 {library}',
connectedTo: '已成功连接到 {name}',
connectionCancelled: '用户已取消连接。',
librarySaveFailed: '保存媒体库选择失败。',
},
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "PlexSync Manager",
"name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+58 -14
View File
@@ -1,7 +1,24 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings, BackupSettings } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings, BackupSettings, LoginCredentials, AuthResponse } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
const getAuthToken = (): string | null => {
try {
return localStorage.getItem('plexsync-token');
} catch {
return null;
}
};
const authFetch = (input: RequestInfo | URL, init: RequestInit = {}) => {
const token = getAuthToken();
const headers = new Headers(init.headers || {});
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
};
const MODE_TO_STRATEGY: Record<string, SyncStrategy> = {
local_force: SyncStrategy.LOCAL_OVERWRITE,
remote_force: SyncStrategy.CLOUD_OVERWRITE,
@@ -20,6 +37,9 @@ const handleResponse = async <T>(response: Response): Promise<ApiResponse<T>> =>
try {
const data = await response.json();
if (!response.ok) {
if (response.status === 401) {
return { data: data as T, status: 'error', message: 'Unauthorized' };
}
return { data: data as T, status: 'error', message: (data as any)?.detail || response.statusText };
}
return { data, status: 'success' };
@@ -97,8 +117,32 @@ const pathMappingToApi = (config: PathMappingConfig) => {
};
export const apiService = {
async getAuthConfig(): Promise<ApiResponse<{ enabled: boolean }>> {
const response = await fetch(`${API_BASE}/api/auth/config`);
return handleResponse(response);
},
async login(creds: LoginCredentials): Promise<ApiResponse<AuthResponse>> {
const response = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds),
});
return handleResponse(response);
},
async me(): Promise<ApiResponse<{ username: string }>> {
const response = await authFetch(`${API_BASE}/api/auth/me`);
return handleResponse(response);
},
async logout(): Promise<ApiResponse<{ status: string }>> {
const response = await authFetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
return handleResponse(response);
},
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>> {
const response = await fetch(`${API_BASE}/api/settings`);
const response = await authFetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const mode = result.data.sync_mode as string;
@@ -118,7 +162,7 @@ export const apiService = {
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
const payload = { mode: STRATEGY_TO_MODE[strategy] };
const response = await fetch(`${API_BASE}/api/settings/sync-mode`, {
const response = await authFetch(`${API_BASE}/api/settings/sync-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -128,7 +172,7 @@ export const apiService = {
async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = pathMappingToApi(config);
const response = await fetch(`${API_BASE}/api/settings/path-mapping`, {
const response = await authFetch(`${API_BASE}/api/settings/path-mapping`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -137,7 +181,7 @@ export const apiService = {
},
async updateLibrary(libraryName: string): Promise<ApiResponse<{ library_name: string }>> {
const response = await fetch(`${API_BASE}/api/settings/library`, {
const response = await authFetch(`${API_BASE}/api/settings/library`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_name: libraryName }),
@@ -146,12 +190,12 @@ export const apiService = {
},
async getScheduleSettings(): Promise<ApiResponse<ScheduleSettings & { nextRun?: string }>> {
const response = await fetch(`${API_BASE}/api/schedule`);
const response = await authFetch(`${API_BASE}/api/schedule`);
return handleResponse(response);
},
async saveScheduleSettings(settings: ScheduleSettings): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/schedule`, {
const response = await authFetch(`${API_BASE}/api/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
@@ -164,7 +208,7 @@ export const apiService = {
if (serverType === ServerType.LOCAL && localPath) {
params.append('local_path', localPath);
}
const response = await fetch(`${API_BASE}/api/playlists?${params.toString()}`, { signal });
const response = await authFetch(`${API_BASE}/api/playlists?${params.toString()}`, { signal });
const result = await handleResponse<any>(response);
if (result.status === 'success' && (result.data as any)?.playlists) {
return { data: (result.data.playlists as any[]).map(mapPlaylist), status: 'success' };
@@ -173,7 +217,7 @@ export const apiService = {
},
async getServerStatus(signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> {
const response = await fetch(`${API_BASE}/api/server`, { signal });
const response = await authFetch(`${API_BASE}/api/server`, { signal });
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const info = result.data.serverInfo || {};
@@ -194,7 +238,7 @@ export const apiService = {
},
async connectToPlex(settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string; serverInfo: PlexServerConnection }>> {
const response = await fetch(`${API_BASE}/api/connect`, {
const response = await authFetch(`${API_BASE}/api/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -219,7 +263,7 @@ export const apiService = {
},
async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/sync`, {
const response = await authFetch(`${API_BASE}/api/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -231,12 +275,12 @@ export const apiService = {
},
async getSyncStatus(): Promise<ApiResponse<{ is_syncing: boolean; last_sync_time: string | null; status: string; error: string | null }>> {
const response = await fetch(`${API_BASE}/api/sync/status`);
const response = await authFetch(`${API_BASE}/api/sync/status`);
return handleResponse(response);
},
async getBackupSettings(): Promise<ApiResponse<BackupSettings>> {
const response = await fetch(`${API_BASE}/api/backup/settings`);
const response = await authFetch(`${API_BASE}/api/backup/settings`);
const result = await handleResponse<any>(response);
if (result.status === 'success') {
return {
@@ -251,7 +295,7 @@ export const apiService = {
},
async saveBackupSettings(settings: BackupSettings): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/backup/settings`, {
const response = await authFetch(`${API_BASE}/api/backup/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+4
View File
@@ -1,9 +1,13 @@
import { en } from './locales/en';
import { es } from './locales/es';
import { zh as chs } from './locales/zh';
import { cht } from './locales/cht';
export const translations = {
en,
es,
chs,
cht,
};
export type Language = keyof typeof translations;
+2 -1
View File
@@ -11,7 +11,8 @@
],
"skipLibCheck": true,
"types": [
"node"
"node",
"vite/client"
],
"moduleResolution": "bundler",
"isolatedModules": true,
+11
View File
@@ -116,3 +116,14 @@ export interface ApiResponse<T> {
status: 'success' | 'error';
message?: string;
}
export interface LoginCredentials {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
username: string;
expires_in?: number;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+86 -12
View File
@@ -1,4 +1,6 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api';
@@ -16,7 +18,8 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
import LoginScreen from './components/LoginScreen';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut, User } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
@@ -115,6 +118,12 @@ const useStripeAnimation = (syncState: SyncState) => {
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
// Auth State
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState('');
// App Data State
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -198,6 +207,16 @@ const App: React.FC = () => {
timeoutsRef.current[id] = dismissTimer;
};
// Check auth on mount
useEffect(() => {
const savedToken = localStorage.getItem('plexsync-token');
const savedUser = localStorage.getItem('plexsync-username');
if (savedToken && savedUser) {
setIsAuthenticated(true);
setCurrentUser(savedUser);
}
}, []);
// Effect to trigger the "slide down" animation
useEffect(() => {
const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
@@ -236,6 +255,7 @@ const App: React.FC = () => {
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (!isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
@@ -247,7 +267,7 @@ const App: React.FC = () => {
}
setLoadingLocal(false);
localAbortRef.current = null;
}, []);
}, [isAuthenticated]);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
@@ -260,6 +280,7 @@ const App: React.FC = () => {
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (!isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
@@ -281,7 +302,7 @@ const App: React.FC = () => {
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, []);
}, [isAuthenticated]);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
@@ -292,16 +313,18 @@ const App: React.FC = () => {
}
};
// Initial Load
// Initial Load (Only if Authenticated)
useEffect(() => {
if (isAuthenticated) {
refreshLocal();
refreshCloud();
}
return () => {
// Cleanup on unmount
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [refreshLocal, refreshCloud]);
}, [isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
@@ -364,13 +387,6 @@ const App: React.FC = () => {
// Timing Breakdown:
// T+0.0s: State is SUCCESS.
// - JS Animation loop detects change and begins decelerating speed from 56 -> 0 over 0.5s.
// - CSS opacity transitions Yellow -> Green over 0.3s.
// T+0.5s: Deceleration complete. Speed is 0. Background is static.
// We hold this static state for another 0.5s.
// T+1.0s: Total success duration complete. Disappear.
setTimeout(() => {
setSyncState(SyncState.IDLE);
refreshLocal();
@@ -390,6 +406,27 @@ const App: React.FC = () => {
refreshCloud();
};
const handleLoginSuccess = (token: string, username: string) => {
localStorage.setItem('plexsync-token', token);
localStorage.setItem('plexsync-username', username);
setIsAuthenticated(true);
setCurrentUser(username);
addToast(t('auth.welcome', { user: username }));
};
const handleLoginError = (msg: string) => {
// Toast handles error display, or LoginScreen internal state handles UI
addToast(msg);
};
const handleLogout = () => {
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
addToast(t('toasts.loggedOut'));
};
const getToastStyles = (toast: Toast): React.CSSProperties => {
if (toast.exiting || toast.entering) {
return {
@@ -563,6 +600,34 @@ const App: React.FC = () => {
const backupInfo = getBackupDisplayInfo(backupSettings);
// If not authenticated, show Login Screen
if (!isAuthenticated) {
return (
<>
{/* Render toasts over login screen if needed (e.g. login errors pushed to global toast) */}
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={getToastClasses()}
style={getToastStyles(toast)}
>
<ShieldCheck size={16} />
<span>{toast.message}</span>
<button
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
className="ml-2 hover:text-white transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
<LoginScreen onLoginSuccess={handleLoginSuccess} onLoginError={handleLoginError} />
</>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -724,6 +789,15 @@ const App: React.FC = () => {
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
{/* Logout Button */}
<button
onClick={handleLogout}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-red-400 hover:border-red-500/30 hover:bg-red-500/10 transition-all"
title={t('auth.logout') + ` (${currentUser})`}
>
<LogOut size={18} />
</button>
</div>
</>
) : (
+170
View File
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { useLanguage } from '../LanguageContext';
import { apiService } from '../services/api';
import { Lock, User, Loader2, Languages, ArrowRight, ArrowLeftRight } from 'lucide-react';
interface LoginScreenProps {
onLoginSuccess: (token: string, username: string) => void;
onLoginError: (msg: string) => void;
}
const LoginScreen: React.FC<LoginScreenProps> = ({ onLoginSuccess, onLoginError }) => {
const { t, language, setLanguage } = useLanguage();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLocalError(null);
try {
// Mock credentials: admin / password
const response = await apiService.login({ username, password });
if (response.status === 'success') {
onLoginSuccess(response.data.token, response.data.username);
} else {
const errorMsg = response.message || t('auth.invalidCredentials');
setLocalError(errorMsg);
onLoginError(errorMsg);
}
} catch (err) {
setLocalError(t('auth.invalidCredentials'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black p-4">
{/* Background decoration */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-plex-orange/10 rounded-full blur-[100px] opacity-20"></div>
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[100px] opacity-20"></div>
</div>
{/* Language Switcher (Top Right) */}
<div className="absolute top-6 right-6 z-20">
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center space-x-2 px-3 py-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 border border-gray-700 hover:border-gray-600 text-gray-300 transition-all backdrop-blur-sm"
>
<Languages size={16} />
<span className="text-sm font-medium">{language === 'en' ? 'English' : 'Español'}</span>
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 overflow-hidden animate-in fade-in slide-in-from-top-2">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
</div>
{/* Main Card */}
<div className="w-full max-w-md bg-gray-900/60 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-2xl p-8 z-10 animate-in zoom-in-95 duration-300">
<div className="text-center mb-8 flex flex-col items-center">
<div className="inline-flex items-center justify-center p-3 rounded-xl bg-gradient-to-br from-plex-orange to-yellow-600 shadow-lg shadow-plex-orange/20 mb-4">
<ArrowLeftRight size={32} strokeWidth={2.5} className="text-gray-900" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
<form onSubmit={handleLogin} className="space-y-4">
{localError && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center justify-center">
{localError}
</div>
)}
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.username')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="admin"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.password')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="password"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || !username || !password}
className={`w-full h-12 mt-6 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLoading
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-plex-orange text-gray-900 hover:bg-yellow-500 hover:shadow-plex-orange/30 active:scale-[0.98]'
}`}
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
<span>{t('auth.loggingIn')}</span>
</>
) : (
<>
<span>{t('auth.loginBtn')}</span>
<ArrowRight size={18} />
</>
)}
</button>
</form>
<div className="mt-8 pt-6 border-t border-gray-700/50 text-center">
<p className="text-[10px] text-gray-600">
© PMS Playlist Sync
</p>
</div>
</div>
</div>
);
};
export default LoginScreen;
+15
View File
@@ -1,4 +1,6 @@
export const en = {
app: {
// title and manager are no longer used for branding
@@ -6,6 +8,17 @@ export const en = {
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
auth: {
title: 'Login',
subtitle: 'Sign in to manage your playlist syncs',
username: 'Username',
password: 'Password',
loginBtn: 'Sign In',
logout: 'Logout',
loggingIn: 'Verifying...',
invalidCredentials: 'Invalid username or password',
welcome: 'Welcome, {user}',
},
common: {
save: 'Save',
cancel: 'Cancel',
@@ -143,5 +156,7 @@ export const en = {
librarySwitched: 'Library switched to {library}',
connectedTo: 'Successfully connected to {name}',
connectionCancelled: 'Connection cancelled by user.',
loginFailed: 'Login failed. Please check credentials.',
loggedOut: 'Successfully logged out.',
}
};
+15
View File
@@ -1,4 +1,6 @@
export const es = {
app: {
// title and manager are no longer used for branding
@@ -6,6 +8,17 @@ export const es = {
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
auth: {
title: 'Iniciar Sesión',
subtitle: 'Ingrese para gestionar sus sincronizaciones',
username: 'Usuario',
password: 'Password',
loginBtn: 'Entrar',
logout: 'Salir',
loggingIn: 'Verificando...',
invalidCredentials: 'Usuario o contraseña incorrectos',
welcome: 'Bienvenido, {user}',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
@@ -143,5 +156,7 @@ export const es = {
librarySwitched: 'Librería cambiada a {library}',
connectedTo: 'Conectado exitosamente a {name}',
connectionCancelled: 'Conexión cancelada por usuario.',
loginFailed: 'Fallo de inicio de sesión. Verifique credenciales.',
loggedOut: 'Sesión cerrada exitosamente.',
}
};
+25 -1
View File
@@ -3,7 +3,9 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode, BackupSettings, LoginCredentials, AuthResponse } from '../types';
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
const SIMULATE_DELAY_MS = 800;
@@ -229,5 +231,27 @@ export const apiService = {
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
}, 500);
});
},
// Mock Login - In a real app this would POST to a backend
login: async (creds: LoginCredentials): Promise<ApiResponse<AuthResponse>> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Hardcoded mock credentials for demonstration
if (creds.username === 'admin' && creds.password === 'password') {
resolve({
data: { token: 'mock-jwt-token-123', username: 'admin' },
status: 'success',
message: 'Login successful'
});
} else {
resolve({
data: { token: '', username: '' },
status: 'error',
message: 'Invalid credentials'
});
}
}, 1000);
});
}
};
+10
View File
@@ -110,3 +110,13 @@ export interface ApiResponse<T> {
status: 'success' | 'error';
message?: string;
}
export interface LoginCredentials {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
username: string;
}