7 Commits

25 changed files with 965 additions and 149 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
db.sqlite3-journal db.sqlite3-journal
# App runtime logs
app/logs/
# Flask stuff: # Flask stuff:
instance/ instance/
.webassets-cache .webassets-cache
@@ -176,3 +179,11 @@ cython_debug/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# data
data/*
playlists/*
docker-compose.yml
# Local dev config may contain Plex token
app/config.json
-29
View File
@@ -1,29 +0,0 @@
{
"theme": "auto",
"token": "",
"server_url": "",
"server_scheme": "http",
"server_port": "32400",
"timeout": 9,
"library_name": "",
"sync_mode": "local_force",
"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,
"backup_enabled": false,
"backup_retention_count": 5
}
+128 -3
View File
@@ -1,11 +1,12 @@
import os import os
import json
from datetime import datetime from datetime import datetime
from typing import Sequence from typing import Sequence
from fastapi import FastAPI, Form, HTTPException, Query, Request from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import asyncio 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -17,9 +18,133 @@ from app.utils.playlist_merge import SyncMode, SYNC_ARTIFACTS_DIR, sync_all_play
from app.utils.plex_client import plex_client 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.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.sync_manager import sync_manager
from app.utils.auth import load_auth_config, issue_token, verify_token
app = FastAPI() 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") @app.on_event("startup")
async def startup_event(): async def startup_event():
sync_manager.set_event_loop(asyncio.get_running_loop()) sync_manager.set_event_loop(asyncio.get_running_loop())
@@ -27,8 +152,8 @@ async def startup_event():
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_CORS_ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=_CORS_ALLOW_CREDENTIALS,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
+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
+26 -2
View File
@@ -1,5 +1,7 @@
import os import os
import zipfile import zipfile
import hashlib
import re
from datetime import datetime from datetime import datetime
from typing import List from typing import List
from app.utils.logger import logger from app.utils.logger import logger
@@ -19,6 +21,28 @@ BACKUP_DIR = os.path.abspath(
) )
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(): def ensure_backup_dir():
"""Ensure the backup directory exists.""" """Ensure the backup directory exists."""
if not os.path.exists(BACKUP_DIR): if not os.path.exists(BACKUP_DIR):
@@ -118,7 +142,7 @@ def backup_local_playlists(local_path: str) -> str | None:
# Get the playlist name without extension and add .m3u8 extension # Get the playlist name without extension and add .m3u8 extension
playlist_name = os.path.splitext(entry.name)[0] 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 # Write to zip
zipf.writestr(archive_name, content) zipf.writestr(archive_name, content)
@@ -217,7 +241,7 @@ def backup_cloud_playlists(library_name: str) -> str | None:
if len(lines) > 1: # More than just #EXTM3U if len(lines) > 1: # More than just #EXTM3U
content = "\n".join(lines) 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) zipf.writestr(archive_name, content)
playlist_count += 1 playlist_count += 1
+22 -3
View File
@@ -2,6 +2,25 @@ import json
import os import os
from app.utils.logger import logger 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" DEFAULT_SYNC_MODE = "merge_local_primary"
LOCAL_PLAYLISTS_FOLDER = "playlists" LOCAL_PLAYLISTS_FOLDER = "playlists"
DEFAULT_PATH_MAPPING = { DEFAULT_PATH_MAPPING = {
@@ -62,7 +81,7 @@ class ServerConfig:
try: try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f: with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
logger.debug(f"Loaded server config: {config}") logger.debug(f"Loaded server config: {_redact_for_log(config)}")
except FileNotFoundError: except FileNotFoundError:
# 如果配置文件不存在,使用默认值 # 如果配置文件不存在,使用默认值
self.save() self.save()
@@ -110,7 +129,7 @@ class ServerConfig:
self.backup_enabled = config.get("backup_enabled", False) self.backup_enabled = config.get("backup_enabled", False)
self.backup_retention_count = config.get("backup_retention_count", 5) self.backup_retention_count = config.get("backup_retention_count", 5)
logger.info(f"Server config loaded.") 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): def save(self):
_ensure_parent_dir(CONFIG_PATH) _ensure_parent_dir(CONFIG_PATH)
@@ -137,7 +156,7 @@ class ServerConfig:
with open(CONFIG_PATH, "w", encoding="utf-8") as f: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False) json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved.") 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: def set_url(self, url: str) -> None:
self.url = url self.url = url
+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. bool: True if successful, False otherwise.
""" """
try: try:
with open(playlist_path, 'w', encoding="utf-8") as file: desired_lines = ["#EXTM3U\n"] + [f"{track}\n" for track in tracks]
file.write("#EXTM3U\n") desired_content = "".join(desired_lines)
for track in tracks:
file.write(f"{track}\n") # 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}") logger.info(f"Written {len(tracks)} songs to the playlist: {playlist_path}")
return True return True
except Exception as e: except Exception as e:
+3 -7
View File
@@ -79,9 +79,7 @@ class PlexClient:
# Update the base URL and connection status # Update the base URL and connection status
self.base_url = build_plex_url(scheme, url, port) self.base_url = build_plex_url(scheme, url, port)
self.connected = True self.connected = True
logger.info( logger.info(f"Connected to Plex server at {self.base_url}.")
f"Connected to Plex server at {self.base_url} with token: {self.token}"
)
return self.server, self.token return self.server, self.token
except Exception as e: except Exception as e:
logger.warning(f"Failed to connect to Plex server: {str(e)}") logger.warning(f"Failed to connect to Plex server: {str(e)}")
@@ -106,9 +104,7 @@ class PlexClient:
self.token = account.authenticationToken self.token = account.authenticationToken
self.server = PlexServer(self.base_url, self.token, timeout=timeout) self.server = PlexServer(self.base_url, self.token, timeout=timeout)
logger.debug( logger.debug(f"Connected to Plex server with username: {username}.")
f"Connected to Plex server with username: {username}, token: {self.token}"
)
return self.server, self.token return self.server, self.token
def _connect_with_token( def _connect_with_token(
@@ -124,7 +120,7 @@ class PlexClient:
self.base_url = build_plex_url(scheme, url, port) self.base_url = build_plex_url(scheme, url, port)
self.server = PlexServer(self.base_url, token, timeout=timeout) 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 return self.server, token
def _connect_check(self): def _connect_check(self):
+86 -3
View File
@@ -2,6 +2,9 @@ import threading
import asyncio import asyncio
import json import json
import os import os
import hashlib
import re
import time
from datetime import datetime from datetime import datetime
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode from app.utils.playlist_merge import sync_all_playlists, SyncMode
@@ -19,6 +22,23 @@ class SyncManager:
self._last_error = None self._last_error = None
self._listeners = [] # List of asyncio.Queue self._listeners = [] # List of asyncio.Queue
self._loop = None 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): def set_event_loop(self, loop):
self._loop = loop self._loop = loop
@@ -76,6 +96,11 @@ class SyncManager:
self._is_syncing = True self._is_syncing = True
self._last_status = "syncing" self._last_status = "syncing"
self._last_error = None 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() self._notify_listeners()
logger.info(f"Starting sync (Source: {trigger_source})...") logger.info(f"Starting sync (Source: {trigger_source})...")
@@ -139,9 +164,10 @@ class SyncManager:
local_result_path = os.path.join(output_dir, "outputs", "local_result.m3u8") local_result_path = os.path.join(output_dir, "outputs", "local_result.m3u8")
if os.path.exists(local_result_path): if os.path.exists(local_result_path):
tracks = load_local_playlist(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 # Ensure directory exists
os.makedirs(os.path.dirname(dest_path), exist_ok=True) 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) write_local_playlist(dest_path, tracks)
# 2. Write Remote (Plex) # 2. Write Remote (Plex)
@@ -156,10 +182,12 @@ class SyncManager:
elif action == "deleted": elif action == "deleted":
# Delete Local # 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) delete_local_playlist(dest_path)
# Also check for .m3u # 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_local_playlist(dest_path_m3u)
# Delete Remote # Delete Remote
@@ -167,8 +195,63 @@ class SyncManager:
except Exception as e: except Exception as e:
logger.error(f"Error applying sync result for playlist {playlist_name}: {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): def _complete_sync(self, status, error=None):
now = time.monotonic()
with self._lock: 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_status = status
self._last_error = error self._last_error = error
self._last_sync_time = datetime.now() self._last_sync_time = datetime.now()
+17 -3
View File
@@ -22,22 +22,36 @@ class PlaylistEventHandler(FileSystemEventHandler):
if event.is_directory: if event.is_directory:
return 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. # Filter out noisy events. Only listen to actual changes.
# 'opened' and 'closed' (without write) are read events and should be ignored. # 'opened' and 'closed' (without write) are read events and should be ignored.
if event.event_type not in ['created', 'modified', 'deleted', 'moved']: if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return return
# Ignore temporary files or hidden files # Ignore temporary files or hidden files
filename = os.path.basename(event.src_path) filename = os.path.basename(event_path)
if filename.startswith('.'): if filename.startswith('.'):
return 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 # Prevent feedback loops: if sync is in progress, ignore events
if sync_manager.is_syncing: 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 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() self.trigger_sync()
def trigger_sync(self): def trigger_sync(self):
-19
View File
@@ -1,19 +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_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
- LOG_LEVEL=INFO
- TZ=${TZ:-Asia/Tokyo}
- PLEXPLAYLISTSYNC_CONFIG_PATH=/app/data/config/config.json
- PLEXPLAYLISTSYNC_BACKUP_DIR=/app/data/backup
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
+177 -57
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 ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector'; import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal'; import ConnectionModal from './components/ConnectionModal';
import LoginScreen from './components/LoginScreen';
import OverflowMarquee from './components/OverflowMarquee'; 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'; import { useLanguage } from './LanguageContext';
interface Toast { interface Toast {
@@ -115,6 +116,12 @@ const useStripeAnimation = (syncState: SyncState) => {
const App: React.FC = () => { const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage(); 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 [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]); const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined); const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -171,6 +178,75 @@ const App: React.FC = () => {
retentionCount: 5 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 // Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({}); const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -299,6 +375,7 @@ const App: React.FC = () => {
// Fetch Local Playlists // Fetch Local Playlists
const refreshLocal = useCallback(async () => { const refreshLocal = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController(); const abortController = new AbortController();
localAbortRef.current = abortController; localAbortRef.current = abortController;
@@ -310,7 +387,7 @@ const App: React.FC = () => {
} }
setLoadingLocal(false); setLoadingLocal(false);
localAbortRef.current = null; localAbortRef.current = null;
}, [localPath]); }, [authReady, isAuthenticated, localPath]);
const cancelLocalRefresh = () => { const cancelLocalRefresh = () => {
if (localAbortRef.current) { if (localAbortRef.current) {
@@ -323,6 +400,7 @@ const App: React.FC = () => {
// Fetch Cloud Playlists and Info // Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => { const refreshCloud = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController(); const abortController = new AbortController();
cloudAbortRef.current = abortController; cloudAbortRef.current = abortController;
@@ -344,7 +422,7 @@ const App: React.FC = () => {
setLoadingCloud(false); setLoadingCloud(false);
cloudAbortRef.current = null; cloudAbortRef.current = null;
} }
}, []); }, [authReady, isAuthenticated]);
const cancelCloudRefresh = () => { const cancelCloudRefresh = () => {
if (cloudAbortRef.current) { if (cloudAbortRef.current) {
@@ -357,13 +435,15 @@ const App: React.FC = () => {
// Load persisted configuration // Load persisted configuration
useEffect(() => { useEffect(() => {
if (!authReady || !isAuthenticated) return;
loadSettings(); loadSettings();
loadSchedule(); loadSchedule();
loadBackupSettings(); loadBackupSettings();
}, [loadSettings, loadSchedule, loadBackupSettings]); }, [authReady, isAuthenticated, loadSettings, loadSchedule, loadBackupSettings]);
// Initial Load // Initial Load
useEffect(() => { useEffect(() => {
if (!authReady || !isAuthenticated) return;
refreshLocal(); refreshLocal();
refreshCloud(); refreshCloud();
return () => { return () => {
@@ -371,7 +451,7 @@ const App: React.FC = () => {
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort();
} }
}, [refreshLocal, refreshCloud]); }, [authReady, isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change // Handle Strategy Change
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => { const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
@@ -397,6 +477,7 @@ const App: React.FC = () => {
// Handle Sync Trigger // Handle Sync Trigger
const handleSyncTrigger = async () => { const handleSyncTrigger = async () => {
if (!authReady || !isAuthenticated) return;
if (syncState !== SyncState.IDLE) return; if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING); setSyncState(SyncState.SYNCING);
@@ -423,7 +504,16 @@ const App: React.FC = () => {
// SSE for sync status // SSE for sync status
useEffect(() => { 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) => { eventSource.onmessage = (event) => {
try { try {
@@ -490,7 +580,24 @@ const App: React.FC = () => {
return () => { return () => {
eventSource.close(); 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) => { const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo); setCloudServerInfo(serverInfo);
@@ -671,7 +778,7 @@ const App: React.FC = () => {
<ArrowLeftRight size={24} strokeWidth={2.5} /> <ArrowLeftRight size={24} strokeWidth={2.5} />
</div> </div>
<h1 className="text-xl font-bold tracking-tight text-white"> <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> </h1>
</div> </div>
@@ -725,60 +832,73 @@ const App: React.FC = () => {
</div> </div>
</div> </div>
{/* Language Switcher */} <div className="flex items-center gap-4">
<div className="relative"> {/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
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('common.switchLanguage')}
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" 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-50 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>
{/* Connection Status Button */}
<button <button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)} onClick={() => setIsConnectionModalOpen(true)}
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" className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
title={t('common.switchLanguage')} ${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
> >
<Languages size={18} /> {isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button> </button>
{isLangMenuOpen && (
<> {/* Logout (rightmost) */}
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div> {authEnabled && isAuthenticated && (
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden"> <button
<button onClick={handleLogout}
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }} 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"
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'}`} title={t('auth.logout')}
> >
English <LogOut size={18} />
</button> </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>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff 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;
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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 src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
+11
View File
@@ -4,6 +4,17 @@ export const cht = {
manager: '管理', manager: '管理',
footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。', footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。',
}, },
auth: {
title: '登入',
subtitle: '登入後管理播放清單同步',
username: '使用者名稱',
password: '密碼',
loginBtn: '登入',
logout: '登出',
loggingIn: '驗證中…',
invalidCredentials: '使用者名稱或密碼錯誤',
welcome: '歡迎,{user}',
},
common: { common: {
save: '儲存', save: '儲存',
cancel: '取消', cancel: '取消',
+11
View File
@@ -4,6 +4,17 @@ export const en = {
manager: 'Manager', manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.', 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: { common: {
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
+11
View File
@@ -4,6 +4,17 @@ export const es = {
manager: 'Gestor', manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.', 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: { common: {
save: 'Guardar', save: 'Guardar',
cancel: 'Cancelar', cancel: 'Cancelar',
+11
View File
@@ -4,6 +4,17 @@ export const zh = {
manager: '管理', manager: '管理',
footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。', footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。',
}, },
auth: {
title: '登录',
subtitle: '登录后管理播放列表同步',
username: '用户名',
password: '密码',
loginBtn: '登录',
logout: '登出',
loggingIn: '验证中...',
invalidCredentials: '用户名或密码错误',
welcome: '欢迎,{user}',
},
common: { common: {
save: '保存', save: '保存',
cancel: '取消', cancel: '取消',
+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.", "description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": [] "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 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> = { const MODE_TO_STRATEGY: Record<string, SyncStrategy> = {
local_force: SyncStrategy.LOCAL_OVERWRITE, local_force: SyncStrategy.LOCAL_OVERWRITE,
remote_force: SyncStrategy.CLOUD_OVERWRITE, remote_force: SyncStrategy.CLOUD_OVERWRITE,
@@ -20,6 +37,9 @@ const handleResponse = async <T>(response: Response): Promise<ApiResponse<T>> =>
try { try {
const data = await response.json(); const data = await response.json();
if (!response.ok) { 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: data as T, status: 'error', message: (data as any)?.detail || response.statusText };
} }
return { data, status: 'success' }; return { data, status: 'success' };
@@ -97,8 +117,32 @@ const pathMappingToApi = (config: PathMappingConfig) => {
}; };
export const apiService = { 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 }>> { 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); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
const mode = result.data.sync_mode as string; const mode = result.data.sync_mode as string;
@@ -118,7 +162,7 @@ export const apiService = {
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> { async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
const payload = { mode: STRATEGY_TO_MODE[strategy] }; 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -128,7 +172,7 @@ export const apiService = {
async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> { async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = pathMappingToApi(config); 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -137,7 +181,7 @@ export const apiService = {
}, },
async updateLibrary(libraryName: string): Promise<ApiResponse<{ library_name: string }>> { 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_name: libraryName }), body: JSON.stringify({ library_name: libraryName }),
@@ -146,12 +190,12 @@ export const apiService = {
}, },
async getScheduleSettings(): Promise<ApiResponse<ScheduleSettings & { nextRun?: string }>> { 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); return handleResponse(response);
}, },
async saveScheduleSettings(settings: ScheduleSettings): Promise<ApiResponse<null>> { 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify(settings),
@@ -164,7 +208,7 @@ export const apiService = {
if (serverType === ServerType.LOCAL && localPath) { if (serverType === ServerType.LOCAL && localPath) {
params.append('local_path', 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); const result = await handleResponse<any>(response);
if (result.status === 'success' && (result.data as any)?.playlists) { if (result.status === 'success' && (result.data as any)?.playlists) {
return { data: (result.data.playlists as any[]).map(mapPlaylist), status: 'success' }; 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>> { 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); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
const info = result.data.serverInfo || {}; const info = result.data.serverInfo || {};
@@ -194,7 +238,7 @@ export const apiService = {
}, },
async connectToPlex(settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string; serverInfo: PlexServerConnection }>> { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -219,7 +263,7 @@ export const apiService = {
}, },
async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> { 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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 }>> { 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); return handleResponse(response);
}, },
async getBackupSettings(): Promise<ApiResponse<BackupSettings>> { 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); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
return { return {
@@ -251,7 +295,7 @@ export const apiService = {
}, },
async saveBackupSettings(settings: BackupSettings): Promise<ApiResponse<null>> { 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
+2 -1
View File
@@ -11,7 +11,8 @@
], ],
"skipLibCheck": true, "skipLibCheck": true,
"types": [ "types": [
"node" "node",
"vite/client"
], ],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"isolatedModules": true, "isolatedModules": true,
+11
View File
@@ -116,3 +116,14 @@ export interface ApiResponse<T> {
status: 'success' | 'error'; status: 'success' | 'error';
message?: string; 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" />