8 Commits

Author SHA1 Message Date
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 559342fae7 feat: Enhance scheduler and watcher with improved logging and cron trigger helpers 2025-11-29 12:56:18 +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 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
11 changed files with 1260 additions and 186 deletions
+137 -2
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=["*"],
@@ -107,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
@@ -401,6 +454,75 @@ 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")
async def api_sync(payload: SyncRequest):
server_config.load()
try:
sync_mode = payload.mode or SyncMode(server_config.sync_mode)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# Update config temporarily for this sync if needed, but sync_manager reads from config.
# If payload overrides config, we might need to handle that.
# However, sync_manager._perform_sync reads from server_config.
# If we want to support one-off sync with custom params via sync_manager, we need to update sync_manager.
# For now, let's assume payload params should be saved or used.
# But sync_manager is designed to run background tasks too.
# If we want to keep the existing behavior of api_sync (blocking and returning stats),
# we can use sync_manager.run_sync(wait=True).
# But we need to make sure sync_manager uses the params from payload if provided.
# Since sync_manager reads from server_config, let's update server_config if payload has values.
# Or better, pass params to sync_manager.run_sync?
# sync_manager._perform_sync currently hardcodes reading from server_config.
# Let's stick to the requirement: "watchdog当发现更改时,执行同步,同步时UI页面也会显示正在同步状态。"
# This implies we need a shared state.
# If I change api_sync to use sync_manager, I need to ensure it supports the custom params.
# But payload.local_path and payload.mode are optional.
# Let's modify sync_manager to accept overrides.
# But wait, sync_manager is a singleton.
# For this task, I will just wrap the existing logic in sync_manager.run_sync(wait=True)
# AND I will modify sync_manager to allow passing explicit args to _perform_sync.
# But first, let's update api_sync to use sync_manager.run_sync(wait=True)
# AND we need to handle the parameter passing.
# Actually, looking at sync_manager implementation I just wrote:
# def _perform_sync(self):
# server_config.load()
# return sync_all_playlists(local_dir=server_config.local_path, mode=SyncMode(server_config.sync_mode))
# It ignores arguments. This is a limitation.
# I should update SyncManager to accept kwargs for sync_all_playlists.
# Let's update SyncManager first.
pass
@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()
@@ -410,7 +532,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")
+35
View File
@@ -22,6 +22,12 @@ class ServerConfig:
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:
@@ -49,6 +55,12 @@ class ServerConfig:
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 []
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: {self.__dict__}") logger.info(f"Server config loaded: {self.__dict__}")
def save(self): def save(self):
@@ -63,6 +75,12 @@ class ServerConfig:
"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)
@@ -102,6 +120,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,
+14 -1
View File
@@ -159,8 +159,21 @@ 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)
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
+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()
+178 -5
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,6 +384,77 @@ 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) {
@@ -369,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">
@@ -440,6 +584,32 @@ const App: React.FC = () => {
</h1> </h1>
</div> </div>
{/* Normal Toolbar Right */}
<div className="flex items-center gap-4">
{/* 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 */} {/* Connection Status Button */}
<button <button
onClick={() => setIsConnectionModalOpen(true)} onClick={() => setIsConnectionModalOpen(true)}
@@ -452,6 +622,7 @@ const App: React.FC = () => {
> >
{isConnected ? <Server size={18} /> : <ServerOff size={18} />} {isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button> </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">
@@ -522,6 +693,8 @@ const App: React.FC = () => {
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements} savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex} onSaveRegex={handleSaveRegex}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
onSync={handleSyncTrigger} onSync={handleSyncTrigger}
/> />
+339 -57
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, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from '../types';
import { import {
ArrowRightCircle, ArrowRightCircle,
ArrowLeftCircle, ArrowLeftCircle,
@@ -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,11 +59,34 @@ 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;
} }
@@ -69,6 +96,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
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,12 +284,14 @@ 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'}`}
> >
<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) => (
@@ -208,7 +329,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
{/* Section 2: Regex Preprocessing */} {/* Section 2: Regex Preprocessing */}
<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">Regex Rules</h3>
{localReplacements.length === 0 && ( {localReplacements.length === 0 && (
@@ -222,9 +343,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
)} )}
</div> </div>
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar"> <div className="space-y-2 mb-4 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
{localReplacements.length === 0 ? ( {localReplacements.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg"> <div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No regex replacements configured. No regex replacements configured.
</div> </div>
) : ( ) : (
@@ -233,12 +354,11 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<input <input
type="text" type="text"
placeholder="Regex Pattern" placeholder="Pattern"
value={regex.pattern} value={regex.pattern}
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)} onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
disabled={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={`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 && isRegexDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
/> />
</div> </div>
<div className="flex-none text-gray-600"> <div className="flex-none text-gray-600">
@@ -250,14 +370,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
placeholder="Replacement" placeholder="Replacement"
value={regex.replacement} value={regex.replacement}
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)} 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 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"
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> </div>
<button <button
onClick={() => handleDeleteRegex(regex.id)} 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"
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" title="Delete Rule"
> >
<Trash2 size={14} /> <Trash2 size={14} />
@@ -267,57 +385,221 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
)} )}
</div> </div>
{/* Actions */} <div className="flex justify-between items-center gap-2">
<div className="space-y-3 pt-3 border-t border-white/5">
{localReplacements.length > 0 && (
<div className="flex justify-center">
<button <button
onClick={handleAddRegex} onClick={handleAddRegex}
disabled={isLocked} 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'}`}
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"
> >
<Plus size={12} /> <Plus size={10} />
<span className="font-medium">Add Rule</span> <span>Add</span>
</button> </button>
</div>
)}
<div className="grid grid-cols-2 gap-3"> <div className="flex items-center gap-2 ml-auto">
<button <button
onClick={handleReset} onClick={handleResetRegex}
disabled={!isDirty || isLocked} disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isDirty && !isLocked ${isRegexDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white' ? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`} : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
> >
<RotateCcw size={14} /> <RotateCcw size={12} />
<span>Revert</span> <span>Revert</span>
</button> </button>
<button <button
onClick={handleSave} onClick={handleSaveRegex}
disabled={!isDirty || isLocked} disabled={!isRegexDirty}
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 && !isLocked ${isRegexDirty
? '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</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Section 3: Sync Now Button */} {/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-950/50 border-t border-white/5"> <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>
{/* Section 4: Sync Now Button */}
<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>
+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