5 Commits

8 changed files with 183 additions and 52 deletions
+6
View File
@@ -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
-29
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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:
+3 -7
View File
@@ -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):
+86 -3
View File
@@ -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
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):