Compare commits
5 Commits
a0631c6280
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9687f62b2 | |||
| 96c853125c | |||
| c00d6100c2 | |||
| 86f18cc410 | |||
| 254c391c89 |
@@ -62,6 +62,9 @@ local_settings.py
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# App runtime logs
|
||||||
|
app/logs/
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
.webassets-cache
|
.webassets-cache
|
||||||
@@ -181,3 +184,6 @@ cython_debug/
|
|||||||
data/*
|
data/*
|
||||||
playlists/*
|
playlists/*
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Local dev config may contain Plex token
|
||||||
|
app/config.json
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"theme": "auto",
|
|
||||||
"token": "",
|
|
||||||
"server_url": "",
|
|
||||||
"server_scheme": "http",
|
|
||||||
"server_port": "32400",
|
|
||||||
"timeout": 9,
|
|
||||||
"library_name": "",
|
|
||||||
"sync_mode": "local_force",
|
|
||||||
"path_rules": [],
|
|
||||||
"path_mapping": {
|
|
||||||
"mode": "SIMPLE",
|
|
||||||
"simple": [],
|
|
||||||
"regex": {
|
|
||||||
"local_pre": [],
|
|
||||||
"local_post": [],
|
|
||||||
"remote_pre": [],
|
|
||||||
"remote_post": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"schedule_mode": "DISABLED",
|
|
||||||
"schedule_cron": "",
|
|
||||||
"schedule_daily_time": "00:00",
|
|
||||||
"schedule_weekly_days": [0],
|
|
||||||
"schedule_weekly_time": "00:00",
|
|
||||||
"schedule_auto_watch": false,
|
|
||||||
"backup_enabled": false,
|
|
||||||
"backup_retention_count": 5
|
|
||||||
}
|
|
||||||
+26
-2
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
@@ -19,6 +21,28 @@ BACKUP_DIR = os.path.abspath(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_zip_entry_name(name: str, extension: str = ".m3u8") -> str:
|
||||||
|
"""Return a safe zip entry filename.
|
||||||
|
|
||||||
|
Prevents zip-slip style paths and avoids problematic characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
original = (name or "").strip()
|
||||||
|
base = os.path.basename(original)
|
||||||
|
base = re.sub(r"[\x00-\x1f\x7f]", "_", base)
|
||||||
|
invalid = set('<>:"/\\|?*')
|
||||||
|
cleaned = "".join(("_" if ch in invalid else ch) for ch in base).strip().strip(". ")
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = "playlist"
|
||||||
|
|
||||||
|
cleaned = cleaned[:160].rstrip().strip(". ")
|
||||||
|
if cleaned != original:
|
||||||
|
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
|
||||||
|
cleaned = f"{cleaned}__{digest}"
|
||||||
|
|
||||||
|
return f"{cleaned}{extension}"
|
||||||
|
|
||||||
|
|
||||||
def ensure_backup_dir():
|
def ensure_backup_dir():
|
||||||
"""Ensure the backup directory exists."""
|
"""Ensure the backup directory exists."""
|
||||||
if not os.path.exists(BACKUP_DIR):
|
if not os.path.exists(BACKUP_DIR):
|
||||||
@@ -118,7 +142,7 @@ def backup_local_playlists(local_path: str) -> str | None:
|
|||||||
|
|
||||||
# Get the playlist name without extension and add .m3u8 extension
|
# Get the playlist name without extension and add .m3u8 extension
|
||||||
playlist_name = os.path.splitext(entry.name)[0]
|
playlist_name = os.path.splitext(entry.name)[0]
|
||||||
archive_name = f"{playlist_name}.m3u8"
|
archive_name = _safe_zip_entry_name(playlist_name)
|
||||||
|
|
||||||
# Write to zip
|
# Write to zip
|
||||||
zipf.writestr(archive_name, content)
|
zipf.writestr(archive_name, content)
|
||||||
@@ -217,7 +241,7 @@ def backup_cloud_playlists(library_name: str) -> str | None:
|
|||||||
|
|
||||||
if len(lines) > 1: # More than just #EXTM3U
|
if len(lines) > 1: # More than just #EXTM3U
|
||||||
content = "\n".join(lines)
|
content = "\n".join(lines)
|
||||||
archive_name = f"{playlist.title}.m3u8"
|
archive_name = _safe_zip_entry_name(getattr(playlist, "title", "playlist"))
|
||||||
zipf.writestr(archive_name, content)
|
zipf.writestr(archive_name, content)
|
||||||
playlist_count += 1
|
playlist_count += 1
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -2,6 +2,25 @@ import json
|
|||||||
import os
|
import os
|
||||||
from app.utils.logger import logger
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_for_log(value: object) -> object:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
redacted = dict(value)
|
||||||
|
for key in ("token", "password"):
|
||||||
|
if key in redacted and redacted.get(key):
|
||||||
|
redacted[key] = "***"
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_server_config_dict(state: dict) -> dict:
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
return {}
|
||||||
|
redacted = dict(state)
|
||||||
|
if redacted.get("token"):
|
||||||
|
redacted["token"] = "***"
|
||||||
|
return redacted
|
||||||
|
|
||||||
DEFAULT_SYNC_MODE = "merge_local_primary"
|
DEFAULT_SYNC_MODE = "merge_local_primary"
|
||||||
LOCAL_PLAYLISTS_FOLDER = "playlists"
|
LOCAL_PLAYLISTS_FOLDER = "playlists"
|
||||||
DEFAULT_PATH_MAPPING = {
|
DEFAULT_PATH_MAPPING = {
|
||||||
@@ -62,7 +81,7 @@ class ServerConfig:
|
|||||||
try:
|
try:
|
||||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
logger.debug(f"Loaded server config: {config}")
|
logger.debug(f"Loaded server config: {_redact_for_log(config)}")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# 如果配置文件不存在,使用默认值
|
# 如果配置文件不存在,使用默认值
|
||||||
self.save()
|
self.save()
|
||||||
@@ -110,7 +129,7 @@ class ServerConfig:
|
|||||||
self.backup_enabled = config.get("backup_enabled", False)
|
self.backup_enabled = config.get("backup_enabled", False)
|
||||||
self.backup_retention_count = config.get("backup_retention_count", 5)
|
self.backup_retention_count = config.get("backup_retention_count", 5)
|
||||||
logger.info(f"Server config loaded.")
|
logger.info(f"Server config loaded.")
|
||||||
logger.debug(f"Current server config: {self.__dict__}")
|
logger.debug(f"Current server config: {_redact_server_config_dict(self.__dict__)}")
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
_ensure_parent_dir(CONFIG_PATH)
|
_ensure_parent_dir(CONFIG_PATH)
|
||||||
@@ -137,7 +156,7 @@ class ServerConfig:
|
|||||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||||
json.dump(config, f, indent=4, ensure_ascii=False)
|
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||||
logger.info(f"Server config saved.")
|
logger.info(f"Server config saved.")
|
||||||
logger.debug(f"Saved server config: {config}")
|
logger.debug(f"Saved server config: {_redact_for_log(config)}")
|
||||||
|
|
||||||
def set_url(self, url: str) -> None:
|
def set_url(self, url: str) -> None:
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -79,9 +79,7 @@ class PlexClient:
|
|||||||
# Update the base URL and connection status
|
# Update the base URL and connection status
|
||||||
self.base_url = build_plex_url(scheme, url, port)
|
self.base_url = build_plex_url(scheme, url, port)
|
||||||
self.connected = True
|
self.connected = True
|
||||||
logger.info(
|
logger.info(f"Connected to Plex server at {self.base_url}.")
|
||||||
f"Connected to Plex server at {self.base_url} with token: {self.token}"
|
|
||||||
)
|
|
||||||
return self.server, self.token
|
return self.server, self.token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to connect to Plex server: {str(e)}")
|
logger.warning(f"Failed to connect to Plex server: {str(e)}")
|
||||||
@@ -106,9 +104,7 @@ class PlexClient:
|
|||||||
self.token = account.authenticationToken
|
self.token = account.authenticationToken
|
||||||
|
|
||||||
self.server = PlexServer(self.base_url, self.token, timeout=timeout)
|
self.server = PlexServer(self.base_url, self.token, timeout=timeout)
|
||||||
logger.debug(
|
logger.debug(f"Connected to Plex server with username: {username}.")
|
||||||
f"Connected to Plex server with username: {username}, token: {self.token}"
|
|
||||||
)
|
|
||||||
return self.server, self.token
|
return self.server, self.token
|
||||||
|
|
||||||
def _connect_with_token(
|
def _connect_with_token(
|
||||||
@@ -124,7 +120,7 @@ class PlexClient:
|
|||||||
self.base_url = build_plex_url(scheme, url, port)
|
self.base_url = build_plex_url(scheme, url, port)
|
||||||
|
|
||||||
self.server = PlexServer(self.base_url, token, timeout=timeout)
|
self.server = PlexServer(self.base_url, token, timeout=timeout)
|
||||||
logger.debug(f"Connected to Plex server with token: {token}")
|
logger.debug("Connected to Plex server with token.")
|
||||||
return self.server, token
|
return self.server, token
|
||||||
|
|
||||||
def _connect_check(self):
|
def _connect_check(self):
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import threading
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import hashlib
|
||||||
|
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
|
||||||
@@ -19,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
|
||||||
@@ -76,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})...")
|
||||||
@@ -139,9 +164,10 @@ class SyncManager:
|
|||||||
local_result_path = os.path.join(output_dir, "outputs", "local_result.m3u8")
|
local_result_path = os.path.join(output_dir, "outputs", "local_result.m3u8")
|
||||||
if os.path.exists(local_result_path):
|
if os.path.exists(local_result_path):
|
||||||
tracks = load_local_playlist(local_result_path)
|
tracks = load_local_playlist(local_result_path)
|
||||||
dest_path = os.path.join(server_config.local_path, f"{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)
|
||||||
@@ -156,10 +182,12 @@ class SyncManager:
|
|||||||
|
|
||||||
elif action == "deleted":
|
elif action == "deleted":
|
||||||
# Delete Local
|
# Delete Local
|
||||||
dest_path = os.path.join(server_config.local_path, f"{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 = os.path.join(server_config.local_path, f"{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
|
||||||
@@ -167,8 +195,63 @@ class SyncManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error applying sync result for playlist {playlist_name}: {e}")
|
logger.error(f"Error applying sync result for playlist {playlist_name}: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_local_playlist_path(local_dir: str, playlist_name: str, extension: str) -> str:
|
||||||
|
base_dir = os.path.abspath(local_dir or "")
|
||||||
|
if not base_dir:
|
||||||
|
raise ValueError("Local playlist directory is not configured")
|
||||||
|
|
||||||
|
original = (playlist_name or "").strip()
|
||||||
|
# Drop any path components.
|
||||||
|
name = os.path.basename(original)
|
||||||
|
# Remove control chars.
|
||||||
|
name = re.sub(r"[\x00-\x1f\x7f]", "_", name)
|
||||||
|
# Replace path separators and Windows-invalid characters.
|
||||||
|
invalid = set('<>:"/\\|?*')
|
||||||
|
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip().strip(". ")
|
||||||
|
|
||||||
|
windows_reserved = {
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
*(f"COM{i}" for i in range(1, 10)),
|
||||||
|
*(f"LPT{i}" for i in range(1, 10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
needs_hash = False
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = "playlist"
|
||||||
|
needs_hash = True
|
||||||
|
if cleaned.upper() in windows_reserved:
|
||||||
|
needs_hash = True
|
||||||
|
if cleaned != original:
|
||||||
|
needs_hash = True
|
||||||
|
|
||||||
|
cleaned = cleaned[:160].rstrip().strip(". ")
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = "playlist"
|
||||||
|
needs_hash = True
|
||||||
|
|
||||||
|
if needs_hash:
|
||||||
|
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
|
||||||
|
cleaned = f"{cleaned}__{digest}"
|
||||||
|
|
||||||
|
filename = f"{cleaned}{extension}"
|
||||||
|
candidate = os.path.abspath(os.path.join(base_dir, filename))
|
||||||
|
|
||||||
|
# Ensure the final path stays within base_dir.
|
||||||
|
if os.path.commonpath([base_dir, candidate]) != base_dir:
|
||||||
|
raise ValueError("Refusing to write outside local playlist directory")
|
||||||
|
|
||||||
|
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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user