23 Commits

Author SHA1 Message Date
Koha9 32e96ebea4 Merge branch 'main' into testbed 2025-12-03 04:40:47 +09:00
Koha9 3f43662c1f feat: Enhance logging configuration and add dynamic log level support 2025-12-02 10:15:44 +09:00
Koha9 aa4517aaf5 Fix: Fixed an issue where the Sync Now button became unresponsive due to duplicate API calls. 2025-12-02 09:55:57 +09:00
Koha9 15e7636a92 PlexPlaylist_UI subtree merge
feat: Introduce path mapping for sync

Merge commit 'f791798206d87c694c14d7bffb52645706af4964'
2025-11-30 02:58:04 +09:00
Koha9 f791798206 Squashed 'sample-front-end/' changes from 0e20813..8ae211a
8ae211a feat: Introduce path mapping for sync

git-subtree-dir: sample-front-end
git-subtree-split: 8ae211a79c0d522050553e80674b82e2c9471e0f
2025-11-30 02:58:04 +09:00
Koha9 3719cda819 feat: Add a Docker run script to start the PlexPlaylistSync container. 2025-11-30 02:27:21 +09:00
Koha9 2718d817d9 Merge branch 'scheduling-function' 2025-11-30 02:21:32 +09:00
Koha9 5f62040611 feat: add loacal file watcher statement 2025-11-29 13:53:38 +09:00
Koha9 fda9f01da1 Merge commit 'c879c4c0d927c834c557f89b33a06d29956412a9' into scheduling-function 2025-11-29 13:11:40 +09:00
Koha9 c879c4c0d9 fix:The server detail page failed to correctly display server_scheme from the saved config.json. 2025-11-29 13:10:52 +09:00
Koha9 559342fae7 feat: Enhance scheduler and watcher with improved logging and cron trigger helpers 2025-11-29 12:56:18 +09:00
Koha9 d1a4273fb2 Squashed 'sample-front-end/' changes from 9f02555..0e20813
0e20813 feat: Add eye icon for visibility toggles

git-subtree-dir: sample-front-end
git-subtree-split: 0e208135b924170bcd757c693265a5cc1b620ac3
2025-11-29 12:35:27 +09:00
Koha9 432eee153e PlexPlaylist_UI subtree merge
feat: Add eye icon for visibility toggles

Merge commit 'd1a4273fb2f0c2b69e166cace3729fdb02b310ab'
2025-11-29 12:35:27 +09:00
Koha9 fe4061d1a1 feat: Implement sync manager and file watcher for automated playlist synchronization 2025-11-29 12:26:59 +09:00
Koha9 22697fdc1d feat: Enhance schedule handling in StrategySelector component 2025-11-29 11:10:24 +09:00
Koha9 c982fb930f Merge commit '6f234ebc48e506f0c46ebf811b2a791dd8960dcd' into scheduling-function 2025-11-29 10:54:08 +09:00
Koha9 305743d752 Squashed 'sample-front-end/' changes from 99ea3a6..9f02555
9f02555 feat(ui): Improve schedule dirty state detection

git-subtree-dir: sample-front-end
git-subtree-split: 9f02555bbcc1e7bd576ad04763fbeb5d1f0e0b31
2025-11-29 10:52:05 +09:00
Koha9 6f234ebc48 PlexPlaylist_UI subtree merge
feat(ui): Improve schedule dirty state detection
Detects changes in schedule settings more accurately, considering the active tab and deriving the effective schedule state before comparison. This prevents unintended saving of disabled schedules when switching tabs.

Merge commit '305743d752e1a1ecaefba79419929524ad060663'
2025-11-29 10:52:05 +09:00
Koha9 7dae8647e6 feat: Implement scheduling functionality for playlist synchronization
- Added a new scheduler module using APScheduler to manage scheduled sync jobs.
- Introduced cron expression validation and job scheduling based on user-defined settings.
- Enhanced frontend to support schedule settings, including cron, daily, and weekly modes.
- Updated API service to handle fetching and saving schedule settings.
- Modified StrategySelector component to include schedule management UI.
- Added new types for schedule settings and modes in the frontend.
- Updated requirements to include APScheduler for scheduling capabilities.
2025-11-29 10:49:35 +09:00
Koha9 06e49be1f9 Squashed 'sample-front-end/' changes from 552f9c4..99ea3a6
99ea3a6 feat: Display next sync schedule information
fb8d17a feat: Implement schedule settings and basic UI

git-subtree-dir: sample-front-end
git-subtree-split: 99ea3a68de98503b706d3ee5782baf4a66dc7134
2025-11-29 08:23:31 +09:00
Koha9 40f818bd2c PlexPlaylist_UI subtree merge
feat: Implement schedule settings and basic UI
feat: Display next sync schedule information

Merge commit '06e49be1f9c587f66cca97de97cf449b33b04a4b'
2025-11-29 08:23:31 +09:00
Koha9 6b14847598 Merge commit '0ede13717064aaee99c699d8a3720e9a9c478b1e' 2025-11-29 08:20:22 +09:00
Koha9 0ede137170 Support backend connection timeout configuration 2025-11-29 08:18:32 +09:00
21 changed files with 2193 additions and 373 deletions
+1
View File
@@ -4,6 +4,7 @@
"server_url": "", "server_url": "",
"server_port": "32400", "server_port": "32400",
"server_scheme": "https", "server_scheme": "https",
"timeout": 9,
"library_name": "", "library_name": "",
"sync_mode": "merge_local_primary", "sync_mode": "merge_local_primary",
"local_path": "playlist", "local_path": "playlist",
+102 -3
View File
@@ -4,7 +4,8 @@ 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
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse import asyncio
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse
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
@@ -14,9 +15,16 @@ 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, TEST_PLAYLIST_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.sync_manager import sync_manager
app = FastAPI() app = FastAPI()
@app.on_event("startup")
async def startup_event():
sync_manager.set_event_loop(asyncio.get_running_loop())
start_scheduler()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -92,6 +100,7 @@ class SyncSettingsResponse(BaseModel):
server_url: str | None = None server_url: str | None = None
scheme: str | None = None scheme: str | None = None
port: str | None = None port: str | None = None
timeout: int | None = None
token: str | None = None token: str | None = None
@@ -106,6 +115,51 @@ class ConnectRequest(BaseModel):
library_name: str | None = None library_name: str | None = None
class ScheduleSettings(BaseModel):
mode: str
cronExpression: str
dailyTime: str
weeklyDays: list[int]
weeklyTime: str
autoWatch: bool
@app.get("/api/schedule")
async def get_schedule():
next_run = get_next_run_time()
next_run_str = next_run.strftime("%Y-%m-%d %H:%M:%S") if next_run else None
return {
"mode": server_config.schedule_mode,
"cronExpression": server_config.schedule_cron,
"dailyTime": server_config.schedule_daily_time,
"weeklyDays": server_config.schedule_weekly_days,
"weeklyTime": server_config.schedule_weekly_time,
"autoWatch": server_config.schedule_auto_watch,
"nextRun": next_run_str
}
@app.put("/api/schedule")
async def save_schedule(settings: ScheduleSettings):
# Validate Cron if mode is CRON
if settings.mode == "CRON" and settings.cronExpression.strip():
if not validate_cron_expression(settings.cronExpression):
raise HTTPException(status_code=400, detail="Invalid Cron expression format")
server_config.set_schedule(
mode=settings.mode,
cron=settings.cronExpression,
daily_time=settings.dailyTime,
weekly_days=settings.weeklyDays,
weekly_time=settings.weeklyTime,
auto_watch=settings.autoWatch
)
update_scheduler_job()
logger.info(f"Schedule settings updated via API. Mode: {settings.mode}")
return {"status": "success", "message": "Schedule updated"}
class ConnectResponse(BaseModel): class ConnectResponse(BaseModel):
token: str token: str
serverInfo: dict serverInfo: dict
@@ -135,6 +189,7 @@ def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]:
scheme=server_config.scheme, scheme=server_config.scheme,
url=server_config.url, url=server_config.url,
port=server_config.port, port=server_config.port,
timeout=server_config.timeout,
) )
status = "connected" if plex_client.connected else "failed" status = "connected" if plex_client.connected else "failed"
server_info.update( server_info.update(
@@ -245,6 +300,7 @@ def _get_server_status() -> tuple[dict, str, list[dict]]:
scheme=server_config.scheme, scheme=server_config.scheme,
url=server_config.url, url=server_config.url,
port=server_config.port, port=server_config.port,
timeout=server_config.timeout,
) )
connection_status = "connected" if plex_client.connected else "failed" connection_status = "connected" if plex_client.connected else "failed"
server_info.update( server_info.update(
@@ -301,6 +357,7 @@ async def get_settings():
server_url=server_config.url, server_url=server_config.url,
scheme=server_config.scheme, scheme=server_config.scheme,
port=server_config.port, port=server_config.port,
timeout=server_config.timeout,
token=server_config.token, token=server_config.token,
) )
@@ -345,6 +402,7 @@ async def api_connect(payload: ConnectRequest):
scheme=payload.protocol, scheme=payload.protocol,
url=payload.address, url=payload.address,
port=payload.port, port=payload.port,
timeout=payload.timeout,
) )
libraries = [] libraries = []
selected_library = payload.library_name or server_config.library_name selected_library = payload.library_name or server_config.library_name
@@ -359,6 +417,7 @@ async def api_connect(payload: ConnectRequest):
scheme=payload.protocol, scheme=payload.protocol,
url=payload.address, url=payload.address,
port=payload.port, port=payload.port,
timeout=payload.timeout,
library_name=selected_library or "", library_name=selected_library or "",
) )
server_info = { server_info = {
@@ -395,6 +454,25 @@ async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"),
} }
@app.get("/api/sync/status")
async def get_sync_status():
return sync_manager.status
@app.get("/api/sync/events")
async def sync_events(request: Request):
async def event_generator():
q = await sync_manager.subscribe()
try:
while True:
if await request.is_disconnected():
break
data = await q.get()
yield f"data: {data}\n\n"
finally:
sync_manager.unsubscribe(q)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.post("/api/sync") @app.post("/api/sync")
async def api_sync(payload: SyncRequest): async def api_sync(payload: SyncRequest):
server_config.load() server_config.load()
@@ -404,7 +482,20 @@ async def api_sync(payload: SyncRequest):
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_dir = payload.local_path or server_config.local_path
results = sync_all_playlists(local_dir=local_dir, mode=sync_mode, test_folder=TEST_PLAYLIST_DIR)
# Use sync_manager to execute sync, ensuring state is updated
try:
results = sync_manager.run_sync(
trigger_source="api",
wait=True,
# We need to pass these to _perform_sync
sync_kwargs={"local_dir": local_dir, "mode": sync_mode}
)
except Exception as e:
if str(e) == "Sync already in progress":
raise HTTPException(status_code=409, detail="Sync already in progress")
raise e
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")
@@ -565,6 +656,7 @@ async def login_page(request: Request):
scheme=server_config.scheme, scheme=server_config.scheme,
url=server_config.url, url=server_config.url,
port=server_config.port, port=server_config.port,
timeout=server_config.timeout,
) )
music_libraries = plex_client.get_libs_name_list() music_libraries = plex_client.get_libs_name_list()
if music_libraries: if music_libraries:
@@ -612,6 +704,7 @@ async def login(
scheme=scheme, scheme=scheme,
url=url, url=url,
port=port, port=port,
timeout=server_config.timeout,
) )
# 成功连接后保存配置到配置文件 # 成功连接后保存配置到配置文件
music_libraries: list[str] = [] music_libraries: list[str] = []
@@ -632,12 +725,18 @@ async def login(
scheme=scheme, scheme=scheme,
url=url, url=url,
port=port, port=port,
timeout=server_config.timeout,
library_name=selected_library, library_name=selected_library,
) )
else: else:
music_libraries = [] music_libraries = []
server_config.set_and_save_config( server_config.set_and_save_config(
token=token_success, scheme=scheme, url=url, port=port, library_name="" token=token_success,
scheme=scheme,
url=url,
port=port,
timeout=server_config.timeout,
library_name="",
) )
return templates.TemplateResponse( return templates.TemplateResponse(
"login.html", "login.html",
+46 -1
View File
@@ -17,10 +17,17 @@ class ServerConfig:
self.url = "" self.url = ""
self.scheme = "https" self.scheme = "https"
self.port = "32400" self.port = "32400"
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" self.local_path = "playlist"
self.path_rules: list[dict[str, str]] = [] self.path_rules: list[dict[str, str]] = []
self.schedule_mode = "DISABLED"
self.schedule_cron = ""
self.schedule_daily_time = "02:00"
self.schedule_weekly_days = [0]
self.schedule_weekly_time = "03:00"
self.schedule_auto_watch = False
self.load() self.load()
def load(self) -> None: def load(self) -> None:
@@ -43,11 +50,19 @@ class ServerConfig:
self.url = config.get("server_url", "") self.url = config.get("server_url", "")
self.scheme = config.get("server_scheme", "https") self.scheme = config.get("server_scheme", "https")
self.port = config.get("server_port", "32400") self.port = config.get("server_port", "32400")
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") self.local_path = config.get("local_path", "playlist")
self.path_rules = config.get("path_rules", []) or [] self.path_rules = config.get("path_rules", []) or []
logger.info(f"Server config loaded: {self.__dict__}") self.schedule_mode = config.get("schedule_mode", "DISABLED")
self.schedule_cron = config.get("schedule_cron", "")
self.schedule_daily_time = config.get("schedule_daily_time", "02:00")
self.schedule_weekly_days = config.get("schedule_weekly_days", [0])
self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00")
self.schedule_auto_watch = config.get("schedule_auto_watch", False)
logger.info(f"Server config loaded.")
logger.debug(f"Current server config: {self.__dict__}")
def save(self): def save(self):
config = { config = {
@@ -56,10 +71,17 @@ class ServerConfig:
"server_url": self.url, "server_url": self.url,
"server_scheme": self.scheme, "server_scheme": self.scheme,
"server_port": self.port, "server_port": self.port,
"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, "local_path": self.local_path,
"path_rules": self.path_rules, "path_rules": self.path_rules,
"schedule_mode": self.schedule_mode,
"schedule_cron": self.schedule_cron,
"schedule_daily_time": self.schedule_daily_time,
"schedule_weekly_days": self.schedule_weekly_days,
"schedule_weekly_time": self.schedule_weekly_time,
"schedule_auto_watch": self.schedule_auto_watch,
} }
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)
@@ -74,6 +96,9 @@ class ServerConfig:
def set_port(self, port: str) -> None: def set_port(self, port: str) -> None:
self.port = port self.port = port
def set_timeout(self, timeout: int) -> None:
self.timeout = timeout if timeout and timeout > 0 else 9
def set_token(self, token: str) -> None: def set_token(self, token: str) -> None:
self.token = token self.token = token
@@ -96,6 +121,23 @@ class ServerConfig:
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None: def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
self.path_rules = path_rules or [] self.path_rules = path_rules or []
def set_schedule(
self,
mode: str,
cron: str,
daily_time: str,
weekly_days: list[int],
weekly_time: str,
auto_watch: bool,
) -> None:
self.schedule_mode = mode
self.schedule_cron = cron
self.schedule_daily_time = daily_time
self.schedule_weekly_days = weekly_days
self.schedule_weekly_time = weekly_time
self.schedule_auto_watch = auto_watch
self.save()
def set_and_save_config( def set_and_save_config(
self, self,
theme: str = None, theme: str = None,
@@ -103,6 +145,7 @@ class ServerConfig:
url: str = None, url: str = None,
scheme: str = None, scheme: str = None,
port: str = None, port: str = 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, local_path: str | None = None,
@@ -118,6 +161,8 @@ class ServerConfig:
self.set_scheme(scheme) self.set_scheme(scheme)
if port is not None: if port is not None:
self.set_port(port) self.set_port(port)
if timeout is not None:
self.set_timeout(timeout)
if library_name is not None: if library_name is not None:
self.set_library(library_name) self.set_library(library_name)
if sync_mode is not None: if sync_mode is not None:
+23 -1
View File
@@ -4,7 +4,29 @@ import os
LOG_PATH = os.path.abspath( LOG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "logs", "app.log")) os.path.join(os.path.dirname(__file__), "..", "logs", "app.log"))
LOG_LEVEL = logging.DEBUG def _get_log_level():
"""Get log level from environment variable."""
level_str = os.getenv("LOG_LEVEL", "INFO").upper()
# Try to convert to integer
if level_str.isdigit():
return int(level_str)
# Map string to logging level
levels = {
"CRITICAL": logging.CRITICAL,
"FATAL": logging.FATAL,
"ERROR": logging.ERROR,
"WARN": logging.WARNING,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
"NOTSET": logging.NOTSET,
}
return levels.get(level_str, logging.INFO)
LOG_LEVEL = _get_log_level()
def logger_initialize() -> logging.Logger: def logger_initialize() -> logging.Logger:
"""Initialize the logger for the application. Return a logger that logs to console and a app.log.""" """Initialize the logger for the application. Return a logger that logs to console and a app.log."""
+16 -1
View File
@@ -159,8 +159,22 @@ def _read_text_if_exists(path: str) -> tuple[str, bool]:
def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str: def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str:
_ensure_test_dir(folder) _ensure_test_dir(folder)
file_path = os.path.join(folder, filename) file_path = os.path.join(folder, filename)
logger.info(f"Saving playlist to: {file_path}")
new_content = save_paths(paths)
# Check if content has changed before writing to avoid triggering unnecessary file events
if os.path.exists(file_path):
try:
with open(file_path, "r", encoding="utf-8") as f:
current_content = f.read()
if current_content == new_content:
return file_path
except OSError:
pass
with open(file_path, "w", encoding="utf-8") as file: with open(file_path, "w", encoding="utf-8") as file:
file.write(save_paths(paths)) file.write(new_content)
return file_path return file_path
@@ -572,6 +586,7 @@ def sync_all_playlists(
server_config.load() server_config.load()
compiled_rules = _compile_regex_rules(server_config.path_rules) compiled_rules = _compile_regex_rules(server_config.path_rules)
_ensure_test_dir(test_folder) _ensure_test_dir(test_folder)
logger.info(f"Syncing playlists to test 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())
+19 -7
View File
@@ -50,6 +50,7 @@ class PlexClient:
scheme: str = "https", scheme: str = "https",
url: str = "", url: str = "",
port: str = "32400", port: str = "32400",
timeout: int | None = None,
) -> tuple[PlexServer, str]: ) -> tuple[PlexServer, str]:
"""Connect to the Plex server using username/password or token. """Connect to the Plex server using username/password or token.
@@ -69,11 +70,11 @@ class PlexClient:
try: try:
if not str_is_empty(token): if not str_is_empty(token):
self.server, self.token = self._connect_with_token( self.server, self.token = self._connect_with_token(
token, scheme, url, port token, scheme, url, port, timeout
) )
else: else:
self.server, self.token = self._connect_with_pw( self.server, self.token = self._connect_with_pw(
username, password, scheme, url, port username, password, scheme, url, port, timeout
) )
# 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)
@@ -88,30 +89,41 @@ class PlexClient:
raise raise
def _connect_with_pw( def _connect_with_pw(
self, username: str, password: str, scheme: str, url: str, port: str = "32400" self,
username: str,
password: str,
scheme: str,
url: str,
port: str = "32400",
timeout: int | None = None,
): ):
"""Return a connected PlexServer instance and update config with token and server info.""" """Return a connected PlexServer instance and update config with token and server info."""
# url 初始化 # url 初始化
self.base_url = build_plex_url(scheme, url, port) self.base_url = build_plex_url(scheme, url, port)
# account 初始化 # account 初始化
account = MyPlexAccount(username, password) account = MyPlexAccount(username, password, timeout=timeout)
# token 获取 # token 获取
self.token = account.authenticationToken self.token = account.authenticationToken
self.server = PlexServer(self.base_url, self.token) self.server = PlexServer(self.base_url, self.token, timeout=timeout)
logger.debug( logger.debug(
f"Connected to Plex server with username: {username}, token: {self.token}" 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(
self, token: str, scheme: str, url: str, port: str = "32400" self,
token: str,
scheme: str,
url: str,
port: str = "32400",
timeout: int | None = None,
): ):
"""Return a connected PlexServer instance using a token.""" """Return a connected PlexServer instance using a token."""
# URL 初始化 # URL 初始化
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) self.server = PlexServer(self.base_url, token, timeout=timeout)
logger.debug(f"Connected to Plex server with token: {token}") logger.debug(f"Connected to Plex server with token: {token}")
return self.server, token return self.server, token
+160
View File
@@ -0,0 +1,160 @@
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.base import BaseTrigger
from app.utils.config import server_config
from app.utils.logger import logger
from app.utils.watcher import watcher_manager
from app.utils.sync_manager import sync_manager
import os
# Initialize the scheduler
scheduler = BackgroundScheduler()
def validate_cron_expression(expression: str) -> bool:
"""
Validates a cron expression.
Expected format: "minute hour day month day_of_week"
"""
try:
parts = expression.split()
if len(parts) != 5:
return False
# Try to create a trigger to validate
CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4]
)
return True
except Exception:
return False
def job_function():
"""
The function to be executed by the scheduler.
Triggers the sync process.
"""
logger.info("Executing scheduled sync job...")
try:
sync_manager.run_sync(trigger_source="scheduler", wait=False)
except Exception as e:
logger.error(f"Error during scheduled sync job: {e}", exc_info=True)
def start_scheduler():
"""
Starts the background scheduler if it's not already running.
"""
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started.")
update_scheduler_job()
def _create_cron_trigger(cron_exp: str) -> Optional[CronTrigger]:
"""Helper to create a CronTrigger from a cron expression string."""
try:
# 5 parts: minute hour day month day_of_week
parts = cron_exp.split()
if len(parts) == 5:
return CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4]
)
else:
logger.error(f"Invalid cron expression format (needs 5 parts): {cron_exp}")
except Exception as e:
logger.error(f"Invalid cron expression: {cron_exp}, error: {e}")
return None
def _create_daily_trigger(time_str: str) -> Optional[CronTrigger]:
"""Helper to create a CronTrigger for daily execution at a specific time."""
try:
hour, minute = map(int, time_str.split(':'))
return CronTrigger(hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid daily time format: {time_str}")
return None
def _create_weekly_trigger(days: list[int], time_str: str) -> Optional[CronTrigger]:
"""
Helper to create a CronTrigger for weekly execution.
days: List of integers 0-6 where 0 is Sunday, 1 is Monday, ..., 6 is Saturday.
APScheduler expects: 0 = Monday, ..., 6 = Sunday.
"""
# Convert Frontend days (0=Sun...6=Sat) to APScheduler days (0=Mon...6=Sun)
aps_days = []
for d in days:
if d == 0:
aps_days.append(6) # Sunday
else:
aps_days.append(d - 1) # Mon(1)->0, ..., Sat(6)->5
days_str = ",".join(map(str, aps_days))
try:
hour, minute = map(int, time_str.split(':'))
return CronTrigger(day_of_week=days_str, hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid weekly time format: {time_str}")
return None
def update_scheduler_job():
"""
Updates the scheduler jobs based on the current configuration.
Reloads configuration, handles auto-watch, and sets up the sync job trigger.
"""
scheduler.remove_all_jobs()
# Reload config to get latest schedule settings
server_config.load()
logger.info("Configuration reloaded for scheduler update.")
# Handle Auto Watch
if server_config.schedule_auto_watch:
# Ensure we have an absolute path
local_path = os.path.abspath(server_config.local_path)
watcher_manager.start(local_path)
logger.info(f"Auto-watch started for path: {local_path}")
else:
watcher_manager.stop()
logger.info("Auto-watch stopped.")
mode = server_config.schedule_mode
logger.info(f"Updating scheduler with mode: {mode}")
if mode == "DISABLED":
logger.info("Schedule is disabled. No jobs added.")
return
trigger: Optional[BaseTrigger] = None
if mode == "CRON":
trigger = _create_cron_trigger(server_config.schedule_cron)
elif mode == "DAILY":
trigger = _create_daily_trigger(server_config.schedule_daily_time)
elif mode == "WEEKLY":
trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time)
if trigger:
scheduler.add_job(job_function, trigger)
logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
else:
logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.")
def get_next_run_time():
"""
Returns the next run time of the scheduled job, if any.
"""
jobs = scheduler.get_jobs()
if not jobs:
return None
# Assuming only one job is scheduled for sync
job = jobs[0]
return job.next_run_time
+124
View File
@@ -0,0 +1,124 @@
import threading
import asyncio
import json
from datetime import datetime
from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode
from app.utils.config import server_config
class SyncManager:
def __init__(self):
self._lock = threading.Lock()
self._is_syncing = False
self._last_sync_time = None
self._last_status = "idle" # idle, syncing, success, error
self._last_error = None
self._listeners = [] # List of asyncio.Queue
self._loop = None
def set_event_loop(self, loop):
self._loop = loop
async def subscribe(self):
q = asyncio.Queue()
self._listeners.append(q)
# Send current status immediately
await q.put(json.dumps(self.status))
return q
def unsubscribe(self, q):
if q in self._listeners:
self._listeners.remove(q)
def _notify_listeners(self):
if not self._loop or not self._listeners:
return
status_json = json.dumps(self.status)
for q in self._listeners:
try:
self._loop.call_soon_threadsafe(q.put_nowait, status_json)
except Exception as e:
logger.error(f"Error notifying listener: {e}")
@property
def is_syncing(self):
with self._lock:
return self._is_syncing
@property
def status(self):
with self._lock:
return {
"is_syncing": self._is_syncing,
"last_sync_time": self._last_sync_time.isoformat() if self._last_sync_time else None,
"status": self._last_status,
"error": str(self._last_error) if self._last_error else None
}
def run_sync(self, trigger_source="manual", wait=False, sync_kwargs=None):
"""
Thread-safe sync execution.
If wait=True, blocks until sync completes and returns result.
If wait=False, runs in background and returns True if started.
"""
with self._lock:
if self._is_syncing:
logger.warning(f"Sync requested ({trigger_source}) but already in progress.")
if wait:
raise Exception("Sync already in progress")
return False
self._is_syncing = True
self._last_status = "syncing"
self._last_error = None
self._notify_listeners()
logger.info(f"Starting sync (Source: {trigger_source})...")
if wait:
try:
result = self._perform_sync(sync_kwargs)
self._complete_sync("success")
return result
except Exception as e:
self._complete_sync("error", e)
raise e
else:
thread = threading.Thread(target=self._sync_worker, args=(trigger_source, sync_kwargs))
thread.start()
return True
def _sync_worker(self, trigger_source, sync_kwargs=None):
try:
self._perform_sync(sync_kwargs)
self._complete_sync("success")
logger.info(f"Sync completed successfully (Source: {trigger_source}).")
except Exception as e:
logger.error(f"Sync failed (Source: {trigger_source}): {e}")
self._complete_sync("error", e)
def _perform_sync(self, sync_kwargs=None):
# Reload config to ensure latest values
server_config.load()
kwargs = {
"local_dir": server_config.local_path,
"mode": SyncMode(server_config.sync_mode)
}
if sync_kwargs:
kwargs.update(sync_kwargs)
# Execute sync
return sync_all_playlists(**kwargs)
def _complete_sync(self, status, error=None):
with self._lock:
self._last_status = status
self._last_error = error
self._last_sync_time = datetime.now()
self._is_syncing = False
self._notify_listeners()
sync_manager = SyncManager()
+115
View File
@@ -0,0 +1,115 @@
import os
import threading
from typing import Optional
from watchdog.observers.polling import PollingObserver as Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from app.utils.logger import logger
from app.utils.sync_manager import sync_manager
class PlaylistEventHandler(FileSystemEventHandler):
"""
Handles file system events for the playlist directory.
Triggers a sync operation when changes are detected, with debouncing.
"""
def __init__(self):
self.debounce_timer: Optional[threading.Timer] = None
self.debounce_interval = 5.0 # Seconds
def on_any_event(self, event: FileSystemEvent):
# Log all events at DEBUG level to avoid cluttering INFO logs
logger.debug(f"[Watcher] Event detected: {event.event_type} {event.src_path}")
if event.is_directory:
return
# Filter out noisy events. Only listen to actual changes.
# 'opened' and 'closed' (without write) are read events and should be ignored.
if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return
# Ignore temporary files or hidden files
filename = os.path.basename(event.src_path)
if filename.startswith('.'):
return
# Prevent feedback loops: if sync is in progress, ignore events
if sync_manager.is_syncing:
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event.src_path} because sync is in progress.")
return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event.src_path}")
self.trigger_sync()
def trigger_sync(self):
"""
Triggers the sync process after a debounce interval.
"""
if self.debounce_timer:
self.debounce_timer.cancel()
logger.debug(f"[Watcher] Debouncing sync for {self.debounce_interval} seconds...")
self.debounce_timer = threading.Timer(self.debounce_interval, self.run_sync)
self.debounce_timer.start()
def run_sync(self):
"""
Executes the sync via SyncManager.
"""
logger.info("[Watcher] Debounce timer expired. Triggering sync due to file changes.")
try:
sync_manager.run_sync(trigger_source="watcher", wait=False)
except Exception as e:
logger.error(f"[Watcher] Failed to trigger sync: {e}", exc_info=True)
class WatcherManager:
"""
Manages the lifecycle of the file watcher.
"""
def __init__(self):
self.observer: Optional[Observer] = None
self.handler: Optional[PlaylistEventHandler] = None
self.current_path: Optional[str] = None
def start(self, path: str):
"""
Starts watching the specified directory.
"""
# If already watching the same path, do nothing
if self.observer and self.observer.is_alive() and self.current_path == path:
logger.info(f"[Watcher] Already running on {path}")
return
self.stop()
if not os.path.exists(path):
logger.warning(f"[Watcher] Cannot watch path {path}: Directory does not exist.")
return
logger.info(f"[Watcher] Starting file watcher on: {path}")
try:
files = os.listdir(path)
logger.debug(f"[Watcher] Initial files in watch directory: {files}")
except Exception as e:
logger.error(f"[Watcher] Failed to list files in watch directory: {e}")
self.handler = PlaylistEventHandler()
# Explicitly set timeout for PollingObserver
self.observer = Observer(timeout=1.0)
self.observer.schedule(self.handler, path, recursive=True)
self.observer.start()
self.current_path = path
logger.info("[Watcher] Watcher started successfully.")
def stop(self):
"""
Stops the file watcher.
"""
if self.observer:
logger.info("[Watcher] Stopping file watcher...")
self.observer.stop()
self.observer.join()
self.observer = None
self.current_path = None
logger.info("[Watcher] Watcher stopped.")
watcher_manager = WatcherManager()
+1
View File
@@ -10,4 +10,5 @@ services:
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
- LOG_LEVEL=INFO
restart: unless-stopped restart: unless-stopped
+6
View File
@@ -0,0 +1,6 @@
Write-Output "Starting PlexPlaylistSync Docker Container..."
Set-Location ./frontend
npm run build
Set-Location ..
docker compose down
docker compose up --build
+194 -19
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings, SyncState } from './types'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types';
import { apiService } from './services/api'; import { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
@@ -18,7 +18,7 @@ import {
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 } from 'lucide-react'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react';
interface Toast { interface Toast {
id: number; id: number;
@@ -125,6 +125,8 @@ const App: React.FC = () => {
const [loadingCloud, setLoadingCloud] = useState(false); const [loadingCloud, setLoadingCloud] = useState(false);
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE); const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
const manualSyncInProgress = useRef(false);
const lastKnownSyncTimeRef = useRef<string | null | undefined>(undefined);
// Animation Refs // Animation Refs
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState); const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
@@ -142,6 +144,17 @@ const App: React.FC = () => {
// Regex State // Regex State
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]); const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
// Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
mode: ScheduleMode.DISABLED,
cronExpression: '',
dailyTime: '02:00',
weeklyDays: [0], // Sunday
weeklyTime: '03:00',
autoWatch: false
});
const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined);
// 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>}>({});
@@ -154,7 +167,7 @@ const App: React.FC = () => {
} }
}; };
const addToast = (message: string) => { const addToast = useCallback((message: string) => {
const id = Date.now(); const id = Date.now();
// Start with entering: true to position it above // Start with entering: true to position it above
const newToast: Toast = { id, message, exiting: false, entering: true }; const newToast: Toast = { id, message, exiting: false, entering: true };
@@ -171,7 +184,7 @@ const App: React.FC = () => {
}, TOAST_AUTO_DISMISS_MS); }, TOAST_AUTO_DISMISS_MS);
timeoutsRef.current[id] = dismissTimer; timeoutsRef.current[id] = dismissTimer;
}; }, []);
// Effect to trigger the "slide down" animation // Effect to trigger the "slide down" animation
useEffect(() => { useEffect(() => {
@@ -219,6 +232,35 @@ const App: React.FC = () => {
} }
}, []); }, []);
const loadSchedule = useCallback(async () => {
const result = await apiService.getScheduleSettings();
if (result.status === 'success') {
setScheduleSettings(result.data);
setNextRunTime(result.data.nextRun);
}
}, []);
// Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
const result = await apiService.saveScheduleSettings(settings);
if (result.status === 'success') {
setScheduleSettings(settings);
// Refresh schedule info to get next run time
loadSchedule();
if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled.");
} else {
addToast("Scheduled task updated successfully.");
}
return true;
} else {
addToast(result.message || "Failed to update schedule.");
return false;
}
};
// Fetch Local Playlists // Fetch Local Playlists
const refreshLocal = useCallback(async () => { const refreshLocal = useCallback(async () => {
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
@@ -280,7 +322,8 @@ const App: React.FC = () => {
// Load persisted configuration // Load persisted configuration
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
}, [loadSettings]); loadSchedule();
}, [loadSettings, loadSchedule]);
// Initial Load // Initial Load
useEffect(() => { useEffect(() => {
@@ -320,9 +363,12 @@ const App: React.FC = () => {
if (syncState !== SyncState.IDLE) return; if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING); setSyncState(SyncState.SYNCING);
manualSyncInProgress.current = true;
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined); const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined);
manualSyncInProgress.current = false;
if (result.status === 'success') { if (result.status === 'success') {
setSyncState(SyncState.SUCCESS); setSyncState(SyncState.SUCCESS);
@@ -338,12 +384,85 @@ const App: React.FC = () => {
} }
}; };
// SSE for sync status
useEffect(() => {
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const { is_syncing, status, error, last_sync_time } = data;
// Initialize lastKnownSyncTime if it's the first event
if (lastKnownSyncTimeRef.current === undefined) {
lastKnownSyncTimeRef.current = last_sync_time;
// If we are currently syncing on load, show it
if (is_syncing && !manualSyncInProgress.current) {
setSyncState(SyncState.SYNCING);
}
return;
}
// If manual sync is in progress, we ignore background updates to avoid state conflict
if (manualSyncInProgress.current) {
if (last_sync_time !== lastKnownSyncTimeRef.current) {
lastKnownSyncTimeRef.current = last_sync_time;
}
return;
}
// Handle Syncing State
if (is_syncing) {
if (syncState !== SyncState.SYNCING) {
setSyncState(SyncState.SYNCING);
}
} else {
// Check for completion by comparing timestamps
if (last_sync_time !== lastKnownSyncTimeRef.current) {
lastKnownSyncTimeRef.current = last_sync_time;
// A sync has completed since our last check
if (status === 'success') {
setSyncState(SyncState.SUCCESS);
refreshLocal();
refreshCloud();
addToast("Background sync completed successfully.");
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
} else if (status === 'error') {
setSyncState(SyncState.ERROR);
addToast(`Background sync failed: ${error}`);
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
} else {
// Edge case: We are in SYNCING state but backend says not syncing, and time hasn't changed.
if (syncState === SyncState.SYNCING) {
setSyncState(SyncState.IDLE);
}
}
}
} catch (e) {
console.error("Failed to parse SSE event", e);
}
};
eventSource.onerror = (err) => {
console.error("EventSource failed:", err);
eventSource.close();
};
return () => {
eventSource.close();
};
}, [syncState, refreshLocal, refreshCloud, addToast]);
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => { const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo); setCloudServerInfo(serverInfo);
if (serverInfo.libraryName) { if (serverInfo.libraryName) {
await apiService.updateLibrary(serverInfo.libraryName); await apiService.updateLibrary(serverInfo.libraryName);
setConnectionSettings(prev => prev ? { ...prev, libraryName: serverInfo.libraryName } : prev);
} }
// Reload settings to ensure we have the latest connection details (protocol, etc.)
await loadSettings();
// Refresh playlists after new connection // Refresh playlists after new connection
refreshCloud(); refreshCloud();
}; };
@@ -367,6 +486,33 @@ const App: React.FC = () => {
const isConnected = cloudServerInfo?.isConnected; const isConnected = cloudServerInfo?.isConnected;
const getScheduleDisplayInfo = () => {
const result = {
label: 'Schedule',
value: 'Not configured',
active: false,
autoWatch: scheduleSettings.autoWatch
};
if (scheduleSettings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync';
result.value = 'Disabled';
return result;
}
let label = 'Schedule';
if (scheduleSettings.mode === ScheduleMode.CRON) label = 'Cron Schedule';
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule';
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule';
result.label = label;
result.value = nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...';
result.active = true;
return result;
};
const scheduleInfo = getScheduleDisplayInfo();
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">
@@ -438,18 +584,45 @@ const App: React.FC = () => {
</h1> </h1>
</div> </div>
{/* Connection Status Button */} {/* Normal Toolbar Right */}
<button <div className="flex items-center gap-4">
onClick={() => setIsConnectionModalOpen(true)} {/* Schedule Info */}
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 ${ <div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
isConnected <span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20" {scheduleInfo.label}
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20" </span>
}`} <div className="text-xs font-mono flex items-center gap-1.5">
title={isConnected ? "Connected to Plex" : "Disconnected"} {/* Schedule Part */}
> <div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />} {scheduleInfo.active && <Clock size={12} />}
</button> <span>{scheduleInfo.value}</span>
</div>
{/* Watch Part */}
<span className="text-gray-700 mx-0.5">|</span>
<div
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"}
>
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />}
<span className="text-[10px] font-sans font-bold">WATCH</span>
</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 ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</div>
</> </>
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
@@ -515,11 +688,13 @@ const App: React.FC = () => {
/* Desktop Positioning: Center Horizontally, Anchored Top */ /* Desktop Positioning: Center Horizontally, Anchored Top */
md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2" md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2"
> >
<StrategySelector <StrategySelector
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements} savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex} onSaveRegex={handleSaveRegex}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
onSync={handleSyncTrigger} onSync={handleSyncTrigger}
/> />
+446 -164
View File
@@ -1,10 +1,9 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, RegexReplacement, SyncState } from '../types'; import { SyncStrategy, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from '../types';
import { import {
ArrowRightCircle, ArrowRightCircle,
ArrowLeftCircle, ArrowLeftCircle,
GitMerge, GitMerge,
ChevronDown, ChevronDown,
Check, Check,
HelpCircle, HelpCircle,
@@ -12,8 +11,13 @@ import {
Trash2, Trash2,
Save, Save,
RotateCcw, RotateCcw,
Zap,
Loader2, Loader2,
Zap Calendar,
Clock,
Repeat,
CheckSquare,
Square
} from 'lucide-react'; } from 'lucide-react';
interface StrategyOption { interface StrategyOption {
@@ -55,20 +59,45 @@ const STRATEGIES: StrategyOption[] = [
} }
]; ];
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// Helper to determine the actual mode and settings that would be saved based on the current UI state
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule };
if (tab === ScheduleMode.CRON) {
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED;
} else {
// For Daily/Weekly
// If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was CRON or DISABLED), then in the context of this tab, it is effectively Disabled until the user checks the box.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
}
return derived;
};
interface StrategySelectorProps { interface StrategySelectorProps {
currentStrategy: SyncStrategy; currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void; onSelect: (strategy: SyncStrategy, label: string) => void;
savedRegexReplacements: RegexReplacement[]; savedRegexReplacements: RegexReplacement[];
onSaveRegex: (replacements: RegexReplacement[]) => void; onSaveRegex: (replacements: RegexReplacement[]) => void;
savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState; syncState: SyncState;
onSync: () => void; onSync: () => void;
} }
const StrategySelector: React.FC<StrategySelectorProps> = ({ const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy, currentStrategy,
onSelect, onSelect,
savedRegexReplacements, savedRegexReplacements,
onSaveRegex, onSaveRegex,
savedSchedule,
onSaveSchedule,
syncState, syncState,
onSync onSync
}) => { }) => {
@@ -77,23 +106,50 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Local state for regex editing // Local state for regex editing
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]); const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
const [isDirty, setIsDirty] = useState(false); const [isRegexDirty, setIsRegexDirty] = useState(false);
// Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs
// We initialize active tab based on the saved mode. If DISABLED, default to CRON.
const [activeTab, setActiveTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
);
const isSyncing = syncState === SyncState.SYNCING; const isSyncing = syncState === SyncState.SYNCING;
const isLocked = isSyncing || syncState === SyncState.SUCCESS; const isLocked = isSyncing || syncState === SyncState.SUCCESS;
// Initialize local state when prop updates (only if not dirty, or initially) // Initialize local state when prop updates
useEffect(() => { useEffect(() => {
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
setIsDirty(false); setIsRegexDirty(false);
}, [savedRegexReplacements]); }, [savedRegexReplacements]);
useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
// If the saved mode is not disabled, ensure we show that tab.
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveTab(savedSchedule.mode);
}
setIsScheduleDirty(false);
}, [savedSchedule]);
// Check dirty state whenever local changes // Check dirty state whenever local changes
useEffect(() => { useEffect(() => {
const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements); const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements);
setIsDirty(isDifferent); setIsRegexDirty(isDifferent);
}, [localReplacements, savedRegexReplacements]); }, [localReplacements, savedRegexReplacements]);
// Check dirty state for Schedule (including Active Tab changes)
useEffect(() => {
// We calculate what the "effective" schedule would be if we saved right now.
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0]; const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
useEffect(() => { useEffect(() => {
@@ -106,45 +162,108 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Determine if tabs have changed from the saved state
const initialTab = savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode;
const hasTabChanged = activeTab !== initialTab;
const isScheduleActionable = isScheduleDirty || hasTabChanged;
const handleSelect = (strategy: StrategyOption) => { const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return; if (isLocked) return;
onSelect(strategy.value, strategy.label); onSelect(strategy.value, strategy.label);
}; };
// Regex Handlers // --- Regex Handlers ---
const handleAddRegex = () => { const handleAddRegex = () => {
if (isLocked) return;
const newId = Date.now().toString(); const newId = Date.now().toString();
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]); setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
}; };
const handleDeleteRegex = (id: string) => { const handleDeleteRegex = (id: string) => {
if (isLocked) return;
setLocalReplacements(prev => prev.filter(r => r.id !== id)); setLocalReplacements(prev => prev.filter(r => r.id !== id));
}; };
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => { const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
if (isLocked) return;
setLocalReplacements(prev => prev.map(r => setLocalReplacements(prev => prev.map(r =>
r.id === id ? { ...r, [field]: value } : r r.id === id ? { ...r, [field]: value } : r
)); ));
}; };
const handleReset = () => { const handleResetRegex = () => {
if (isLocked) return;
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
}; };
const handleSave = () => { const handleSaveRegex = () => {
if (isLocked) return;
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== ''); const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
setLocalReplacements(validReplacements); setLocalReplacements(validReplacements);
onSaveRegex(validReplacements); onSaveRegex(validReplacements);
}; };
// --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return;
setLocalSchedule(prev => ({ ...prev, [field]: value }));
};
const toggleWeekDay = (dayIndex: number) => {
if (isLocked) return;
const currentDays = localSchedule.weeklyDays;
const newDays = currentDays.includes(dayIndex)
? currentDays.filter(d => d !== dayIndex)
: [...currentDays, dayIndex].sort();
handleUpdateSchedule('weeklyDays', newDays);
};
const handleResetSchedule = () => {
if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveTab(savedSchedule.mode);
} else {
setActiveTab(ScheduleMode.CRON);
}
};
const handleSaveScheduleClick = async () => {
if (isLocked) return;
// Determine the effective settings based on the current view (tab) and inputs
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeTab);
// Call API
const success = await onSaveSchedule(settingsToSave);
if (success) {
setLocalSchedule(settingsToSave);
// Dirty state is cleared by the useEffect prop update, or we can clear it optimistically here if needed,
// but useEffect [savedSchedule] handles it correctly.
}
};
const handleSyncClick = () => { const handleSyncClick = () => {
if (isLocked || isDirty) return; if (isLocked) return;
onSync(); onSync();
}; };
// Helper to toggle enable/disable for current active tab (Daily/Weekly)
const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return;
if (localSchedule.mode === targetMode) {
handleUpdateSchedule('mode', ScheduleMode.DISABLED);
} else {
handleUpdateSchedule('mode', targetMode);
}
};
// If syncing or locked, apply grayscale filter to content sections
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return ( return (
<div className={`relative group ${isLocked ? 'opacity-80' : ''}`} ref={dropdownRef}> <div className="relative group" ref={dropdownRef}>
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */} {/* Trigger Button */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95" className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
@@ -156,7 +275,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
</button> </button>
{/* Dropdown Menu - Persistent Mount for State Preservation */} {/* Dropdown Menu */}
<div <div
className={`absolute className={`absolute
top-14 top-14
@@ -165,159 +284,322 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
/* Desktop: Center alignment */ /* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2 md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
w-80 md:w-[30rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl w-80 md:w-[32rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`} ${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
> >
{/* Section 1: Sync Strategy */} <div className={`flex flex-col max-h-[75vh] md:max-h-[85vh] overflow-y-auto custom-scrollbar ${contentClass}`}>
<div className="px-4 py-3 bg-black/20 border-b border-white/5">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3> {/* Section 1: Sync Strategy */}
<div className="space-y-1"> <div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
{STRATEGIES.map((strategy) => ( <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<div <div className="space-y-1">
key={strategy.value} {STRATEGIES.map((strategy) => (
onClick={() => handleSelect(strategy)} <div
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${ key={strategy.value}
currentStrategy === strategy.value onClick={() => handleSelect(strategy)}
? 'bg-white/10 border-white/10 shadow-sm' className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
: 'hover:bg-white/5 border-transparent' currentStrategy === strategy.value
}`} ? 'bg-white/10 border-white/10 shadow-sm'
> : 'hover:bg-white/5 border-transparent'
<div className="flex items-center space-x-3 overflow-hidden"> }`}
<strategy.icon size={18} className={strategy.color} /> >
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}> <div className="flex items-center space-x-3 overflow-hidden">
{strategy.label} <strategy.icon size={18} className={strategy.color} />
</span> <span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
</div>
</div>
{currentStrategy === strategy.value && (
<Check size={14} className="text-plex-orange" strokeWidth={3} />
)}
</div>
</div> </div>
))}
<div className="flex items-center space-x-2"> </div>
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
</div>
</div>
{currentStrategy === strategy.value && (
<Check size={14} className="text-plex-orange" strokeWidth={3} />
)}
</div>
</div>
))}
</div>
</div>
{/* Section 2: Regex Preprocessing */}
<div className="p-4 bg-gray-900/40">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
{localReplacements.length === 0 && (
<button
onClick={handleAddRegex}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
>
<Plus size={14} />
</button>
)}
</div> </div>
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar"> {/* Section 2: Regex Preprocessing */}
{localReplacements.length === 0 ? ( <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg"> <div className="flex items-center justify-between mb-3">
No regex replacements configured. <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
</div> {localReplacements.length === 0 && (
) : ( <button
localReplacements.map((regex) => (
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Regex Pattern"
value={regex.pattern}
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
disabled={isLocked}
className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600 disabled:opacity-60 disabled:cursor-not-allowed
${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
/>
</div>
<div className="flex-none text-gray-600">
<ArrowRightCircle size={12} />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
disabled={isLocked}
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600 disabled:opacity-60 disabled:cursor-not-allowed"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
disabled={isLocked}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Actions */}
<div className="space-y-3 pt-3 border-t border-white/5">
{localReplacements.length > 0 && (
<div className="flex justify-center">
<button
onClick={handleAddRegex} onClick={handleAddRegex}
disabled={isLocked} className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed" title="Add Rule"
> >
<Plus size={12} /> <Plus size={14} />
<span className="font-medium">Add Rule</span> </button>
</button> )}
</div> </div>
)}
<div className="grid grid-cols-2 gap-3"> <div className="space-y-2 mb-4 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
<button {localReplacements.length === 0 ? (
onClick={handleReset} <div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
disabled={!isDirty || isLocked} No regex replacements configured.
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all </div>
${isDirty && !isLocked ) : (
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white' localReplacements.map((regex) => (
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`} <div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
> <div className="flex-1 min-w-0">
<RotateCcw size={14} /> <input
<span>Revert</span> type="text"
</button> placeholder="Pattern"
<button value={regex.pattern}
onClick={handleSave} onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
disabled={!isDirty || isLocked} className={`w-full bg-gray-900/80 border rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all ${!regex.pattern && isRegexDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
${isDirty && !isLocked />
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10' </div>
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`} <div className="flex-none text-gray-600">
> <ArrowRightCircle size={12} />
<Save size={14} /> </div>
<span>Save Changes</span> <div className="flex-1 min-w-0">
</button> <input
</div> type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
<div className="flex justify-between items-center gap-2">
<button
onClick={handleAddRegex}
className={`flex items-center space-x-1 px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide transition-colors ${localReplacements.length > 0 ? 'text-plex-orange hover:bg-plex-orange/10' : 'hidden'}`}
>
<Plus size={10} />
<span>Add</span>
</button>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleResetRegex}
disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isRegexDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
</button>
<button
onClick={handleSaveRegex}
disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isRegexDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
</button>
</div>
</div>
</div>
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${activeTab === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="mb-4 min-h-[50px]">
{activeTab === ScheduleMode.CRON && (
<div className="space-y-2 animate-in fade-in duration-200">
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span>
<input
type="text"
value={localSchedule.cronExpression}
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)}
placeholder="0 0 * * *"
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 font-mono focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange placeholder-gray-600"
/>
</div>
<p className="text-[10px] text-gray-500">
Unix-cron format. Leave empty to disable schedule.
</p>
</div>
)}
{activeTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
>
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-2">
<input
type="time"
value={localSchedule.dailyTime}
onChange={(e) => handleUpdateSchedule('dailyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.DAILY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.DAILY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
{activeTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"}
>
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div>
{/* Middle Row: Full Width Capsules */}
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
{WEEK_DAYS.map((day, index) => {
const isSelected = localSchedule.weeklyDays.includes(index);
return (
<button
key={index}
onClick={() => toggleWeekDay(index)}
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
first:rounded-l-lg last:rounded-r-lg
${isSelected
? 'bg-plex-orange text-gray-900 border-plex-orange z-10'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
}
`}
>
{day}
</button>
)
})}
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-1">
<input
type="time"
value={localSchedule.weeklyTime}
onChange={(e) => handleUpdateSchedule('weeklyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.WEEKLY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
</div>
{/* Auto Watch Checkbox */}
<div className="flex items-center mb-4 px-1">
<button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
className="flex items-center space-x-2 group"
>
{localSchedule.autoWatch ? (
<CheckSquare size={16} className="text-plex-orange" />
) : (
<Square size={16} className="text-gray-600 group-hover:text-gray-400" />
)}
<span className={`text-xs ${localSchedule.autoWatch ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'}`}>
Watch for local playlist changes
</span>
</button>
</div>
{/* Action Buttons (Mirrored from Regex) */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button
onClick={handleResetSchedule}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isScheduleActionable
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
</button>
<button
onClick={handleSaveScheduleClick}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isScheduleActionable
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
</button>
</div>
</div> </div>
</div> </div>
{/* Section 3: Sync Now Button */} {/* Section 4: Sync Now Button */}
<div className="p-4 bg-gray-950/50 border-t border-white/5"> <div className="p-4 bg-gray-950/50 border-t border-white/5 flex-none">
<button <button
onClick={handleSyncClick} onClick={handleSyncClick}
disabled={isLocked || isDirty} disabled={isLocked}
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked ${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50' ? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isDirty : isRegexDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' ? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]' : 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`} }`}
@@ -334,9 +616,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</> </>
)} )}
</button> </button>
{isDirty && ( {(isRegexDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2"> <p className="text-[10px] text-plex-orange text-center mt-2">
Please save or revert regex rules changes before syncing. Please save regex changes before syncing.
</p> </p>
)} )}
</div> </div>
@@ -345,4 +627,4 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
); );
}; };
export default StrategySelector; export default StrategySelector;
+20 -1
View File
@@ -1,4 +1,4 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy } from '../types'; import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy, ScheduleSettings } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
@@ -97,6 +97,20 @@ export const apiService = {
return handleResponse(response); return handleResponse(response);
}, },
async getScheduleSettings(): Promise<ApiResponse<ScheduleSettings & { nextRun?: string }>> {
const response = await fetch(`${API_BASE}/api/schedule`);
return handleResponse(response);
},
async saveScheduleSettings(settings: ScheduleSettings): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return handleResponse(response);
},
async getPlaylists(serverType: ServerType, signal?: AbortSignal, localPath?: string): Promise<ApiResponse<Playlist[]>> { async getPlaylists(serverType: ServerType, signal?: AbortSignal, localPath?: string): Promise<ApiResponse<Playlist[]>> {
const params = new URLSearchParams({ server: serverType.toLowerCase() }); const params = new URLSearchParams({ server: serverType.toLowerCase() });
if (serverType === ServerType.LOCAL && localPath) { if (serverType === ServerType.LOCAL && localPath) {
@@ -167,4 +181,9 @@ export const apiService = {
}); });
return handleResponse(response); return handleResponse(response);
}, },
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`);
return handleResponse(response);
},
}; };
+16
View File
@@ -40,6 +40,22 @@ export interface RegexReplacement {
replacement: string; replacement: string;
} }
export enum ScheduleMode {
DISABLED = 'DISABLED',
CRON = 'CRON',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY'
}
export interface ScheduleSettings {
mode: ScheduleMode;
cronExpression: string;
dailyTime: string;
weeklyDays: number[]; // 0 = Sunday, 1 = Monday, etc.
weeklyTime: string;
autoWatch: boolean;
}
export interface PlexLibrary { export interface PlexLibrary {
id: string; id: string;
title: string; title: string;
+2
View File
@@ -4,3 +4,5 @@ jinja2
python-multipart python-multipart
plexapi plexapi
merge3 merge3
apscheduler
watchdog
+242 -25
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, SyncState } from './types'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from './types';
import { apiService } from './services/api'; import { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
@@ -15,7 +15,7 @@ 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 } from 'lucide-react'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2 } from 'lucide-react';
interface Toast { interface Toast {
id: number; id: number;
@@ -31,7 +31,7 @@ const useStripeAnimation = (syncState: SyncState) => {
const rightYellowRef = useRef<HTMLDivElement>(null); const rightYellowRef = useRef<HTMLDivElement>(null);
const rightGreenRef = useRef<HTMLDivElement>(null); const rightGreenRef = useRef<HTMLDivElement>(null);
const requestRef = useRef<number>(); const requestRef = useRef<number | undefined>(undefined);
const lastTimeRef = useRef<number>(0); const lastTimeRef = useRef<number>(0);
const offsetRef = useRef<number>(0); const offsetRef = useRef<number>(0);
@@ -135,8 +135,27 @@ const App: React.FC = () => {
// Strategy State // Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE); const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State // Path Mapping State (Includes Simple and Regex Rules)
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]); const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
mode: ScheduleMode.DISABLED,
cronExpression: '',
dailyTime: '02:00',
weeklyDays: [0], // Sunday
weeklyTime: '03:00',
autoWatch: false
});
// Toast Notification System // Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
@@ -280,10 +299,33 @@ const App: React.FC = () => {
addToast(`Selected strategy "${label}" has been saved.`); addToast(`Selected strategy "${label}" has been saved.`);
}; };
// Handle Regex Save // Handle Path Mapping Save
const handleSaveRegex = (replacements: RegexReplacement[]) => { const handleSavePathMapping = (config: PathMappingConfig) => {
setRegexReplacements(replacements); setPathMappingConfig(config);
addToast('Regex preprocessing rules have been saved.'); addToast('Path mapping rules have been saved.');
};
// Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
// Call API (validation happens in Mock)
const result = await apiService.saveScheduleSettings(settings);
if (result.status === 'success') {
// Only update local state if successful
setScheduleSettings(settings);
if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled.");
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast("Scheduled tasks disabled (Empty Cron).");
} else {
addToast("Scheduled task started successfully.");
}
return true;
} else {
addToast(result.message || "Failed to update schedule.");
return false;
}
}; };
// Handle Sync Trigger // Handle Sync Trigger
@@ -293,7 +335,7 @@ const App: React.FC = () => {
setSyncState(SyncState.SYNCING); setSyncState(SyncState.SYNCING);
// Note: We deliberately do not clear playlists here to keep UI populated during sync // Note: We deliberately do not clear playlists here to keep UI populated during sync
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements); const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig);
if (result.status === 'success') { if (result.status === 'success') {
// Transition to Success state // Transition to Success state
@@ -346,6 +388,141 @@ const App: React.FC = () => {
const isConnected = cloudServerInfo?.isConnected; const isConnected = cloudServerInfo?.isConnected;
// Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = {
label: 'Schedule',
value: 'Not configured',
active: false,
autoWatch: settings.autoWatch
};
if (settings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync';
result.value = 'Disabled';
return result;
}
if (settings.mode === ScheduleMode.CRON) {
result.label = 'Cron Schedule';
result.value = settings.cronExpression || 'Pending...';
result.active = true;
return result;
}
const now = new Date();
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let nextRun: Date | null = null;
let timeStr = '';
if (settings.mode === ScheduleMode.DAILY) {
const [h, m] = settings.dailyTime.split(':').map(Number);
const target = new Date();
target.setHours(h, m, 0, 0);
timeStr = settings.dailyTime;
if (now < target) {
nextRun = target;
} else {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(h, m, 0, 0);
nextRun = tomorrow;
}
} else if (settings.mode === ScheduleMode.WEEKLY) {
timeStr = settings.weeklyTime;
const [h, m] = settings.weeklyTime.split(':').map(Number);
const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) {
result.label = 'Weekly Schedule';
result.value = 'No days selected';
return result;
}
// Check rest of today
if (activeDays.includes(now.getDay())) {
const todayTarget = new Date();
todayTarget.setHours(h, m, 0, 0);
if (todayTarget > now) {
nextRun = todayTarget;
}
}
// Check future days
if (!nextRun) {
for (let i = 1; i <= 7; i++) {
const nextDayIndex = (now.getDay() + i) % 7;
if (activeDays.includes(nextDayIndex)) {
const d = new Date();
d.setDate(now.getDate() + i);
d.setHours(h, m, 0, 0);
nextRun = d;
break;
}
}
}
}
if (nextRun) {
// Format logic
const isToday = nextRun.getDate() === now.getDate() && nextRun.getMonth() === now.getMonth();
const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate();
let dateStr = '';
if (isToday) dateStr = 'Today';
else if (isTomorrow) dateStr = 'Tomorrow';
else dateStr = days[nextRun.getDay()];
result.label = `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`;
result.value = `${dateStr} at ${timeStr}`;
result.active = true;
return result;
}
return result;
};
const scheduleInfo = getScheduleDisplayInfo(scheduleSettings);
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let modeLabel = '';
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
} else {
modeLabel = 'Regex';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: 'Path Mapping',
value: 'Not Set',
active: false,
Icon: Icon
};
}
return {
label: 'Path Mapping',
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
};
};
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
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">
@@ -411,7 +588,7 @@ const App: React.FC = () => {
{syncState === SyncState.IDLE ? ( {syncState === SyncState.IDLE ? (
<> <>
{/* Normal Toolbar */} {/* Normal Toolbar Left */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20"> <div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} /> <ArrowLeftRight size={24} strokeWidth={2.5} />
@@ -421,18 +598,56 @@ const App: React.FC = () => {
</h1> </h1>
</div> </div>
{/* Connection Status Button */} {/* Normal Toolbar Right */}
<button <div className="flex items-center gap-4">
onClick={() => setIsConnectionModalOpen(true)} {/* Path Mapping Info */}
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 <div className="flex flex-col items-end hidden md:flex border-r border-gray-700/50 pr-4 mr-1">
${isConnected <span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20" {pathMappingInfo.label}
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20" </span>
}`} <div className={`text-xs font-mono flex items-center gap-1.5 ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
title={isConnected ? "Connected to Plex" : "Disconnected"} {pathMappingInfo.active && <pathMappingInfo.Icon size={12} />}
> <span>{pathMappingInfo.value}</span>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />} </div>
</button> </div>
{/* Schedule Info */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{scheduleInfo.label}
</span>
<div className="text-xs font-mono flex items-center gap-1.5">
{/* Schedule Part */}
<div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{scheduleInfo.active && <Clock size={12} />}
<span>{scheduleInfo.value}</span>
</div>
{/* Watch Part */}
<span className="text-gray-700 mx-0.5">|</span>
<div
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"}
>
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />}
<span className="text-[10px] font-sans font-bold">WATCH</span>
</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 ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</div>
</> </>
) : ( ) : (
/* Syncing / Success Text Banner */ /* Syncing / Success Text Banner */
@@ -502,8 +717,10 @@ const App: React.FC = () => {
<StrategySelector <StrategySelector
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements} savedPathMapping={pathMappingConfig}
onSaveRegex={handleSaveRegex} onSavePathMapping={handleSavePathMapping}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
onSync={handleSyncTrigger} onSync={handleSyncTrigger}
/> />
+580 -136
View File
@@ -1,6 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, RegexReplacement, SyncState } from '../types'; import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from '../types';
import { import {
ArrowRightCircle, ArrowRightCircle,
ArrowLeftCircle, ArrowLeftCircle,
@@ -13,7 +12,14 @@ import {
Save, Save,
RotateCcw, RotateCcw,
Zap, Zap,
Loader2 Loader2,
Calendar,
Clock,
Repeat,
CheckSquare,
Square,
Type,
Code2
} from 'lucide-react'; } from 'lucide-react';
interface StrategyOption { interface StrategyOption {
@@ -55,11 +61,160 @@ const STRATEGIES: StrategyOption[] = [
} }
]; ];
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// Color Theme Variables for Mapping Editors
const MAPPING_THEME = {
// Container Themes
local: {
borderColor: "border-blue-500/20",
bgColor: "bg-blue-900/10"
},
remote: {
borderColor: "border-green-500/20",
bgColor: "bg-green-900/10"
},
simple: {
borderColor: "border-gray-700/50",
bgColor: "bg-gray-900/40"
},
// Input Field Themes
inputs: {
default: "bg-gray-800 border-gray-700 text-gray-200 focus:border-plex-orange placeholder-gray-600",
local: "bg-blue-500/10 border-blue-500/30 text-blue-100 focus:border-blue-400 placeholder-blue-300/30",
cloud: "bg-green-500/10 border-green-500/30 text-green-100 focus:border-green-400 placeholder-green-300/30"
}
};
// Helper to determine the actual mode and settings that would be saved based on the current UI state
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule };
if (tab === ScheduleMode.CRON) {
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED;
} else {
// For Daily/Weekly
// If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was CRON or DISABLED), then in the context of this tab, it is effectively Disabled until the user checks the box.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
}
return derived;
};
// Sub-component for a single Mapping Group Editor
interface MappingGroupEditorProps {
title: string;
subtitle?: string;
rules: ReplacementRule[];
onChange: (newRules: ReplacementRule[]) => void;
isLocked: boolean;
borderColor?: string;
bgColor?: string;
// Input specific props
leftPlaceholder?: string;
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
title,
subtitle,
rules,
onChange,
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftInputClass,
rightInputClass
}) => {
const handleAdd = () => {
if (isLocked) return;
const newId = Date.now().toString() + Math.random().toString();
onChange([...rules, { id: newId, search: '', replace: '' }]);
};
const handleUpdate = (id: string, field: 'search' | 'replace', value: string) => {
if (isLocked) return;
onChange(rules.map(r => r.id === id ? { ...r, [field]: value } : r));
};
const handleDelete = (id: string) => {
if (isLocked) return;
onChange(rules.filter(r => r.id !== id));
};
// Default input style if not provided
const defaultInputStyle = MAPPING_THEME.inputs.default;
return (
<div className={`p-3 rounded-lg border ${borderColor} ${bgColor} flex flex-col h-full transition-colors`}>
<div className="flex items-center justify-between mb-2">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-gray-400">{title}</h4>
{subtitle && <p className="text-[9px] text-gray-500">{subtitle}</p>}
</div>
<button
onClick={handleAdd}
disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
>
<Plus size={12} />
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No rules defined.
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
/>
<ArrowRightCircle size={10} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
/>
<button
onClick={() => handleDelete(rule.id)}
className="text-gray-600 hover:text-red-400 p-1 rounded hover:bg-red-500/10 transition-colors flex-none"
>
<Trash2 size={12} />
</button>
</div>
))
)}
</div>
</div>
);
};
interface StrategySelectorProps { interface StrategySelectorProps {
currentStrategy: SyncStrategy; currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void; onSelect: (strategy: SyncStrategy, label: string) => void;
savedRegexReplacements: RegexReplacement[]; savedPathMapping: PathMappingConfig;
onSaveRegex: (replacements: RegexReplacement[]) => void; onSavePathMapping: (config: PathMappingConfig) => void;
savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState; syncState: SyncState;
onSync: () => void; onSync: () => void;
} }
@@ -67,32 +222,58 @@ interface StrategySelectorProps {
const StrategySelector: React.FC<StrategySelectorProps> = ({ const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy, currentStrategy,
onSelect, onSelect,
savedRegexReplacements, savedPathMapping,
onSaveRegex, onSavePathMapping,
savedSchedule,
onSaveSchedule,
syncState, syncState,
onSync onSync
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for regex editing // Local state for path mapping editing (stores all lists for both modes)
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]); const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isDirty, setIsDirty] = useState(false); const [isMappingDirty, setIsMappingDirty] = useState(false);
// Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs
const [activeScheduleTab, setActiveScheduleTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
);
const isSyncing = syncState === SyncState.SYNCING; const isSyncing = syncState === SyncState.SYNCING;
const isLocked = isSyncing || syncState === SyncState.SUCCESS; const isLocked = isSyncing || syncState === SyncState.SUCCESS;
// Initialize local state when prop updates (only if not dirty, or initially) // Initialize local state when prop updates
useEffect(() => { useEffect(() => {
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
setIsDirty(false); setIsMappingDirty(false);
}, [savedRegexReplacements]); }, [savedPathMapping]);
// Check dirty state whenever local changes
useEffect(() => { useEffect(() => {
const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
setIsDirty(isDifferent); if (savedSchedule.mode !== ScheduleMode.DISABLED) {
}, [localReplacements, savedRegexReplacements]); setActiveScheduleTab(savedSchedule.mode);
}
setIsScheduleDirty(false);
}, [savedSchedule]);
// Check dirty state whenever local mapping changes
useEffect(() => {
const isDifferent = JSON.stringify(localPathMapping) !== JSON.stringify(savedPathMapping);
setIsMappingDirty(isDifferent);
}, [localPathMapping, savedPathMapping]);
// Check dirty state for Schedule (including Active Tab changes)
useEffect(() => {
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeScheduleTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0]; const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
@@ -106,40 +287,102 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const isScheduleActionable = isScheduleDirty || (activeScheduleTab !== (savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode));
const handleSelect = (strategy: StrategyOption) => { const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return; if (isLocked) return;
onSelect(strategy.value, strategy.label); onSelect(strategy.value, strategy.label);
}; };
// Regex Handlers // --- Path Mapping Handlers ---
const handleAddRegex = () => { const currentMappingMode = localPathMapping.mode;
const updateRegexGroup = (section: keyof PathMappingRules, newRules: ReplacementRule[]) => {
if (isLocked) return; if (isLocked) return;
const newId = Date.now().toString(); setLocalPathMapping(prev => ({
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]); ...prev,
regex: {
...prev.regex,
[section]: newRules
}
}));
}; };
const handleDeleteRegex = (id: string) => { const updateSimpleGroup = (newRules: ReplacementRule[]) => {
if (isLocked) return; if (isLocked) return;
setLocalReplacements(prev => prev.filter(r => r.id !== id)); setLocalPathMapping(prev => ({
...prev,
simple: newRules
}));
}; };
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => { const setMappingMode = (mode: PathMappingMode) => {
if (isLocked) return; if (isLocked) return;
setLocalReplacements(prev => prev.map(r => setLocalPathMapping(prev => ({ ...prev, mode }));
r.id === id ? { ...r, [field]: value } : r
));
}; };
const handleReset = () => { const handleResetMapping = () => {
if (isLocked) return; if (isLocked) return;
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
}; };
const handleSave = () => { const handleSaveMappingClick = () => {
if (isLocked) return; if (isLocked) return;
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== ''); const clean = (rules: ReplacementRule[]) => rules.filter(r => r.search.trim() !== '');
setLocalReplacements(validReplacements);
onSaveRegex(validReplacements); // Clean regex rules
const cleanRegex = (rules: PathMappingRules): PathMappingRules => ({
localPre: clean(rules.localPre),
localPost: clean(rules.localPost),
remotePre: clean(rules.remotePre),
remotePost: clean(rules.remotePost),
});
const cleanedConfig: PathMappingConfig = {
mode: localPathMapping.mode,
simple: clean(localPathMapping.simple),
regex: cleanRegex(localPathMapping.regex),
};
setLocalPathMapping(cleanedConfig);
onSavePathMapping(cleanedConfig);
};
const regexRules = localPathMapping.regex;
const simpleRules = localPathMapping.simple;
// --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return;
setLocalSchedule(prev => ({ ...prev, [field]: value }));
};
const toggleWeekDay = (dayIndex: number) => {
if (isLocked) return;
const currentDays = localSchedule.weeklyDays;
const newDays = currentDays.includes(dayIndex)
? currentDays.filter(d => d !== dayIndex)
: [...currentDays, dayIndex].sort();
handleUpdateSchedule('weeklyDays', newDays);
};
const handleResetSchedule = () => {
if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode);
} else {
setActiveScheduleTab(ScheduleMode.CRON);
}
};
const handleSaveScheduleClick = async () => {
if (isLocked) return;
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const success = await onSaveSchedule(settingsToSave);
if (success) {
setLocalSchedule(settingsToSave);
}
}; };
const handleSyncClick = () => { const handleSyncClick = () => {
@@ -147,12 +390,20 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSync(); onSync();
}; };
// If syncing or locked, apply grayscale filter to content sections const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return;
if (localSchedule.mode === targetMode) {
handleUpdateSchedule('mode', ScheduleMode.DISABLED);
} else {
handleUpdateSchedule('mode', targetMode);
}
};
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all"; const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return ( return (
<div className="relative group" ref={dropdownRef}> <div className="relative group" ref={dropdownRef}>
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */} {/* Trigger Button */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95" className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
@@ -164,22 +415,24 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
</button> </button>
{/* Dropdown Menu - Persistent Mount for State Preservation */} {/* Dropdown Menu */}
<div <div
className={`absolute className={`absolute
top-14 top-14
/* Mobile: Open to left */ /* Mobile: Open to left (max width of screen) */
right-0 origin-top-right right-0 w-[90vw] max-w-[90vw] origin-top-right
/* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
w-80 md:w-[30rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl /* Desktop: Center alignment, wider */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2 md:w-[60rem] md:max-w-[60rem]
bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`} ${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
> >
<div className={contentClass}> <div className={`flex flex-col max-h-[75vh] md:max-h-[85vh] overflow-y-auto custom-scrollbar ${contentClass}`}>
{/* Section 1: Sync Strategy */} {/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5"> <div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<div className="space-y-1"> <div className="space-y-1">
{STRATEGIES.map((strategy) => ( {STRATEGIES.map((strategy) => (
@@ -216,115 +469,306 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
</div> </div>
{/* Section 2: Regex Preprocessing */} {/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Path Mapping</h3>
{localReplacements.length === 0 && (
<button
onClick={handleAddRegex}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
>
<Plus size={14} />
</button>
)}
</div> </div>
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar"> {/* Tabs for Path Mapping Mode */}
{localReplacements.length === 0 ? ( <div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg"> {[
No regex replacements configured. { id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type },
</div> { id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 },
) : ( ].map((tab) => (
localReplacements.map((regex) => (
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Regex Pattern"
value={regex.pattern}
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600
${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
/>
</div>
<div className="flex-none text-gray-600">
<ArrowRightCircle size={12} />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Actions */}
<div className="space-y-3 pt-3 border-t border-white/5">
{localReplacements.length > 0 && (
<div className="flex justify-center">
<button
onClick={handleAddRegex}
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100"
>
<Plus size={12} />
<span className="font-medium">Add Rule</span>
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<button <button
onClick={handleReset} key={tab.id}
disabled={!isDirty} onClick={() => setMappingMode(tab.id)}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${isDirty ${currentMappingMode === tab.id
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white' ? 'bg-gray-700 text-plex-orange shadow-sm'
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`} : 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
> >
<RotateCcw size={14} /> <tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className="mb-4">
{currentMappingMode === PathMappingMode.SIMPLE ? (
// Simple Mode: Single Editor
<div className="animate-in fade-in duration-200">
<MappingGroupEditor
title="Path Mapping"
subtitle="Map Local paths to Cloud paths using simple string matching"
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path"
rightPlaceholder="Cloud Path"
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
/>
</div>
) : (
// Regex Mode: 2x2 Grid
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Pre-Processing (Before Sync)"
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Pre-Processing (Before Sync)"
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Post-Processing (After Sync / Result)"
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Post-Processing (After Sync / Result)"
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
</div>
)}
</div>
<div className="flex justify-end items-center gap-2">
<button
onClick={handleResetMapping}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isMappingDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span> <span>Revert</span>
</button> </button>
<button <button
onClick={handleSave} onClick={handleSaveMappingClick}
disabled={!isDirty} disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isDirty ${isMappingDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10' ? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`} : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
> >
<Save size={14} /> <Save size={12} />
<span>Save Changes</span> <span>Save Rules</span>
</button> </button>
</div> </div>
</div> </div>
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveScheduleTab(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${activeScheduleTab === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="mb-4 min-h-[50px]">
{activeScheduleTab === ScheduleMode.CRON && (
<div className="space-y-2 animate-in fade-in duration-200">
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span>
<input
type="text"
value={localSchedule.cronExpression}
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)}
placeholder="0 0 * * *"
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 font-mono focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange placeholder-gray-600"
/>
</div>
<p className="text-[10px] text-gray-500">
Unix-cron format. Leave empty to disable schedule.
</p>
</div>
)}
{activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
>
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-2">
<input
type="time"
value={localSchedule.dailyTime}
onChange={(e) => handleUpdateSchedule('dailyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.DAILY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.DAILY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
{activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"}
>
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div>
{/* Middle Row: Full Width Capsules */}
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
{WEEK_DAYS.map((day, index) => {
const isSelected = localSchedule.weeklyDays.includes(index);
return (
<button
key={index}
onClick={() => toggleWeekDay(index)}
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
first:rounded-l-lg last:rounded-r-lg
${isSelected
? 'bg-plex-orange text-gray-900 border-plex-orange z-10'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
}
`}
>
{day}
</button>
)
})}
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-1">
<input
type="time"
value={localSchedule.weeklyTime}
onChange={(e) => handleUpdateSchedule('weeklyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.WEEKLY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
</div>
{/* Auto Watch Checkbox */}
<div className="flex items-center mb-4 px-1">
<button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
className="flex items-center space-x-2 group"
>
{localSchedule.autoWatch ? (
<CheckSquare size={16} className="text-plex-orange" />
) : (
<Square size={16} className="text-gray-600 group-hover:text-gray-400" />
)}
<span className={`text-xs ${localSchedule.autoWatch ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'}`}>
Watch for local playlist changes
</span>
</button>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button
onClick={handleResetSchedule}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isScheduleActionable
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
</button>
<button
onClick={handleSaveScheduleClick}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isScheduleActionable
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
</button>
</div>
</div> </div>
</div> </div>
{/* Section 3: Sync Now Button */} {/* Section 4: Sync Now Button */}
<div className="p-4 bg-gray-950/50 border-t border-white/5"> <div className="p-4 bg-gray-950/50 border-t border-white/5 flex-none">
<button <button
onClick={handleSyncClick} onClick={handleSyncClick}
disabled={isLocked || isDirty} // Disable if syncing OR if there are unsaved regex changes disabled={isLocked}
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked ${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50' ? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isDirty : isMappingDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' // Must save rules first ? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]' : 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`} }`}
> >
@@ -340,9 +784,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</> </>
)} )}
</button> </button>
{isDirty && ( {(isMappingDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2"> <p className="text-[10px] text-plex-orange text-center mt-2">
Please save or revert regex rules changes before syncing. Please save path mapping changes before syncing.
</p> </p>
)} )}
</div> </div>
@@ -351,4 +795,4 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
); );
}; };
export default StrategySelector; export default StrategySelector;
+7 -6
View File
@@ -41,13 +41,13 @@
background: #6b7280; background: #6b7280;
} }
/* Force native date/time pickers to use dark mode scheme */
input[type="time"] {
color-scheme: dark;
}
/* /*
Symmetrical Diagonal Scroll Animations Symmetrical Diagonal Scroll Animations
Pattern width: 40px (20px color + 20px transparent).
Diagonal length: 40 * sqrt(2) ≈ 56.57px.
Left Side: Anchored to Right (Center). Moves Left (increases right offset).
Right Side: Anchored to Left (Center). Moves Right (increases left offset).
*/ */
@keyframes scroll-out-left { @keyframes scroll-out-left {
0% { background-position: right 0 top 0; } 0% { background-position: right 0 top 0; }
@@ -64,7 +64,8 @@
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0", "lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/", "react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0", "react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/" "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"react-dom": "https://aistudiocdn.com/react-dom@^19.2.0"
} }
} }
</script> </script>
+34 -5
View File
@@ -1,5 +1,8 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, RegexReplacement } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode } 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;
@@ -126,15 +129,24 @@ const authenticatePlex = async (settings: PlexConnectionSettings, signal?: Abort
}); });
} }
const triggerSync = async (strategy: SyncStrategy, regexRules: RegexReplacement[]): Promise<void> => { const triggerSync = async (strategy: SyncStrategy, pathMapping: PathMappingConfig): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
// Simulate a sync process taking 3 seconds // Simulate a sync process taking 3 seconds
// In a real app, pathMapping would be sent to backend
setTimeout(() => { setTimeout(() => {
resolve(); resolve();
}, 3000); }, 3000);
}); });
}; };
// Basic Cron validation helper
const validateCron = (expression: string): boolean => {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return false;
// A very naive check, real validation is more complex but this fits the mock requirement
return true;
};
export const apiService = { export const apiService = {
getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => { getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => {
try { try {
@@ -184,12 +196,29 @@ export const apiService = {
} }
}, },
syncPlaylists: async (strategy: SyncStrategy, regexRules: RegexReplacement[]): Promise<ApiResponse<null>> => { syncPlaylists: async (strategy: SyncStrategy, pathMapping: PathMappingConfig): Promise<ApiResponse<null>> => {
try { try {
await triggerSync(strategy, regexRules); await triggerSync(strategy, pathMapping);
return { data: null, status: 'success', message: 'Sync complete' }; return { data: null, status: 'success', message: 'Sync complete' };
} catch (error) { } catch (error) {
return { data: null, status: 'error', message: 'Sync failed' }; return { data: null, status: 'error', message: 'Sync failed' };
} }
},
saveScheduleSettings: async (settings: ScheduleSettings): Promise<ApiResponse<null>> => {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
// Validation only applies if the mode is CRON and user provided input
if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() !== '') {
if (!validateCron(settings.cronExpression)) {
resolve({ data: null, status: 'error', message: 'Invalid Cron expression format' });
return;
}
}
resolve({ data: null, status: 'success', message: 'Schedule updated successfully' });
}, 500);
});
} }
}; };
+39 -4
View File
@@ -1,4 +1,5 @@
export interface Track { export interface Track {
id: string; id: string;
title: string; title: string;
@@ -34,10 +35,44 @@ export enum SyncState {
ERROR = 'ERROR' ERROR = 'ERROR'
} }
export interface RegexReplacement { export interface ReplacementRule {
id: string; id: string;
pattern: string; search: string;
replacement: string; replace: string;
}
export interface PathMappingRules {
localPre: ReplacementRule[];
localPost: ReplacementRule[];
remotePre: ReplacementRule[];
remotePost: ReplacementRule[];
}
export enum PathMappingMode {
SIMPLE = 'SIMPLE',
REGEX = 'REGEX'
}
export interface PathMappingConfig {
mode: PathMappingMode;
simple: ReplacementRule[];
regex: PathMappingRules;
}
export enum ScheduleMode {
DISABLED = 'DISABLED',
CRON = 'CRON',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY'
}
export interface ScheduleSettings {
mode: ScheduleMode;
cronExpression: string;
dailyTime: string;
weeklyDays: number[]; // 0 = Sunday, 1 = Monday, etc.
weeklyTime: string;
autoWatch: boolean;
} }
export interface PlexLibrary { export interface PlexLibrary {
@@ -69,4 +104,4 @@ export interface ApiResponse<T> {
data: T; data: T;
status: 'success' | 'error'; status: 'success' | 'error';
message?: string; message?: string;
} }