feat: Refactor backup and sync paths, enhance configuration flexibility
This commit is contained in:
+92
-42
@@ -12,10 +12,13 @@ from app.utils.plex_client import plex_client
|
||||
from merge3 import Merge3
|
||||
|
||||
|
||||
TEST_PLAYLIST_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "test_playlists")
|
||||
SYNC_ARTIFACTS_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "data", "sync_artifacts")
|
||||
)
|
||||
|
||||
# Backward-compat alias (older API / logs used this name).
|
||||
TEST_PLAYLIST_DIR = SYNC_ARTIFACTS_DIR
|
||||
|
||||
|
||||
class ConflictResolutionStrategy(str, Enum):
|
||||
LOCAL_PRIORITY = "local_priority"
|
||||
@@ -159,9 +162,37 @@ class MergeResult:
|
||||
conflicts: list[dict]
|
||||
|
||||
|
||||
def _ensure_test_dir(folder: str = TEST_PLAYLIST_DIR) -> str:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return folder
|
||||
def _ensure_dir(path: str) -> str:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _safe_folder_name(name: str, max_len: int = 120) -> str:
|
||||
"""Make a filesystem-safe folder name (especially for Windows hosts).
|
||||
|
||||
This is used for artifacts folders persisted to the host.
|
||||
"""
|
||||
|
||||
if not name:
|
||||
return "(unnamed)"
|
||||
|
||||
# Windows-disallowed characters plus path separators.
|
||||
invalid = set('<>:"/\\|?*')
|
||||
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip()
|
||||
if not cleaned:
|
||||
cleaned = "(unnamed)"
|
||||
if len(cleaned) > max_len:
|
||||
cleaned = cleaned[:max_len].rstrip()
|
||||
return cleaned
|
||||
|
||||
|
||||
def _playlist_artifact_dir(artifacts_root: str, playlist_name: str) -> str:
|
||||
return os.path.join(artifacts_root, _safe_folder_name(playlist_name))
|
||||
|
||||
|
||||
def _artifact_file(playlist_folder: str, category: str, filename: str) -> str:
|
||||
category_dir = _ensure_dir(os.path.join(playlist_folder, category))
|
||||
return os.path.join(category_dir, filename)
|
||||
|
||||
|
||||
def _read_text_if_exists(path: str) -> tuple[str, bool]:
|
||||
@@ -174,26 +205,27 @@ def _read_text_if_exists(path: str) -> tuple[str, bool]:
|
||||
return "", False
|
||||
|
||||
|
||||
def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str:
|
||||
_ensure_test_dir(folder)
|
||||
file_path = os.path.join(folder, filename)
|
||||
logger.info(f"Saving playlist to: {file_path}")
|
||||
|
||||
new_content = save_paths(paths)
|
||||
|
||||
# Check if content has changed before writing to avoid triggering unnecessary file events
|
||||
if os.path.exists(file_path):
|
||||
def _save_playlist_text(path: str, text: str) -> str:
|
||||
"""Write text if changed (avoid triggering unnecessary file events)."""
|
||||
|
||||
_ensure_dir(os.path.dirname(path))
|
||||
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
current_content = f.read()
|
||||
if current_content == new_content:
|
||||
return file_path
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
if file.read() == text:
|
||||
return path
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as file:
|
||||
file.write(new_content)
|
||||
return file_path
|
||||
with open(path, "w", encoding="utf-8") as file:
|
||||
file.write(text)
|
||||
return path
|
||||
|
||||
|
||||
def _save_playlist_paths(path: str, paths: Sequence[str]) -> str:
|
||||
logger.info(f"Saving playlist to: {path}")
|
||||
return _save_playlist_text(path, save_paths(paths))
|
||||
|
||||
|
||||
def _normalize_inputs(
|
||||
@@ -205,9 +237,9 @@ def _normalize_inputs(
|
||||
local_paths = load_paths(local_text)
|
||||
remote_paths = load_paths(remote_text)
|
||||
|
||||
_save_playlist_to_folder("base_playlist.m3u8", base_paths, folder)
|
||||
_save_playlist_to_folder("local_input.m3u8", local_paths, folder)
|
||||
_save_playlist_to_folder("remote_input.m3u8", remote_paths, folder)
|
||||
_save_playlist_paths(_artifact_file(folder, "base", "base_prev.m3u8"), base_paths)
|
||||
_save_playlist_paths(_artifact_file(folder, "inputs", "local_input.m3u8"), local_paths)
|
||||
_save_playlist_paths(_artifact_file(folder, "inputs", "remote_input.m3u8"), remote_paths)
|
||||
|
||||
return base_paths, local_paths, remote_paths
|
||||
|
||||
@@ -275,14 +307,13 @@ def _write_results(
|
||||
else:
|
||||
remote_lines = list(merged_lines)
|
||||
|
||||
_save_playlist_to_folder("local_result.m3u8", local_lines, folder)
|
||||
_save_playlist_to_folder("remote_result.m3u8", remote_lines, folder)
|
||||
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder)
|
||||
_save_playlist_paths(_artifact_file(folder, "outputs", "local_result.m3u8"), local_lines)
|
||||
_save_playlist_paths(_artifact_file(folder, "outputs", "remote_result.m3u8"), remote_lines)
|
||||
_save_playlist_paths(_artifact_file(folder, "base", "base_next.m3u8"), merged_lines)
|
||||
|
||||
|
||||
def _write_delete_marker(playlist: str, folder: str) -> str:
|
||||
_ensure_test_dir(folder)
|
||||
marker_path = os.path.join(folder, "delete.txt")
|
||||
marker_path = _artifact_file(folder, "meta", "delete.txt")
|
||||
with open(marker_path, "w", encoding="utf-8") as file:
|
||||
file.write(f"delete playlist {playlist}")
|
||||
return marker_path
|
||||
@@ -418,7 +449,7 @@ def merge_playlists(
|
||||
local_text: str,
|
||||
remote_text: str,
|
||||
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
|
||||
test_folder: str = TEST_PLAYLIST_DIR,
|
||||
test_folder: str = SYNC_ARTIFACTS_DIR,
|
||||
compiled_rules: CompiledRegexRules | None = None,
|
||||
) -> MergeResult:
|
||||
"""Merge playlists using diff3 and resolve conflicts per strategy.
|
||||
@@ -494,19 +525,42 @@ def _load_local_playlists(local_dir: str) -> dict[str, str]:
|
||||
def _load_playlist_snapshots(playlist: str, folder: str) -> tuple[str, str, str, bool, bool]:
|
||||
"""Load base/local/remote texts for a playlist from its test folder."""
|
||||
|
||||
playlist_folder = os.path.join(folder, playlist)
|
||||
playlist_folder = _playlist_artifact_dir(folder, playlist)
|
||||
|
||||
# Prefer new organized layout.
|
||||
base_text, base_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base_next.m3u8")
|
||||
os.path.join(playlist_folder, "base", "base_next.m3u8")
|
||||
)
|
||||
if not base_text:
|
||||
alt_text, _ = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base_playlist.m3u8")
|
||||
alt_text, alt_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base", "base_prev.m3u8")
|
||||
)
|
||||
base_text = base_text or alt_text
|
||||
base_exists = base_exists or alt_exists
|
||||
|
||||
# Backward-compat: legacy flat file layout.
|
||||
if not base_text:
|
||||
legacy_text, legacy_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base_next.m3u8")
|
||||
)
|
||||
base_text = base_text or legacy_text
|
||||
base_exists = base_exists or legacy_exists
|
||||
if not base_text:
|
||||
legacy_text, legacy_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base_playlist.m3u8")
|
||||
)
|
||||
base_text = base_text or legacy_text
|
||||
base_exists = base_exists or legacy_exists
|
||||
|
||||
remote_text, remote_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "remote_input.m3u8")
|
||||
os.path.join(playlist_folder, "inputs", "remote_input.m3u8")
|
||||
)
|
||||
if not remote_text:
|
||||
legacy_remote, legacy_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "remote_input.m3u8")
|
||||
)
|
||||
remote_text = remote_text or legacy_remote
|
||||
remote_exists = remote_exists or legacy_exists
|
||||
|
||||
return base_text, remote_text, playlist_folder, remote_exists, base_exists
|
||||
|
||||
@@ -748,7 +802,7 @@ def _compile_simple_mapping_rules(simple_mappings: list[dict]) -> CompiledRegexR
|
||||
|
||||
|
||||
def sync_all_playlists(
|
||||
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR
|
||||
local_dir: str, mode: SyncMode, test_folder: str = SYNC_ARTIFACTS_DIR
|
||||
) -> list[PlaylistSyncResult]:
|
||||
"""Synchronize all playlists that can be matched by name.
|
||||
|
||||
@@ -795,18 +849,14 @@ def sync_all_playlists(
|
||||
legacy_compiled_rules = _compile_regex_rules(server_config.path_rules)
|
||||
logger.info("Using legacy path_rules for preprocessing")
|
||||
|
||||
_ensure_test_dir(test_folder)
|
||||
logger.info(f"Syncing playlists to test folder: {test_folder}")
|
||||
_ensure_dir(test_folder)
|
||||
logger.info(f"Sync artifacts folder: {test_folder}")
|
||||
local_playlists = _load_local_playlists(local_dir)
|
||||
remote_playlists = _fetch_remote_playlists()
|
||||
playlist_names: set[str] = set(local_playlists.keys())
|
||||
|
||||
playlist_names.update(remote_playlists.keys())
|
||||
|
||||
for entry in os.scandir(test_folder):
|
||||
if entry.is_dir():
|
||||
playlist_names.add(entry.name)
|
||||
|
||||
results: list[PlaylistSyncResult] = []
|
||||
|
||||
for playlist in sorted(playlist_names):
|
||||
|
||||
Reference in New Issue
Block a user