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()