feat: Implement sync manager and file watcher for automated playlist synchronization
This commit is contained in:
+87
-2
@@ -4,7 +4,8 @@ from typing import Sequence
|
|||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
import asyncio
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -15,11 +16,13 @@ from app.utils.logger import logger
|
|||||||
from app.utils.playlist_merge import SyncMode, TEST_PLAYLIST_DIR, sync_all_playlists
|
from app.utils.playlist_merge import SyncMode, TEST_PLAYLIST_DIR, sync_all_playlists
|
||||||
from app.utils.plex_client import plex_client
|
from app.utils.plex_client import plex_client
|
||||||
from app.utils.scheduler import start_scheduler, update_scheduler_job, get_next_run_time, validate_cron_expression
|
from app.utils.scheduler import start_scheduler, update_scheduler_job, get_next_run_time, validate_cron_expression
|
||||||
|
from app.utils.sync_manager import sync_manager
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
|
sync_manager.set_event_loop(asyncio.get_running_loop())
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -450,6 +453,75 @@ async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"),
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sync/status")
|
||||||
|
async def get_sync_status():
|
||||||
|
return sync_manager.status
|
||||||
|
|
||||||
|
@app.get("/api/sync/events")
|
||||||
|
async def sync_events(request: Request):
|
||||||
|
async def event_generator():
|
||||||
|
q = await sync_manager.subscribe()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
data = await q.get()
|
||||||
|
yield f"data: {data}\n\n"
|
||||||
|
finally:
|
||||||
|
sync_manager.unsubscribe(q)
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
@app.post("/api/sync")
|
||||||
|
async def api_sync(payload: SyncRequest):
|
||||||
|
server_config.load()
|
||||||
|
try:
|
||||||
|
sync_mode = payload.mode or SyncMode(server_config.sync_mode)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
# Update config temporarily for this sync if needed, but sync_manager reads from config.
|
||||||
|
# If payload overrides config, we might need to handle that.
|
||||||
|
# However, sync_manager._perform_sync reads from server_config.
|
||||||
|
# If we want to support one-off sync with custom params via sync_manager, we need to update sync_manager.
|
||||||
|
|
||||||
|
# For now, let's assume payload params should be saved or used.
|
||||||
|
# But sync_manager is designed to run background tasks too.
|
||||||
|
|
||||||
|
# If we want to keep the existing behavior of api_sync (blocking and returning stats),
|
||||||
|
# we can use sync_manager.run_sync(wait=True).
|
||||||
|
# But we need to make sure sync_manager uses the params from payload if provided.
|
||||||
|
|
||||||
|
# Since sync_manager reads from server_config, let's update server_config if payload has values.
|
||||||
|
# Or better, pass params to sync_manager.run_sync?
|
||||||
|
# sync_manager._perform_sync currently hardcodes reading from server_config.
|
||||||
|
|
||||||
|
# Let's stick to the requirement: "watchdog当发现更改时,执行同步,同步时UI页面也会显示正在同步状态。"
|
||||||
|
# This implies we need a shared state.
|
||||||
|
|
||||||
|
# If I change api_sync to use sync_manager, I need to ensure it supports the custom params.
|
||||||
|
# But payload.local_path and payload.mode are optional.
|
||||||
|
|
||||||
|
# Let's modify sync_manager to accept overrides.
|
||||||
|
# But wait, sync_manager is a singleton.
|
||||||
|
|
||||||
|
# For this task, I will just wrap the existing logic in sync_manager.run_sync(wait=True)
|
||||||
|
# AND I will modify sync_manager to allow passing explicit args to _perform_sync.
|
||||||
|
|
||||||
|
# But first, let's update api_sync to use sync_manager.run_sync(wait=True)
|
||||||
|
# AND we need to handle the parameter passing.
|
||||||
|
|
||||||
|
# Actually, looking at sync_manager implementation I just wrote:
|
||||||
|
# def _perform_sync(self):
|
||||||
|
# server_config.load()
|
||||||
|
# return sync_all_playlists(local_dir=server_config.local_path, mode=SyncMode(server_config.sync_mode))
|
||||||
|
|
||||||
|
# It ignores arguments. This is a limitation.
|
||||||
|
# I should update SyncManager to accept kwargs for sync_all_playlists.
|
||||||
|
|
||||||
|
# Let's update SyncManager first.
|
||||||
|
pass
|
||||||
|
|
||||||
@app.post("/api/sync")
|
@app.post("/api/sync")
|
||||||
async def api_sync(payload: SyncRequest):
|
async def api_sync(payload: SyncRequest):
|
||||||
server_config.load()
|
server_config.load()
|
||||||
@@ -459,7 +531,20 @@ async def api_sync(payload: SyncRequest):
|
|||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
local_dir = payload.local_path or server_config.local_path
|
local_dir = payload.local_path or server_config.local_path
|
||||||
results = sync_all_playlists(local_dir=local_dir, mode=sync_mode, test_folder=TEST_PLAYLIST_DIR)
|
|
||||||
|
# Use sync_manager to execute sync, ensuring state is updated
|
||||||
|
try:
|
||||||
|
results = sync_manager.run_sync(
|
||||||
|
trigger_source="api",
|
||||||
|
wait=True,
|
||||||
|
# We need to pass these to _perform_sync
|
||||||
|
sync_kwargs={"local_dir": local_dir, "mode": sync_mode}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if str(e) == "Sync already in progress":
|
||||||
|
raise HTTPException(status_code=409, detail="Sync already in progress")
|
||||||
|
raise e
|
||||||
|
|
||||||
merged_count = sum(len(item.merged_paths) for item in results)
|
merged_count = sum(len(item.merged_paths) for item in results)
|
||||||
conflict_count = sum(len(item.conflicts) for item in results)
|
conflict_count = sum(len(item.conflicts) for item in results)
|
||||||
deleted_count = sum(1 for item in results if item.action == "deleted")
|
deleted_count = sum(1 for item in results if item.action == "deleted")
|
||||||
|
|||||||
@@ -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:
|
def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str:
|
||||||
_ensure_test_dir(folder)
|
_ensure_test_dir(folder)
|
||||||
file_path = os.path.join(folder, filename)
|
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:
|
with open(file_path, "w", encoding="utf-8") as file:
|
||||||
file.write(save_paths(paths))
|
file.write(new_content)
|
||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-19
@@ -2,8 +2,9 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
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.playlist_merge import sync_all_playlists, SyncMode
|
from app.utils.watcher import watcher_manager
|
||||||
import asyncio
|
from app.utils.sync_manager import sync_manager
|
||||||
|
import os
|
||||||
|
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
@@ -26,23 +27,7 @@ def validate_cron_expression(expression: str) -> bool:
|
|||||||
|
|
||||||
def job_function():
|
def job_function():
|
||||||
logger.info("Executing scheduled sync job...")
|
logger.info("Executing scheduled sync job...")
|
||||||
try:
|
sync_manager.run_sync(trigger_source="scheduler", wait=False)
|
||||||
# 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}")
|
|
||||||
|
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
if not scheduler.running:
|
if not scheduler.running:
|
||||||
@@ -55,6 +40,15 @@ def update_scheduler_job():
|
|||||||
|
|
||||||
# Reload config to get latest schedule settings
|
# Reload config to get latest schedule settings
|
||||||
server_config.load()
|
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
|
mode = server_config.schedule_mode
|
||||||
|
|
||||||
if mode == "DISABLED":
|
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()
|
||||||
+78
-2
@@ -125,6 +125,8 @@ const App: React.FC = () => {
|
|||||||
const [loadingCloud, setLoadingCloud] = useState(false);
|
const [loadingCloud, setLoadingCloud] = useState(false);
|
||||||
|
|
||||||
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
|
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
|
||||||
|
const manualSyncInProgress = useRef(false);
|
||||||
|
const lastKnownSyncTimeRef = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
// Animation Refs
|
// Animation Refs
|
||||||
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
|
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
|
||||||
@@ -165,7 +167,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addToast = (message: string) => {
|
const addToast = useCallback((message: string) => {
|
||||||
const id = Date.now();
|
const id = Date.now();
|
||||||
// Start with entering: true to position it above
|
// Start with entering: true to position it above
|
||||||
const newToast: Toast = { id, message, exiting: false, entering: true };
|
const newToast: Toast = { id, message, exiting: false, entering: true };
|
||||||
@@ -182,7 +184,7 @@ const App: React.FC = () => {
|
|||||||
}, TOAST_AUTO_DISMISS_MS);
|
}, TOAST_AUTO_DISMISS_MS);
|
||||||
|
|
||||||
timeoutsRef.current[id] = dismissTimer;
|
timeoutsRef.current[id] = dismissTimer;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Effect to trigger the "slide down" animation
|
// Effect to trigger the "slide down" animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -361,9 +363,12 @@ const App: React.FC = () => {
|
|||||||
if (syncState !== SyncState.IDLE) return;
|
if (syncState !== SyncState.IDLE) return;
|
||||||
|
|
||||||
setSyncState(SyncState.SYNCING);
|
setSyncState(SyncState.SYNCING);
|
||||||
|
manualSyncInProgress.current = true;
|
||||||
|
|
||||||
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined);
|
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined);
|
||||||
|
|
||||||
|
manualSyncInProgress.current = false;
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
setSyncState(SyncState.SUCCESS);
|
setSyncState(SyncState.SUCCESS);
|
||||||
|
|
||||||
@@ -379,6 +384,77 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SSE for sync status
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
const { is_syncing, status, error, last_sync_time } = data;
|
||||||
|
|
||||||
|
// Initialize lastKnownSyncTime if it's the first event
|
||||||
|
if (lastKnownSyncTimeRef.current === undefined) {
|
||||||
|
lastKnownSyncTimeRef.current = last_sync_time;
|
||||||
|
// If we are currently syncing on load, show it
|
||||||
|
if (is_syncing && !manualSyncInProgress.current) {
|
||||||
|
setSyncState(SyncState.SYNCING);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If manual sync is in progress, we ignore background updates to avoid state conflict
|
||||||
|
if (manualSyncInProgress.current) {
|
||||||
|
if (last_sync_time !== lastKnownSyncTimeRef.current) {
|
||||||
|
lastKnownSyncTimeRef.current = last_sync_time;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Syncing State
|
||||||
|
if (is_syncing) {
|
||||||
|
if (syncState !== SyncState.SYNCING) {
|
||||||
|
setSyncState(SyncState.SYNCING);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check for completion by comparing timestamps
|
||||||
|
if (last_sync_time !== lastKnownSyncTimeRef.current) {
|
||||||
|
lastKnownSyncTimeRef.current = last_sync_time;
|
||||||
|
|
||||||
|
// A sync has completed since our last check
|
||||||
|
if (status === 'success') {
|
||||||
|
setSyncState(SyncState.SUCCESS);
|
||||||
|
refreshLocal();
|
||||||
|
refreshCloud();
|
||||||
|
addToast("Background sync completed successfully.");
|
||||||
|
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
|
||||||
|
} else if (status === 'error') {
|
||||||
|
setSyncState(SyncState.ERROR);
|
||||||
|
addToast(`Background sync failed: ${error}`);
|
||||||
|
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edge case: We are in SYNCING state but backend says not syncing, and time hasn't changed.
|
||||||
|
if (syncState === SyncState.SYNCING) {
|
||||||
|
setSyncState(SyncState.IDLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse SSE event", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
console.error("EventSource failed:", err);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [syncState, refreshLocal, refreshCloud, addToast]);
|
||||||
|
|
||||||
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
|
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
|
||||||
setCloudServerInfo(serverInfo);
|
setCloudServerInfo(serverInfo);
|
||||||
if (serverInfo.libraryName) {
|
if (serverInfo.libraryName) {
|
||||||
|
|||||||
@@ -181,4 +181,9 @@ export const apiService = {
|
|||||||
});
|
});
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSyncStatus(): Promise<ApiResponse<{ is_syncing: boolean; last_sync_time: string | null; status: string; error: string | null }>> {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sync/status`);
|
||||||
|
return handleResponse(response);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ python-multipart
|
|||||||
plexapi
|
plexapi
|
||||||
merge3
|
merge3
|
||||||
apscheduler
|
apscheduler
|
||||||
|
watchdog
|
||||||
|
|||||||
Reference in New Issue
Block a user