feat: Implement sync manager and file watcher for automated playlist synchronization

This commit is contained in:
2025-11-29 12:26:59 +09:00
parent 22697fdc1d
commit fe4061d1a1
8 changed files with 413 additions and 24 deletions
+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:
_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
View File
@@ -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":
+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()
+91
View File
@@ -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()