Compare commits
3 Commits
86f18cc410
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9687f62b2 | |||
| 96c853125c | |||
| c00d6100c2 |
@@ -78,10 +78,28 @@ def write_local_playlist(playlist_path: str, tracks: List[str]) -> bool:
|
||||
bool: True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
with open(playlist_path, 'w', encoding="utf-8") as file:
|
||||
file.write("#EXTM3U\n")
|
||||
for track in tracks:
|
||||
file.write(f"{track}\n")
|
||||
desired_lines = ["#EXTM3U\n"] + [f"{track}\n" for track in tracks]
|
||||
desired_content = "".join(desired_lines)
|
||||
|
||||
# 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}")
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from app.utils.logger import logger
|
||||
from app.utils.playlist_merge import sync_all_playlists, SyncMode
|
||||
@@ -21,6 +22,23 @@ class SyncManager:
|
||||
self._last_error = None
|
||||
self._listeners = [] # List of asyncio.Queue
|
||||
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):
|
||||
self._loop = loop
|
||||
@@ -78,6 +96,11 @@ class SyncManager:
|
||||
self._is_syncing = True
|
||||
self._last_status = "syncing"
|
||||
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()
|
||||
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")
|
||||
# Ensure directory exists
|
||||
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)
|
||||
|
||||
# 2. Write Remote (Plex)
|
||||
@@ -159,9 +183,11 @@ class SyncManager:
|
||||
elif action == "deleted":
|
||||
# Delete Local
|
||||
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)
|
||||
# Also check for .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 Remote
|
||||
@@ -218,7 +244,14 @@ class SyncManager:
|
||||
return candidate
|
||||
|
||||
def _complete_sync(self, status, error=None):
|
||||
now = time.monotonic()
|
||||
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_error = error
|
||||
self._last_sync_time = datetime.now()
|
||||
|
||||
+17
-3
@@ -21,6 +21,11 @@ class PlaylistEventHandler(FileSystemEventHandler):
|
||||
|
||||
if event.is_directory:
|
||||
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.
|
||||
# 'opened' and 'closed' (without write) are read events and should be ignored.
|
||||
@@ -28,16 +33,25 @@ class PlaylistEventHandler(FileSystemEventHandler):
|
||||
return
|
||||
|
||||
# Ignore temporary files or hidden files
|
||||
filename = os.path.basename(event.src_path)
|
||||
filename = os.path.basename(event_path)
|
||||
if filename.startswith('.'):
|
||||
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
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
def trigger_sync(self):
|
||||
|
||||
Reference in New Issue
Block a user