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
)
update_scheduler_job()
logger.info(f"Schedule settings updated via API. Mode: {settings.mode}")
return {"status": "success", "message": "Schedule updated"}
+87 -44
View File
@@ -1,14 +1,21 @@
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:
@@ -26,92 +33,128 @@ def validate_cron_expression(expression: str) -> bool:
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 update_scheduler_job():
scheduler.remove_all_jobs()
# Reload config to get latest schedule settings
server_config.load()
# 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)
else:
watcher_manager.stop()
mode = server_config.schedule_mode
if mode == "DISABLED":
logger.info("Schedule is disabled.")
return
trigger = None
if mode == "CRON":
cron_exp = server_config.schedule_cron
if cron_exp:
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:
trigger = CronTrigger(
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
elif mode == "DAILY":
time_str = server_config.schedule_daily_time
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(':'))
trigger = CronTrigger(hour=hour, minute=minute)
return CronTrigger(hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid daily time: {time_str}")
elif mode == "WEEKLY":
days = server_config.schedule_weekly_days # list of ints 0-6 (Sun-Sat)
time_str = server_config.schedule_weekly_time
# Frontend: 0(Sun), 1(Mon)... 6(Sat)
# APScheduler: 0(Mon)... 6(Sun)
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)
else: aps_days.append(d - 1)
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(':'))
trigger = CronTrigger(day_of_week=days_str, hour=hour, minute=minute)
return CronTrigger(day_of_week=days_str, hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid weekly time: {time_str}")
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}")
logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
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():
"""
Returns the next run time of the scheduled job, if any.
"""
jobs = scheduler.get_jobs()
if not jobs:
return None
# Assuming only one job
# Assuming only one job is scheduled for sync
job = jobs[0]
return job.next_run_time
+48 -24
View File
@@ -1,19 +1,23 @@
import os
import threading
import asyncio
from typing import Optional
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.config import server_config
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 = None
self.debounce_timer: Optional[threading.Timer] = None
self.debounce_interval = 5.0 # Seconds
def on_any_event(self, event):
# Log all events for debugging (using INFO temporarily to ensure visibility)
logger.info(f"[WATCHER-DEBUG] Event detected: {event.event_type} {event.src_path}")
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
@@ -23,55 +27,70 @@ class PlaylistEventHandler(FileSystemEventHandler):
if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return
# Ignore temporary files or hidden files if necessary
# 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 (likely caused by the sync itself)
# Prevent feedback loops: if sync is in progress, ignore events
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
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()
def trigger_sync(self):
"""
Triggers the sync process after a debounce interval.
"""
if self.debounce_timer:
self.debounce_timer.cancel()
# Debounce for 5 seconds to allow multiple file operations to complete
self.debounce_timer = threading.Timer(5.0, self.run_sync)
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):
logger.info("Triggering sync due to file change...")
"""
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 = None
self.handler = None
self.current_path = None
self.observer: Optional[Observer] = None
self.handler: Optional[PlaylistEventHandler] = 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 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
self.stop()
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
logger.info(f"Starting file watcher on: {path}")
logger.info(f"[Watcher] Starting file watcher on: {path}")
try:
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:
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()
# Explicitly set timeout for PollingObserver
@@ -79,13 +98,18 @@ class WatcherManager:
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("Stopping file watcher...")
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()
+32 -9
View File
@@ -18,7 +18,7 @@ import {
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
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 {
id: number;
@@ -459,8 +459,10 @@ const App: React.FC = () => {
setCloudServerInfo(serverInfo);
if (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
refreshCloud();
};
@@ -485,8 +487,17 @@ const App: React.FC = () => {
const isConnected = cloudServerInfo?.isConnected;
const getScheduleDisplayInfo = () => {
const result = {
label: 'Schedule',
value: 'Not configured',
active: false,
autoWatch: scheduleSettings.autoWatch
};
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';
@@ -494,11 +505,10 @@ const App: React.FC = () => {
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule';
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule';
return {
label,
value: nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...',
active: true
};
result.label = label;
result.value = nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...';
result.active = true;
return result;
};
const scheduleInfo = getScheduleDisplayInfo();
@@ -581,10 +591,23 @@ const App: React.FC = () => {
<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 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
<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 */}
+39 -7
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 StrategySelector from './components/StrategySelector';
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 {
id: number;
@@ -383,12 +383,24 @@ const App: React.FC = () => {
// 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) {
return { label: 'Auto-Sync', value: 'Disabled', active: false };
result.label = 'Auto-Sync';
result.value = 'Disabled';
return result;
}
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();
@@ -415,7 +427,11 @@ const App: React.FC = () => {
const [h, m] = settings.weeklyTime.split(':').map(Number);
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
if (activeDays.includes(now.getDay())) {
@@ -451,10 +467,13 @@ const App: React.FC = () => {
else if (isTomorrow) dateStr = 'Tomorrow';
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);
@@ -541,10 +560,23 @@ const App: React.FC = () => {
<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 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
<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 */}