2 Commits

Author SHA1 Message Date
Koha9 96c853125c Fix watcher sync loop when auto-watch is enabled 2026-01-15 16:26:52 +09:00
Koha9 c00d6100c2 Merge branch 'feat/Identification_features' 2026-01-15 15:10:25 +09:00
3 changed files with 72 additions and 7 deletions
+22 -4
View File
@@ -78,10 +78,28 @@ def write_local_playlist(playlist_path: str, tracks: List[str]) -> bool:
bool: True if successful, False otherwise. bool: True if successful, False otherwise.
""" """
try: try:
with open(playlist_path, 'w', encoding="utf-8") as file: desired_lines = ["#EXTM3U\n"] + [f"{track}\n" for track in tracks]
file.write("#EXTM3U\n") desired_content = "".join(desired_lines)
for track in tracks:
file.write(f"{track}\n") # Avoid rewriting identical content to prevent watcher feedback loops.
if os.path.exists(playlist_path):
try:
with open(playlist_path, 'r', encoding="utf-8") as existing_file:
existing_content = existing_file.read()
if existing_content == desired_content:
logger.debug(f"Playlist unchanged; skipping write: {playlist_path}")
return True
except Exception as e:
# If read fails, fall back to rewriting.
logger.debug(f"Failed to read existing playlist for comparison ({playlist_path}): {e}")
# Write via temp file then replace for safer updates.
os.makedirs(os.path.dirname(os.path.abspath(playlist_path)), exist_ok=True)
tmp_path = f"{playlist_path}.tmp"
with open(tmp_path, 'w', encoding="utf-8") as file:
file.write(desired_content)
os.replace(tmp_path, playlist_path)
logger.info(f"Written {len(tracks)} songs to the playlist: {playlist_path}") logger.info(f"Written {len(tracks)} songs to the playlist: {playlist_path}")
return True return True
except Exception as e: except Exception as e:
+33
View File
@@ -4,6 +4,7 @@ import json
import os import os
import hashlib import hashlib
import re import re
import time
from datetime import datetime from datetime import datetime
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.playlist_merge import sync_all_playlists, SyncMode
@@ -21,6 +22,23 @@ class SyncManager:
self._last_error = None self._last_error = None
self._listeners = [] # List of asyncio.Queue self._listeners = [] # List of asyncio.Queue
self._loop = None self._loop = None
# Suppress watcher events briefly after we write/delete local playlists.
# This prevents feedback loops where a sync triggers another sync.
self._watcher_suppress_until = 0.0
# Tunable defaults (seconds). Keep short to avoid missing real user edits.
self._watcher_suppress_after_write_seconds = 2.5
self._watcher_suppress_after_sync_seconds = 2.5
def suppress_watcher_events(self, seconds: float):
now = time.monotonic()
with self._lock:
self._watcher_suppress_until = max(self._watcher_suppress_until, now + float(seconds))
@property
def is_watcher_suppressed(self) -> bool:
with self._lock:
return time.monotonic() < self._watcher_suppress_until
def set_event_loop(self, loop): def set_event_loop(self, loop):
self._loop = loop self._loop = loop
@@ -78,6 +96,11 @@ class SyncManager:
self._is_syncing = True self._is_syncing = True
self._last_status = "syncing" self._last_status = "syncing"
self._last_error = None self._last_error = None
# Preemptively suppress watcher in case the poller notices changes right after.
self._watcher_suppress_until = max(
self._watcher_suppress_until,
time.monotonic() + self._watcher_suppress_after_sync_seconds,
)
self._notify_listeners() self._notify_listeners()
logger.info(f"Starting sync (Source: {trigger_source})...") logger.info(f"Starting sync (Source: {trigger_source})...")
@@ -144,6 +167,7 @@ class SyncManager:
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8") dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
# Ensure directory exists # Ensure directory exists
os.makedirs(os.path.dirname(dest_path), exist_ok=True) os.makedirs(os.path.dirname(dest_path), exist_ok=True)
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
write_local_playlist(dest_path, tracks) write_local_playlist(dest_path, tracks)
# 2. Write Remote (Plex) # 2. Write Remote (Plex)
@@ -159,9 +183,11 @@ class SyncManager:
elif action == "deleted": elif action == "deleted":
# Delete Local # Delete Local
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8") dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
delete_local_playlist(dest_path) delete_local_playlist(dest_path)
# Also check for .m3u # Also check for .m3u
dest_path_m3u = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u") dest_path_m3u = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u")
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
delete_local_playlist(dest_path_m3u) delete_local_playlist(dest_path_m3u)
# Delete Remote # Delete Remote
@@ -218,7 +244,14 @@ class SyncManager:
return candidate return candidate
def _complete_sync(self, status, error=None): def _complete_sync(self, status, error=None):
now = time.monotonic()
with self._lock: with self._lock:
# Keep watcher suppression a bit after sync finishes, since PollingObserver
# may detect file changes on the next polling tick.
self._watcher_suppress_until = max(
self._watcher_suppress_until,
now + self._watcher_suppress_after_sync_seconds,
)
self._last_status = status self._last_status = status
self._last_error = error self._last_error = error
self._last_sync_time = datetime.now() self._last_sync_time = datetime.now()
+17 -3
View File
@@ -22,22 +22,36 @@ class PlaylistEventHandler(FileSystemEventHandler):
if event.is_directory: if event.is_directory:
return return
# For moved events, the interesting path is the destination.
event_path = getattr(event, "dest_path", None) if event.event_type == "moved" else event.src_path
if not event_path:
return
# Filter out noisy events. Only listen to actual changes. # Filter out noisy events. Only listen to actual changes.
# 'opened' and 'closed' (without write) are read events and should be ignored. # 'opened' and 'closed' (without write) are read events and should be ignored.
if event.event_type not in ['created', 'modified', 'deleted', 'moved']: if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return return
# Ignore temporary files or hidden files # Ignore temporary files or hidden files
filename = os.path.basename(event.src_path) filename = os.path.basename(event_path)
if filename.startswith('.'): if filename.startswith('.'):
return return
# Only watch playlist files to avoid noisy triggers.
if not filename.lower().endswith((".m3u", ".m3u8")):
return
# Prevent feedback loops: if sync is in progress, ignore events # Prevent feedback loops: if sync is in progress, ignore events
if sync_manager.is_syncing: if sync_manager.is_syncing:
logger.debug(f"[Watcher] 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_path} because sync is in progress.")
return return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event.src_path}") # Prevent feedback loops: ignore events right after sync writes/deletes.
if getattr(sync_manager, "is_watcher_suppressed", False):
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event_path} because watcher is suppressed.")
return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event_path}")
self.trigger_sync() self.trigger_sync()
def trigger_sync(self): def trigger_sync(self):