feat: Implement sync manager and file watcher for automated playlist synchronization
This commit is contained in:
@@ -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:
|
||||
_ensure_test_dir(folder)
|
||||
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:
|
||||
file.write(save_paths(paths))
|
||||
file.write(new_content)
|
||||
return file_path
|
||||
|
||||
|
||||
|
||||
+13
-19
@@ -2,8 +2,9 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.utils.config import server_config
|
||||
from app.utils.logger import logger
|
||||
from app.utils.playlist_merge import sync_all_playlists, SyncMode
|
||||
import asyncio
|
||||
from app.utils.watcher import watcher_manager
|
||||
from app.utils.sync_manager import sync_manager
|
||||
import os
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
@@ -26,23 +27,7 @@ def validate_cron_expression(expression: str) -> bool:
|
||||
|
||||
def job_function():
|
||||
logger.info("Executing scheduled sync job...")
|
||||
try:
|
||||
# Re-read config to ensure latest values
|
||||
server_config.load()
|
||||
|
||||
mode_str = server_config.sync_mode
|
||||
regex_rules = server_config.path_rules
|
||||
local_path = server_config.local_path
|
||||
|
||||
asyncio.run(sync_all_playlists(
|
||||
sync_mode=SyncMode(mode_str),
|
||||
path_rules=regex_rules,
|
||||
local_dir=local_path
|
||||
))
|
||||
|
||||
logger.info("Scheduled sync job completed.")
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled sync job failed: {e}")
|
||||
sync_manager.run_sync(trigger_source="scheduler", wait=False)
|
||||
|
||||
def start_scheduler():
|
||||
if not scheduler.running:
|
||||
@@ -55,6 +40,15 @@ def update_scheduler_job():
|
||||
|
||||
# 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":
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
import threading
|
||||
import asyncio
|
||||
from watchdog.observers.polling import PollingObserver as Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from app.utils.logger import logger
|
||||
from app.utils.config import server_config
|
||||
from app.utils.sync_manager import sync_manager
|
||||
|
||||
class PlaylistEventHandler(FileSystemEventHandler):
|
||||
def __init__(self):
|
||||
self.debounce_timer = None
|
||||
|
||||
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}")
|
||||
|
||||
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 if necessary
|
||||
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)
|
||||
if sync_manager.is_syncing:
|
||||
logger.info(f"[WATCHER-DEBUG] 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}")
|
||||
self.trigger_sync()
|
||||
|
||||
def trigger_sync(self):
|
||||
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)
|
||||
self.debounce_timer.start()
|
||||
|
||||
def run_sync(self):
|
||||
logger.info("Triggering sync due to file change...")
|
||||
sync_manager.run_sync(trigger_source="watcher", wait=False)
|
||||
|
||||
class WatcherManager:
|
||||
def __init__(self):
|
||||
self.observer = None
|
||||
self.handler = None
|
||||
self.current_path = None
|
||||
|
||||
def start(self, path):
|
||||
# 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"Cannot watch path {path}: Directory does not exist.")
|
||||
return
|
||||
|
||||
logger.info(f"Starting file watcher on: {path}")
|
||||
try:
|
||||
files = os.listdir(path)
|
||||
logger.info(f"Files currently in watch directory: {files}")
|
||||
except Exception as e:
|
||||
logger.error(f"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
|
||||
|
||||
def stop(self):
|
||||
if self.observer:
|
||||
logger.info("Stopping file watcher...")
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
self.observer = None
|
||||
self.current_path = None
|
||||
|
||||
watcher_manager = WatcherManager()
|
||||
Reference in New Issue
Block a user