6 Commits

Author SHA1 Message Date
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 432eee153e PlexPlaylist_UI subtree merge
feat: Add eye icon for visibility toggles

Merge commit 'd1a4273fb2f0c2b69e166cace3729fdb02b310ab'
2025-11-29 12:35:27 +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
5 changed files with 214 additions and 91 deletions
+1
View File
@@ -156,6 +156,7 @@ async def save_schedule(settings: ScheduleSettings):
auto_watch=settings.autoWatch auto_watch=settings.autoWatch
) )
update_scheduler_job() update_scheduler_job()
logger.info(f"Schedule settings updated via API. Mode: {settings.mode}")
return {"status": "success", "message": "Schedule updated"} return {"status": "success", "message": "Schedule updated"}
+88 -45
View File
@@ -1,14 +1,21 @@
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.base import BaseTrigger
from app.utils.config import server_config from app.utils.config import server_config
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.watcher import watcher_manager from app.utils.watcher import watcher_manager
from app.utils.sync_manager import sync_manager from app.utils.sync_manager import sync_manager
import os import os
# Initialize the scheduler
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
def validate_cron_expression(expression: str) -> bool: def validate_cron_expression(expression: str) -> bool:
"""
Validates a cron expression.
Expected format: "minute hour day month day_of_week"
"""
try: try:
parts = expression.split() parts = expression.split()
if len(parts) != 5: if len(parts) != 5:
@@ -26,92 +33,128 @@ def validate_cron_expression(expression: str) -> bool:
return False return False
def job_function(): def job_function():
"""
The function to be executed by the scheduler.
Triggers the sync process.
"""
logger.info("Executing scheduled sync job...") logger.info("Executing scheduled sync job...")
sync_manager.run_sync(trigger_source="scheduler", wait=False) 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(): def start_scheduler():
"""
Starts the background scheduler if it's not already running.
"""
if not scheduler.running: if not scheduler.running:
scheduler.start() scheduler.start()
logger.info("Scheduler started.") logger.info("Scheduler started.")
update_scheduler_job() 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(): 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() scheduler.remove_all_jobs()
# Reload config to get latest schedule settings # Reload config to get latest schedule settings
server_config.load() server_config.load()
logger.info("Configuration reloaded for scheduler update.")
# Handle Auto Watch # Handle Auto Watch
if server_config.schedule_auto_watch: if server_config.schedule_auto_watch:
# Ensure we have an absolute path # Ensure we have an absolute path
local_path = os.path.abspath(server_config.local_path) local_path = os.path.abspath(server_config.local_path)
watcher_manager.start(local_path) watcher_manager.start(local_path)
logger.info(f"Auto-watch started for path: {local_path}")
else: else:
watcher_manager.stop() watcher_manager.stop()
logger.info("Auto-watch stopped.")
mode = server_config.schedule_mode mode = server_config.schedule_mode
logger.info(f"Updating scheduler with mode: {mode}")
if mode == "DISABLED": if mode == "DISABLED":
logger.info("Schedule is disabled.") logger.info("Schedule is disabled. No jobs added.")
return return
trigger = None trigger: Optional[BaseTrigger] = None
if mode == "CRON": if mode == "CRON":
cron_exp = server_config.schedule_cron trigger = _create_cron_trigger(server_config.schedule_cron)
if cron_exp:
try:
# 5 parts: minute hour day month day_of_week
parts = cron_exp.split()
if len(parts) == 5:
trigger = CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4]
)
except Exception as e:
logger.error(f"Invalid cron expression: {cron_exp}, error: {e}")
elif mode == "DAILY": elif mode == "DAILY":
time_str = server_config.schedule_daily_time trigger = _create_daily_trigger(server_config.schedule_daily_time)
try:
hour, minute = map(int, time_str.split(':'))
trigger = CronTrigger(hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid daily time: {time_str}")
elif mode == "WEEKLY": elif mode == "WEEKLY":
days = server_config.schedule_weekly_days # list of ints 0-6 (Sun-Sat) trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time)
time_str = server_config.schedule_weekly_time
# Frontend: 0(Sun), 1(Mon)... 6(Sat)
# APScheduler: 0(Mon)... 6(Sun)
aps_days = []
for d in days:
if d == 0: aps_days.append(6)
else: aps_days.append(d - 1)
days_str = ",".join(map(str, aps_days))
try:
hour, minute = map(int, time_str.split(':'))
trigger = CronTrigger(day_of_week=days_str, hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid weekly time: {time_str}")
if trigger: if trigger:
scheduler.add_job(job_function, trigger) scheduler.add_job(job_function, trigger)
logger.info(f"Added scheduled job with mode {mode} and trigger {trigger}") logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
else: else:
logger.warning(f"Failed to create trigger for mode {mode}") logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.")
def get_next_run_time(): def get_next_run_time():
"""
Returns the next run time of the scheduled job, if any.
"""
jobs = scheduler.get_jobs() jobs = scheduler.get_jobs()
if not jobs: if not jobs:
return None return None
# Assuming only one job # Assuming only one job is scheduled for sync
job = jobs[0] job = jobs[0]
return job.next_run_time return job.next_run_time
+49 -25
View File
@@ -1,19 +1,23 @@
import os import os
import threading import threading
import asyncio from typing import Optional
from watchdog.observers.polling import PollingObserver as Observer from watchdog.observers.polling import PollingObserver as Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler, FileSystemEvent
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.config import server_config
from app.utils.sync_manager import sync_manager from app.utils.sync_manager import sync_manager
class PlaylistEventHandler(FileSystemEventHandler): class PlaylistEventHandler(FileSystemEventHandler):
"""
Handles file system events for the playlist directory.
Triggers a sync operation when changes are detected, with debouncing.
"""
def __init__(self): def __init__(self):
self.debounce_timer = None self.debounce_timer: Optional[threading.Timer] = None
self.debounce_interval = 5.0 # Seconds
def on_any_event(self, event): def on_any_event(self, event: FileSystemEvent):
# Log all events for debugging (using INFO temporarily to ensure visibility) # Log all events at DEBUG level to avoid cluttering INFO logs
logger.info(f"[WATCHER-DEBUG] Event detected: {event.event_type} {event.src_path}") logger.debug(f"[Watcher] Event detected: {event.event_type} {event.src_path}")
if event.is_directory: if event.is_directory:
return return
@@ -23,55 +27,70 @@ class PlaylistEventHandler(FileSystemEventHandler):
if event.event_type not in ['created', 'modified', 'deleted', 'moved']: if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return return
# Ignore temporary files or hidden files if necessary # Ignore temporary files or hidden files
filename = os.path.basename(event.src_path) filename = os.path.basename(event.src_path)
if filename.startswith('.'): if filename.startswith('.'):
return return
# Prevent feedback loops: if sync is in progress, ignore events (likely caused by the sync itself) # Prevent feedback loops: if sync is in progress, ignore events
if sync_manager.is_syncing: if sync_manager.is_syncing:
logger.info(f"[WATCHER-DEBUG] Ignoring event {event.event_type} on {event.src_path} because sync is in progress.") logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event.src_path} because sync is in progress.")
return return
logger.info(f"File system event detected and accepted: {event.event_type} {event.src_path}") logger.info(f"[Watcher] Accepted file change: {event.event_type} {event.src_path}")
self.trigger_sync() self.trigger_sync()
def trigger_sync(self): def trigger_sync(self):
"""
Triggers the sync process after a debounce interval.
"""
if self.debounce_timer: if self.debounce_timer:
self.debounce_timer.cancel() self.debounce_timer.cancel()
# Debounce for 5 seconds to allow multiple file operations to complete logger.debug(f"[Watcher] Debouncing sync for {self.debounce_interval} seconds...")
self.debounce_timer = threading.Timer(5.0, self.run_sync) self.debounce_timer = threading.Timer(self.debounce_interval, self.run_sync)
self.debounce_timer.start() self.debounce_timer.start()
def run_sync(self): def run_sync(self):
logger.info("Triggering sync due to file change...") """
sync_manager.run_sync(trigger_source="watcher", wait=False) 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: class WatcherManager:
"""
Manages the lifecycle of the file watcher.
"""
def __init__(self): def __init__(self):
self.observer = None self.observer: Optional[Observer] = None
self.handler = None self.handler: Optional[PlaylistEventHandler] = None
self.current_path = None self.current_path: Optional[str] = None
def start(self, path): def start(self, path: str):
"""
Starts watching the specified directory.
"""
# If already watching the same path, do nothing # If already watching the same path, do nothing
if self.observer and self.observer.is_alive() and self.current_path == path: if self.observer and self.observer.is_alive() and self.current_path == path:
logger.info(f"Watcher already running on {path}") logger.info(f"[Watcher] Already running on {path}")
return return
self.stop() self.stop()
if not os.path.exists(path): if not os.path.exists(path):
logger.warning(f"Cannot watch path {path}: Directory does not exist.") logger.warning(f"[Watcher] Cannot watch path {path}: Directory does not exist.")
return return
logger.info(f"Starting file watcher on: {path}") logger.info(f"[Watcher] Starting file watcher on: {path}")
try: try:
files = os.listdir(path) files = os.listdir(path)
logger.info(f"Files currently in watch directory: {files}") logger.debug(f"[Watcher] Initial files in watch directory: {files}")
except Exception as e: except Exception as e:
logger.error(f"Failed to list files in watch directory: {e}") logger.error(f"[Watcher] Failed to list files in watch directory: {e}")
self.handler = PlaylistEventHandler() self.handler = PlaylistEventHandler()
# Explicitly set timeout for PollingObserver # Explicitly set timeout for PollingObserver
@@ -79,13 +98,18 @@ class WatcherManager:
self.observer.schedule(self.handler, path, recursive=True) self.observer.schedule(self.handler, path, recursive=True)
self.observer.start() self.observer.start()
self.current_path = path self.current_path = path
logger.info("[Watcher] Watcher started successfully.")
def stop(self): def stop(self):
"""
Stops the file watcher.
"""
if self.observer: if self.observer:
logger.info("Stopping file watcher...") logger.info("[Watcher] Stopping file watcher...")
self.observer.stop() self.observer.stop()
self.observer.join() self.observer.join()
self.observer = None self.observer = None
self.current_path = None self.current_path = None
logger.info("[Watcher] Watcher stopped.")
watcher_manager = WatcherManager() watcher_manager = WatcherManager()
+34 -11
View File
@@ -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, Clock } from 'lucide-react'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react';
interface Toast { interface Toast {
id: number; id: number;
@@ -459,8 +459,10 @@ const App: React.FC = () => {
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();
}; };
@@ -485,8 +487,17 @@ const App: React.FC = () => {
const isConnected = cloudServerInfo?.isConnected; const isConnected = cloudServerInfo?.isConnected;
const getScheduleDisplayInfo = () => { const getScheduleDisplayInfo = () => {
const result = {
label: 'Schedule',
value: 'Not configured',
active: false,
autoWatch: scheduleSettings.autoWatch
};
if (scheduleSettings.mode === ScheduleMode.DISABLED) { if (scheduleSettings.mode === ScheduleMode.DISABLED) {
return { label: 'Auto-Sync', value: 'Disabled', active: false }; result.label = 'Auto-Sync';
result.value = 'Disabled';
return result;
} }
let label = 'Schedule'; let label = 'Schedule';
@@ -494,11 +505,10 @@ const App: React.FC = () => {
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule'; else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule';
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule'; else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule';
return { result.label = label;
label, result.value = nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...';
value: nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...', result.active = true;
active: true return result;
};
}; };
const scheduleInfo = getScheduleDisplayInfo(); const scheduleInfo = getScheduleDisplayInfo();
@@ -581,9 +591,22 @@ const App: React.FC = () => {
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider"> <span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{scheduleInfo.label} {scheduleInfo.label}
</span> </span>
<div className={`text-xs font-mono flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}> <div className="text-xs font-mono flex items-center gap-1.5">
{scheduleInfo.active && <Clock size={12} />} {/* Schedule Part */}
<span>{scheduleInfo.value}</span> <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>
</div> </div>
+42 -10
View File
@@ -17,7 +17,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, Clock } from 'lucide-react'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react';
interface Toast { interface Toast {
id: number; id: number;
@@ -383,12 +383,24 @@ const App: React.FC = () => {
// Helper: Calculate Next Run Info // Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => { const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = {
label: 'Schedule',
value: 'Not configured',
active: false,
autoWatch: settings.autoWatch
};
if (settings.mode === ScheduleMode.DISABLED) { if (settings.mode === ScheduleMode.DISABLED) {
return { label: 'Auto-Sync', value: 'Disabled', active: false }; result.label = 'Auto-Sync';
result.value = 'Disabled';
return result;
} }
if (settings.mode === ScheduleMode.CRON) { if (settings.mode === ScheduleMode.CRON) {
return { label: 'Cron Schedule', value: settings.cronExpression || 'Pending...', active: true }; result.label = 'Cron Schedule';
result.value = settings.cronExpression || 'Pending...';
result.active = true;
return result;
} }
const now = new Date(); const now = new Date();
@@ -415,7 +427,11 @@ const App: React.FC = () => {
const [h, m] = settings.weeklyTime.split(':').map(Number); const [h, m] = settings.weeklyTime.split(':').map(Number);
const activeDays = [...settings.weeklyDays].sort(); const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) return { label: 'Weekly Schedule', value: 'No days selected', active: false }; if (activeDays.length === 0) {
result.label = 'Weekly Schedule';
result.value = 'No days selected';
return result;
}
// Check rest of today // Check rest of today
if (activeDays.includes(now.getDay())) { if (activeDays.includes(now.getDay())) {
@@ -451,10 +467,13 @@ const App: React.FC = () => {
else if (isTomorrow) dateStr = 'Tomorrow'; else if (isTomorrow) dateStr = 'Tomorrow';
else dateStr = days[nextRun.getDay()]; else dateStr = days[nextRun.getDay()];
return { label: `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`, value: `${dateStr} at ${timeStr}`, active: true }; result.label = `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`;
result.value = `${dateStr} at ${timeStr}`;
result.active = true;
return result;
} }
return { label: 'Schedule', value: 'Not configured', active: false }; return result;
}; };
const scheduleInfo = getScheduleDisplayInfo(scheduleSettings); const scheduleInfo = getScheduleDisplayInfo(scheduleSettings);
@@ -541,9 +560,22 @@ const App: React.FC = () => {
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider"> <span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{scheduleInfo.label} {scheduleInfo.label}
</span> </span>
<div className={`text-xs font-mono flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}> <div className="text-xs font-mono flex items-center gap-1.5">
{scheduleInfo.active && <Clock size={12} />} {/* Schedule Part */}
<span>{scheduleInfo.value}</span> <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>
</div> </div>
@@ -669,4 +701,4 @@ const App: React.FC = () => {
); );
}; };
export default App; export default App;