Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d3df9ecb | |||
| a14210c458 | |||
| f0b129a27e | |||
| 9ddc0d9eb2 | |||
| 834e21b331 |
+3
-2
@@ -7,7 +7,6 @@
|
|||||||
"timeout": 9,
|
"timeout": 9,
|
||||||
"library_name": "",
|
"library_name": "",
|
||||||
"sync_mode": "local_force",
|
"sync_mode": "local_force",
|
||||||
"local_path": "playlists",
|
|
||||||
"path_rules": [],
|
"path_rules": [],
|
||||||
"path_mapping": {
|
"path_mapping": {
|
||||||
"mode": "SIMPLE",
|
"mode": "SIMPLE",
|
||||||
@@ -24,5 +23,7 @@
|
|||||||
"schedule_daily_time": "00:00",
|
"schedule_daily_time": "00:00",
|
||||||
"schedule_weekly_days": [0],
|
"schedule_weekly_days": [0],
|
||||||
"schedule_weekly_time": "00:00",
|
"schedule_weekly_time": "00:00",
|
||||||
"schedule_auto_watch": false
|
"schedule_auto_watch": false,
|
||||||
|
"backup_enabled": false,
|
||||||
|
"backup_retention_count": 5
|
||||||
}
|
}
|
||||||
+19
-17
@@ -13,7 +13,7 @@ from pydantic import BaseModel, Field
|
|||||||
from app.utils.config import server_config
|
from app.utils.config import server_config
|
||||||
from app.utils.local_playlist import load_local_playlist, scan_local_playlists
|
from app.utils.local_playlist import load_local_playlist, scan_local_playlists
|
||||||
from app.utils.logger import logger
|
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.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
|
||||||
@@ -499,8 +499,8 @@ async def api_connect(payload: ConnectRequest):
|
|||||||
async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"), local_path: str | None = None):
|
async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"), local_path: str | None = None):
|
||||||
server_type = server.lower()
|
server_type = server.lower()
|
||||||
if server_type == "local":
|
if server_type == "local":
|
||||||
resolved_path = local_path or server_config.local_path
|
# local_path is intentionally fixed; ignore query overrides.
|
||||||
server_config.set_and_save_config(local_path=resolved_path)
|
resolved_path = server_config.local_path
|
||||||
playlists = _scan_local_playlists_with_meta(resolved_path)
|
playlists = _scan_local_playlists_with_meta(resolved_path)
|
||||||
return {"playlists": [item.model_dump() for item in playlists]}
|
return {"playlists": [item.model_dump() for item in playlists]}
|
||||||
|
|
||||||
@@ -542,7 +542,8 @@ async def api_sync(payload: SyncRequest):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(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
|
# Use sync_manager to execute sync, ensuring state is updated
|
||||||
try:
|
try:
|
||||||
@@ -567,7 +568,7 @@ async def api_sync(payload: SyncRequest):
|
|||||||
"conflict_count": conflict_count,
|
"conflict_count": conflict_count,
|
||||||
"delete_count": deleted_count,
|
"delete_count": deleted_count,
|
||||||
"playlist_count": len(results),
|
"playlist_count": len(results),
|
||||||
"output_dir": TEST_PLAYLIST_DIR,
|
"output_dir": SYNC_ARTIFACTS_DIR,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -611,23 +612,24 @@ def _build_home_context(
|
|||||||
|
|
||||||
# 显示主页
|
# 显示主页
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@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")
|
index_path = os.path.join(FRONTEND_DIST_PATH, "index.html")
|
||||||
if os.path.exists(index_path):
|
if os.path.exists(index_path):
|
||||||
return FileResponse(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)
|
return templates.TemplateResponse("home.html", context)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/sync", response_class=HTMLResponse)
|
@app.post("/sync", response_class=HTMLResponse)
|
||||||
async def trigger_sync(request: Request, mode: str = Form(...), local_path: str = Form("playlist")):
|
async def trigger_sync(request: Request, mode: str = Form(...), local_path: str = Form("playlists")):
|
||||||
try:
|
try:
|
||||||
sync_mode = SyncMode(mode)
|
sync_mode = SyncMode(mode)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
context = _build_home_context(
|
context = _build_home_context(
|
||||||
request,
|
request,
|
||||||
local_path,
|
server_config.local_path,
|
||||||
message=f"未知的同步策略:{mode}",
|
message=f"未知的同步策略:{mode}",
|
||||||
message_type="danger",
|
message_type="danger",
|
||||||
selected_mode=mode,
|
selected_mode=mode,
|
||||||
@@ -636,17 +638,17 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
results = sync_all_playlists(
|
results = sync_all_playlists(
|
||||||
local_dir=local_path,
|
local_dir=server_config.local_path,
|
||||||
mode=sync_mode,
|
mode=sync_mode,
|
||||||
test_folder=TEST_PLAYLIST_DIR,
|
test_folder=SYNC_ARTIFACTS_DIR,
|
||||||
)
|
)
|
||||||
merged_count = sum(len(item.merged_paths) for item in results)
|
merged_count = sum(len(item.merged_paths) for item in results)
|
||||||
conflict_count = sum(len(item.conflicts) 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")
|
deleted_count = sum(1 for item in results if item.action == "deleted")
|
||||||
context = _build_home_context(
|
context = _build_home_context(
|
||||||
request,
|
request,
|
||||||
local_path,
|
server_config.local_path,
|
||||||
message="同步完成,输出已写入测试目录用于验证。",
|
message="同步完成,输出已写入同步工作目录(Artifacts)。",
|
||||||
message_type="success",
|
message_type="success",
|
||||||
sync_result={
|
sync_result={
|
||||||
"mode": sync_mode.value,
|
"mode": sync_mode.value,
|
||||||
@@ -658,7 +660,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
|
|||||||
"conflict_count": conflict_count,
|
"conflict_count": conflict_count,
|
||||||
"delete_count": deleted_count,
|
"delete_count": deleted_count,
|
||||||
"playlist_count": len(results),
|
"playlist_count": len(results),
|
||||||
"output_dir": TEST_PLAYLIST_DIR,
|
"output_dir": SYNC_ARTIFACTS_DIR,
|
||||||
},
|
},
|
||||||
selected_mode=sync_mode.value,
|
selected_mode=sync_mode.value,
|
||||||
)
|
)
|
||||||
@@ -667,7 +669,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
|
|||||||
logger.warning(f"Sync failed: {exc}")
|
logger.warning(f"Sync failed: {exc}")
|
||||||
context = _build_home_context(
|
context = _build_home_context(
|
||||||
request,
|
request,
|
||||||
local_path,
|
server_config.local_path,
|
||||||
message=f"同步失败:{exc}",
|
message=f"同步失败:{exc}",
|
||||||
message_type="danger",
|
message_type="danger",
|
||||||
selected_mode=sync_mode.value,
|
selected_mode=sync_mode.value,
|
||||||
@@ -678,7 +680,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
|
|||||||
@app.post("/path-rules", response_class=HTMLResponse)
|
@app.post("/path-rules", response_class=HTMLResponse)
|
||||||
async def save_path_rules(
|
async def save_path_rules(
|
||||||
request: Request,
|
request: Request,
|
||||||
local_path: str = Form("playlist"),
|
local_path: str = Form("playlists"),
|
||||||
pattern: list[str] | None = Form(None),
|
pattern: list[str] | None = Form(None),
|
||||||
replacement: list[str] | None = Form(None),
|
replacement: list[str] | None = Form(None),
|
||||||
):
|
):
|
||||||
@@ -696,7 +698,7 @@ async def save_path_rules(
|
|||||||
|
|
||||||
context = _build_home_context(
|
context = _build_home_context(
|
||||||
request,
|
request,
|
||||||
local_path,
|
server_config.local_path,
|
||||||
message="正则规则已保存并会在同步前应用。",
|
message="正则规则已保存并会在同步前应用。",
|
||||||
message_type="success",
|
message_type="success",
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-2
@@ -7,11 +7,17 @@ from app.utils.config import server_config
|
|||||||
from app.utils.local_playlist import load_local_playlist
|
from app.utils.local_playlist import load_local_playlist
|
||||||
from app.utils.plex_client import plex_client
|
from app.utils.plex_client import plex_client
|
||||||
|
|
||||||
# Default backup directory
|
# Default backup directory (repo root /backups)
|
||||||
BACKUP_DIR = os.path.abspath(
|
DEFAULT_BACKUP_DIR = os.path.abspath(
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..", "backups")
|
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 ensure_backup_dir():
|
def ensure_backup_dir():
|
||||||
"""Ensure the backup directory exists."""
|
"""Ensure the backup directory exists."""
|
||||||
|
|||||||
+23
-13
@@ -3,6 +3,7 @@ import os
|
|||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
|
|
||||||
DEFAULT_SYNC_MODE = "merge_local_primary"
|
DEFAULT_SYNC_MODE = "merge_local_primary"
|
||||||
|
LOCAL_PLAYLISTS_FOLDER = "playlists"
|
||||||
DEFAULT_PATH_MAPPING = {
|
DEFAULT_PATH_MAPPING = {
|
||||||
"mode": "SIMPLE",
|
"mode": "SIMPLE",
|
||||||
"simple": [],
|
"simple": [],
|
||||||
@@ -14,10 +15,22 @@ DEFAULT_PATH_MAPPING = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CONFIG_PATH = os.path.abspath(
|
DEFAULT_CONFIG_PATH = os.path.abspath(
|
||||||
os.path.join(os.path.dirname(__file__), "..", "config.json")
|
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:
|
class ServerConfig:
|
||||||
|
|
||||||
@@ -30,16 +43,18 @@ class ServerConfig:
|
|||||||
self.timeout = 9
|
self.timeout = 9
|
||||||
self.library_name = ""
|
self.library_name = ""
|
||||||
self.sync_mode = DEFAULT_SYNC_MODE
|
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_rules: list[dict[str, str]] = [] # Legacy field for backward compatibility
|
||||||
self.path_mapping: dict = DEFAULT_PATH_MAPPING.copy()
|
self.path_mapping: dict = DEFAULT_PATH_MAPPING.copy()
|
||||||
self.schedule_mode = "DISABLED"
|
self.schedule_mode = "DISABLED"
|
||||||
self.schedule_cron = ""
|
self.schedule_cron = ""
|
||||||
self.schedule_daily_time = "02:00"
|
self.schedule_daily_time = "00:00"
|
||||||
self.schedule_weekly_days = [0]
|
self.schedule_weekly_days = [0]
|
||||||
self.schedule_weekly_time = "03:00"
|
self.schedule_weekly_time = "00:00"
|
||||||
self.schedule_auto_watch = False
|
self.schedule_auto_watch = False
|
||||||
self.backup_enabled = False
|
self.backup_enabled = True
|
||||||
self.backup_retention_count = 5
|
self.backup_retention_count = 5
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
@@ -66,7 +81,8 @@ class ServerConfig:
|
|||||||
self.timeout = config.get("timeout", 9)
|
self.timeout = config.get("timeout", 9)
|
||||||
self.library_name = config.get("library_name", "")
|
self.library_name = config.get("library_name", "")
|
||||||
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
|
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 []
|
self.path_rules = config.get("path_rules", []) or []
|
||||||
|
|
||||||
# Load path_mapping with default fallback
|
# Load path_mapping with default fallback
|
||||||
@@ -97,6 +113,7 @@ class ServerConfig:
|
|||||||
logger.debug(f"Current server config: {self.__dict__}")
|
logger.debug(f"Current server config: {self.__dict__}")
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
|
_ensure_parent_dir(CONFIG_PATH)
|
||||||
config = {
|
config = {
|
||||||
"theme": self.theme,
|
"theme": self.theme,
|
||||||
"token": self.token,
|
"token": self.token,
|
||||||
@@ -106,7 +123,6 @@ class ServerConfig:
|
|||||||
"timeout": self.timeout,
|
"timeout": self.timeout,
|
||||||
"library_name": self.library_name,
|
"library_name": self.library_name,
|
||||||
"sync_mode": self.sync_mode,
|
"sync_mode": self.sync_mode,
|
||||||
"local_path": self.local_path,
|
|
||||||
"path_rules": self.path_rules,
|
"path_rules": self.path_rules,
|
||||||
"path_mapping": self.path_mapping,
|
"path_mapping": self.path_mapping,
|
||||||
"schedule_mode": self.schedule_mode,
|
"schedule_mode": self.schedule_mode,
|
||||||
@@ -144,9 +160,6 @@ class ServerConfig:
|
|||||||
def set_sync_mode(self, sync_mode: str) -> None:
|
def set_sync_mode(self, sync_mode: str) -> None:
|
||||||
self.sync_mode = sync_mode
|
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:
|
def set_theme(self, theme: str) -> None:
|
||||||
# check theme is valid
|
# check theme is valid
|
||||||
if theme not in ["auto", "dark", "light"]:
|
if theme not in ["auto", "dark", "light"]:
|
||||||
@@ -208,7 +221,6 @@ class ServerConfig:
|
|||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
library_name: str | None = None,
|
library_name: str | None = None,
|
||||||
sync_mode: str | None = None,
|
sync_mode: str | None = None,
|
||||||
local_path: str | None = None,
|
|
||||||
path_rules: list[dict[str, str]] | None = None,
|
path_rules: list[dict[str, str]] | None = None,
|
||||||
path_mapping: dict | None = None,
|
path_mapping: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -228,8 +240,6 @@ class ServerConfig:
|
|||||||
self.set_library(library_name)
|
self.set_library(library_name)
|
||||||
if sync_mode is not None:
|
if sync_mode is not None:
|
||||||
self.set_sync_mode(sync_mode)
|
self.set_sync_mode(sync_mode)
|
||||||
if local_path is not None:
|
|
||||||
self.set_local_path(local_path)
|
|
||||||
if path_rules is not None:
|
if path_rules is not None:
|
||||||
self.set_path_rules(path_rules)
|
self.set_path_rules(path_rules)
|
||||||
if path_mapping is not None:
|
if path_mapping is not None:
|
||||||
|
|||||||
+89
-39
@@ -12,10 +12,13 @@ from app.utils.plex_client import plex_client
|
|||||||
from merge3 import Merge3
|
from merge3 import Merge3
|
||||||
|
|
||||||
|
|
||||||
TEST_PLAYLIST_DIR = os.path.abspath(
|
SYNC_ARTIFACTS_DIR = os.path.abspath(
|
||||||
os.path.join(os.path.dirname(__file__), "..", "test_playlists")
|
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):
|
class ConflictResolutionStrategy(str, Enum):
|
||||||
LOCAL_PRIORITY = "local_priority"
|
LOCAL_PRIORITY = "local_priority"
|
||||||
@@ -159,9 +162,37 @@ class MergeResult:
|
|||||||
conflicts: list[dict]
|
conflicts: list[dict]
|
||||||
|
|
||||||
|
|
||||||
def _ensure_test_dir(folder: str = TEST_PLAYLIST_DIR) -> str:
|
def _ensure_dir(path: str) -> str:
|
||||||
os.makedirs(folder, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
return folder
|
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]:
|
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
|
return "", False
|
||||||
|
|
||||||
|
|
||||||
def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str:
|
def _save_playlist_text(path: str, text: str) -> str:
|
||||||
_ensure_test_dir(folder)
|
"""Write text if changed (avoid triggering unnecessary file events)."""
|
||||||
file_path = os.path.join(folder, filename)
|
|
||||||
logger.info(f"Saving playlist to: {file_path}")
|
|
||||||
|
|
||||||
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(path):
|
||||||
if os.path.exists(file_path):
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as file:
|
||||||
current_content = f.read()
|
if file.read() == text:
|
||||||
if current_content == new_content:
|
return path
|
||||||
return file_path
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
with open(file_path, "w", encoding="utf-8") as file:
|
with open(path, "w", encoding="utf-8") as file:
|
||||||
file.write(new_content)
|
file.write(text)
|
||||||
return file_path
|
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(
|
def _normalize_inputs(
|
||||||
@@ -205,9 +237,9 @@ def _normalize_inputs(
|
|||||||
local_paths = load_paths(local_text)
|
local_paths = load_paths(local_text)
|
||||||
remote_paths = load_paths(remote_text)
|
remote_paths = load_paths(remote_text)
|
||||||
|
|
||||||
_save_playlist_to_folder("base_playlist.m3u8", base_paths, folder)
|
_save_playlist_paths(_artifact_file(folder, "base", "base_prev.m3u8"), base_paths)
|
||||||
_save_playlist_to_folder("local_input.m3u8", local_paths, folder)
|
_save_playlist_paths(_artifact_file(folder, "inputs", "local_input.m3u8"), local_paths)
|
||||||
_save_playlist_to_folder("remote_input.m3u8", remote_paths, folder)
|
_save_playlist_paths(_artifact_file(folder, "inputs", "remote_input.m3u8"), remote_paths)
|
||||||
|
|
||||||
return base_paths, local_paths, remote_paths
|
return base_paths, local_paths, remote_paths
|
||||||
|
|
||||||
@@ -275,14 +307,13 @@ def _write_results(
|
|||||||
else:
|
else:
|
||||||
remote_lines = list(merged_lines)
|
remote_lines = list(merged_lines)
|
||||||
|
|
||||||
_save_playlist_to_folder("local_result.m3u8", local_lines, folder)
|
_save_playlist_paths(_artifact_file(folder, "outputs", "local_result.m3u8"), local_lines)
|
||||||
_save_playlist_to_folder("remote_result.m3u8", remote_lines, folder)
|
_save_playlist_paths(_artifact_file(folder, "outputs", "remote_result.m3u8"), remote_lines)
|
||||||
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder)
|
_save_playlist_paths(_artifact_file(folder, "base", "base_next.m3u8"), merged_lines)
|
||||||
|
|
||||||
|
|
||||||
def _write_delete_marker(playlist: str, folder: str) -> str:
|
def _write_delete_marker(playlist: str, folder: str) -> str:
|
||||||
_ensure_test_dir(folder)
|
marker_path = _artifact_file(folder, "meta", "delete.txt")
|
||||||
marker_path = os.path.join(folder, "delete.txt")
|
|
||||||
with open(marker_path, "w", encoding="utf-8") as file:
|
with open(marker_path, "w", encoding="utf-8") as file:
|
||||||
file.write(f"delete playlist {playlist}")
|
file.write(f"delete playlist {playlist}")
|
||||||
return marker_path
|
return marker_path
|
||||||
@@ -418,7 +449,7 @@ def merge_playlists(
|
|||||||
local_text: str,
|
local_text: str,
|
||||||
remote_text: str,
|
remote_text: str,
|
||||||
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
|
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
|
||||||
test_folder: str = TEST_PLAYLIST_DIR,
|
test_folder: str = SYNC_ARTIFACTS_DIR,
|
||||||
compiled_rules: CompiledRegexRules | None = None,
|
compiled_rules: CompiledRegexRules | None = None,
|
||||||
) -> MergeResult:
|
) -> MergeResult:
|
||||||
"""Merge playlists using diff3 and resolve conflicts per strategy.
|
"""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]:
|
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."""
|
"""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(
|
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:
|
if not base_text:
|
||||||
alt_text, _ = _read_text_if_exists(
|
alt_text, alt_exists = _read_text_if_exists(
|
||||||
os.path.join(playlist_folder, "base_playlist.m3u8")
|
os.path.join(playlist_folder, "base", "base_prev.m3u8")
|
||||||
)
|
)
|
||||||
base_text = base_text or alt_text
|
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(
|
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")
|
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
|
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(
|
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]:
|
) -> list[PlaylistSyncResult]:
|
||||||
"""Synchronize all playlists that can be matched by name.
|
"""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)
|
legacy_compiled_rules = _compile_regex_rules(server_config.path_rules)
|
||||||
logger.info("Using legacy path_rules for preprocessing")
|
logger.info("Using legacy path_rules for preprocessing")
|
||||||
|
|
||||||
_ensure_test_dir(test_folder)
|
_ensure_dir(test_folder)
|
||||||
logger.info(f"Syncing playlists to test folder: {test_folder}")
|
logger.info(f"Sync artifacts folder: {test_folder}")
|
||||||
local_playlists = _load_local_playlists(local_dir)
|
local_playlists = _load_local_playlists(local_dir)
|
||||||
remote_playlists = _fetch_remote_playlists()
|
remote_playlists = _fetch_remote_playlists()
|
||||||
playlist_names: set[str] = set(local_playlists.keys())
|
playlist_names: set[str] = set(local_playlists.keys())
|
||||||
|
|
||||||
playlist_names.update(remote_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] = []
|
results: list[PlaylistSyncResult] = []
|
||||||
|
|
||||||
for playlist in sorted(playlist_names):
|
for playlist in sorted(playlist_names):
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class SyncManager:
|
|||||||
try:
|
try:
|
||||||
if action == "synced":
|
if action == "synced":
|
||||||
# 1. Write Local
|
# 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):
|
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 = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
|
||||||
@@ -145,7 +145,7 @@ class SyncManager:
|
|||||||
write_local_playlist(dest_path, tracks)
|
write_local_playlist(dest_path, tracks)
|
||||||
|
|
||||||
# 2. Write Remote (Plex)
|
# 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):
|
if os.path.exists(remote_result_path):
|
||||||
tracks = load_local_playlist(remote_result_path)
|
tracks = load_local_playlist(remote_result_path)
|
||||||
if server_config.library_name:
|
if server_config.library_name:
|
||||||
|
|||||||
+5
-1
@@ -6,10 +6,14 @@ services:
|
|||||||
- "8888:8080"
|
- "8888:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
|
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
|
||||||
- PATH_TO_YOUR_BACKUP:/app/backup
|
- PATH_TO_DATA/backup:/app/data/backup
|
||||||
|
- PATH_TO_DATA/config:/app/data/config
|
||||||
|
- PATH_TO_DATA/sync_artifacts:/app/data/sync_artifacts
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PYTHONDONTWRITEBYTECODE=1
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
- TZ=${TZ:-Asia/Tokyo}
|
- TZ=${TZ:-Asia/Tokyo}
|
||||||
|
- PLEXPLAYLISTSYNC_CONFIG_PATH=/app/data/config/config.json
|
||||||
|
- PLEXPLAYLISTSYNC_BACKUP_DIR=/app/data/backup
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+86
-12
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
|
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
|
||||||
import { apiService } from './services/api';
|
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 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 { 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';
|
import { useLanguage } from './LanguageContext';
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
@@ -115,6 +118,12 @@ const useStripeAnimation = (syncState: SyncState) => {
|
|||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { t, language, setLanguage } = useLanguage();
|
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 [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);
|
||||||
@@ -198,6 +207,16 @@ const App: React.FC = () => {
|
|||||||
timeoutsRef.current[id] = dismissTimer;
|
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
|
// Effect to trigger the "slide down" animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
|
const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
|
||||||
@@ -236,6 +255,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Fetch Local Playlists
|
// Fetch Local Playlists
|
||||||
const refreshLocal = useCallback(async () => {
|
const refreshLocal = useCallback(async () => {
|
||||||
|
if (!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;
|
||||||
@@ -247,7 +267,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setLoadingLocal(false);
|
setLoadingLocal(false);
|
||||||
localAbortRef.current = null;
|
localAbortRef.current = null;
|
||||||
}, []);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const cancelLocalRefresh = () => {
|
const cancelLocalRefresh = () => {
|
||||||
if (localAbortRef.current) {
|
if (localAbortRef.current) {
|
||||||
@@ -260,6 +280,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Fetch Cloud Playlists and Info
|
// Fetch Cloud Playlists and Info
|
||||||
const refreshCloud = useCallback(async () => {
|
const refreshCloud = useCallback(async () => {
|
||||||
|
if (!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;
|
||||||
@@ -281,7 +302,7 @@ const App: React.FC = () => {
|
|||||||
setLoadingCloud(false);
|
setLoadingCloud(false);
|
||||||
cloudAbortRef.current = null;
|
cloudAbortRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const cancelCloudRefresh = () => {
|
const cancelCloudRefresh = () => {
|
||||||
if (cloudAbortRef.current) {
|
if (cloudAbortRef.current) {
|
||||||
@@ -292,16 +313,18 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial Load
|
// Initial Load (Only if Authenticated)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
refreshLocal();
|
refreshLocal();
|
||||||
refreshCloud();
|
refreshCloud();
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
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]);
|
}, [isAuthenticated, refreshLocal, refreshCloud]);
|
||||||
|
|
||||||
// Handle Strategy Change
|
// Handle Strategy Change
|
||||||
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
|
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
|
||||||
@@ -364,13 +387,6 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Timing Breakdown:
|
// Timing Breakdown:
|
||||||
// T+0.0s: State is SUCCESS.
|
// 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(() => {
|
setTimeout(() => {
|
||||||
setSyncState(SyncState.IDLE);
|
setSyncState(SyncState.IDLE);
|
||||||
refreshLocal();
|
refreshLocal();
|
||||||
@@ -390,6 +406,27 @@ const App: React.FC = () => {
|
|||||||
refreshCloud();
|
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 => {
|
const getToastStyles = (toast: Toast): React.CSSProperties => {
|
||||||
if (toast.exiting || toast.entering) {
|
if (toast.exiting || toast.entering) {
|
||||||
return {
|
return {
|
||||||
@@ -563,6 +600,34 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const backupInfo = getBackupDisplayInfo(backupSettings);
|
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 (
|
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">
|
<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} />}
|
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
app: {
|
app: {
|
||||||
// title and manager are no longer used for branding
|
// title and manager are no longer used for branding
|
||||||
@@ -6,6 +8,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',
|
||||||
@@ -143,5 +156,7 @@ export const en = {
|
|||||||
librarySwitched: 'Library switched to {library}',
|
librarySwitched: 'Library switched to {library}',
|
||||||
connectedTo: 'Successfully connected to {name}',
|
connectedTo: 'Successfully connected to {name}',
|
||||||
connectionCancelled: 'Connection cancelled by user.',
|
connectionCancelled: 'Connection cancelled by user.',
|
||||||
|
loginFailed: 'Login failed. Please check credentials.',
|
||||||
|
loggedOut: 'Successfully logged out.',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const es = {
|
export const es = {
|
||||||
app: {
|
app: {
|
||||||
// title and manager are no longer used for branding
|
// title and manager are no longer used for branding
|
||||||
@@ -6,6 +8,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',
|
||||||
@@ -143,5 +156,7 @@ export const es = {
|
|||||||
librarySwitched: 'Librería cambiada a {library}',
|
librarySwitched: 'Librería cambiada a {library}',
|
||||||
connectedTo: 'Conectado exitosamente a {name}',
|
connectedTo: 'Conectado exitosamente a {name}',
|
||||||
connectionCancelled: 'Conexión cancelada por usuario.',
|
connectionCancelled: 'Conexión cancelada por usuario.',
|
||||||
|
loginFailed: 'Fallo de inicio de sesión. Verifique credenciales.',
|
||||||
|
loggedOut: 'Sesión cerrada exitosamente.',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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';
|
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
|
||||||
|
|
||||||
const SIMULATE_DELAY_MS = 800;
|
const SIMULATE_DELAY_MS = 800;
|
||||||
@@ -229,5 +231,27 @@ export const apiService = {
|
|||||||
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
|
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
|
||||||
}, 500);
|
}, 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -110,3 +110,13 @@ 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user