Sync all playlists and track deletions
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Literal, Sequence
|
||||
|
||||
from merge3 import Merge3
|
||||
|
||||
|
||||
TEST_PLAYLIST_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "test_playlists")
|
||||
)
|
||||
|
||||
|
||||
class ConflictResolutionStrategy(str, Enum):
|
||||
LOCAL_PRIORITY = "local_priority"
|
||||
REMOTE_PRIORITY = "remote_priority"
|
||||
DELETE_PRIORITY = "delete_priority"
|
||||
KEEP_PRIORITY = "keep_priority"
|
||||
|
||||
|
||||
class SyncMode(str, Enum):
|
||||
LOCAL_FORCE = "local_force"
|
||||
REMOTE_FORCE = "remote_force"
|
||||
MERGE_LOCAL_PRIMARY = "merge_local_primary"
|
||||
MERGE_REMOTE_PRIMARY = "merge_remote_primary"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistSyncResult:
|
||||
name: str
|
||||
merged_paths: list[str]
|
||||
conflicts: list[dict]
|
||||
action: Literal["synced", "deleted"]
|
||||
output_dir: str
|
||||
|
||||
|
||||
def load_paths(text: str) -> list[str]:
|
||||
"""Normalize playlist text into a list of absolute paths.
|
||||
|
||||
* Remove blank lines.
|
||||
* Ignore comment or metadata lines starting with ``#``.
|
||||
* Preserve order and allow duplicates.
|
||||
"""
|
||||
|
||||
output: list[str] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
output.append(line)
|
||||
return output
|
||||
|
||||
|
||||
def playlist_to_text(paths: Sequence[str]) -> str:
|
||||
"""Convert list of paths into diff3-friendly text."""
|
||||
|
||||
return "\n".join(paths) + "\n"
|
||||
|
||||
|
||||
def save_paths(paths: Sequence[str]) -> str:
|
||||
"""Format playlist paths into m3u/m3u8 text."""
|
||||
|
||||
return "#EXTM3U\n" + "\n".join(paths) + "\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeChunk:
|
||||
type: Literal["normal", "conflict"]
|
||||
text: str | None = None
|
||||
base_text: str | None = None
|
||||
local_text: str | None = None
|
||||
remote_text: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeResult:
|
||||
merged_paths: list[str]
|
||||
conflicts: list[dict]
|
||||
|
||||
|
||||
def _ensure_test_dir(folder: str = TEST_PLAYLIST_DIR) -> str:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def _read_text_if_exists(path: str) -> tuple[str, bool]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
return file.read(), True
|
||||
except FileNotFoundError:
|
||||
return "", False
|
||||
except OSError:
|
||||
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)
|
||||
with open(file_path, "w", encoding="utf-8") as file:
|
||||
file.write(save_paths(paths))
|
||||
return file_path
|
||||
|
||||
|
||||
def _normalize_inputs(
|
||||
base_text: str, local_text: str, remote_text: str, folder: str
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Normalize playlist inputs and persist snapshots for inspection."""
|
||||
|
||||
base_paths = load_paths(base_text)
|
||||
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)
|
||||
|
||||
return base_paths, local_paths, remote_paths
|
||||
|
||||
|
||||
def _merge_chunks(
|
||||
base_paths: Sequence[str], local_paths: Sequence[str], remote_paths: Sequence[str]
|
||||
) -> list[MergeChunk]:
|
||||
"""Run a diff3 merge on normalized playlists and return merge chunks."""
|
||||
|
||||
merger = Merge3(base_paths, local_paths, remote_paths)
|
||||
chunks: list[MergeChunk] = []
|
||||
|
||||
for group in merger.merge_groups():
|
||||
kind = group[0]
|
||||
if kind == "conflict":
|
||||
base_lines, local_lines, remote_lines = group[1], group[2], group[3]
|
||||
chunks.append(
|
||||
MergeChunk(
|
||||
type="conflict",
|
||||
base_text=playlist_to_text(base_lines),
|
||||
local_text=playlist_to_text(local_lines),
|
||||
remote_text=playlist_to_text(remote_lines),
|
||||
)
|
||||
)
|
||||
else:
|
||||
if kind in ("unchanged", "same", "a", "b"):
|
||||
lines = group[1]
|
||||
chunks.append(
|
||||
MergeChunk(
|
||||
type="normal",
|
||||
text=playlist_to_text(lines),
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported merge chunk type: {kind}")
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _write_results(merged_lines: Sequence[str], folder: str) -> None:
|
||||
_save_playlist_to_folder("local_result.m3u8", merged_lines, folder)
|
||||
_save_playlist_to_folder("remote_result.m3u8", merged_lines, folder)
|
||||
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder)
|
||||
|
||||
|
||||
def _write_delete_marker(playlist: str, folder: str) -> str:
|
||||
_ensure_test_dir(folder)
|
||||
marker_path = os.path.join(folder, "delete.txt")
|
||||
with open(marker_path, "w", encoding="utf-8") as file:
|
||||
file.write(f"delete playlist {playlist}")
|
||||
return marker_path
|
||||
|
||||
|
||||
def _resolve_conflict(conflict: dict, strategy: ConflictResolutionStrategy) -> list[str]:
|
||||
local_lines = conflict["local"]
|
||||
remote_lines = conflict["remote"]
|
||||
|
||||
if strategy == ConflictResolutionStrategy.LOCAL_PRIORITY:
|
||||
return local_lines
|
||||
if strategy == ConflictResolutionStrategy.REMOTE_PRIORITY:
|
||||
return remote_lines
|
||||
if strategy == ConflictResolutionStrategy.DELETE_PRIORITY:
|
||||
if not local_lines or not remote_lines:
|
||||
return []
|
||||
return local_lines
|
||||
if strategy == ConflictResolutionStrategy.KEEP_PRIORITY:
|
||||
if local_lines:
|
||||
return local_lines
|
||||
if remote_lines:
|
||||
return remote_lines
|
||||
return []
|
||||
|
||||
raise ValueError(f"Unsupported conflict resolution strategy: {strategy}")
|
||||
|
||||
|
||||
def merge_playlists(
|
||||
base_text: str,
|
||||
local_text: str,
|
||||
remote_text: str,
|
||||
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
|
||||
test_folder: str = TEST_PLAYLIST_DIR,
|
||||
) -> MergeResult:
|
||||
"""Merge playlists using diff3 and resolve conflicts per strategy.
|
||||
|
||||
The base, local, and remote normalized playlists are saved into ``test_folder``
|
||||
for inspection. The merged playlist is also stored twice to simulate the
|
||||
versions intended for local save and cloud upload.
|
||||
"""
|
||||
|
||||
base_paths, local_paths, remote_paths = _normalize_inputs(
|
||||
base_text, local_text, remote_text, test_folder
|
||||
)
|
||||
|
||||
chunks = _merge_chunks(base_paths, local_paths, remote_paths)
|
||||
|
||||
merged_lines: list[str] = []
|
||||
conflicts: list[dict] = []
|
||||
|
||||
for chunk in chunks:
|
||||
if chunk.type == "normal":
|
||||
merged_lines.extend(chunk.text.splitlines())
|
||||
else:
|
||||
conflict_info = {
|
||||
"base": chunk.base_text.splitlines(),
|
||||
"local": chunk.local_text.splitlines(),
|
||||
"remote": chunk.remote_text.splitlines(),
|
||||
}
|
||||
conflicts.append(conflict_info)
|
||||
resolved = _resolve_conflict(conflict_info, strategy)
|
||||
merged_lines.extend(resolved)
|
||||
|
||||
_write_results(merged_lines, test_folder)
|
||||
|
||||
return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
|
||||
|
||||
|
||||
def _load_local_playlists(local_dir: str) -> dict[str, str]:
|
||||
"""Read all local playlists under ``local_dir`` keyed by stem name."""
|
||||
|
||||
playlists: dict[str, str] = {}
|
||||
if not local_dir:
|
||||
return playlists
|
||||
|
||||
absolute = os.path.abspath(local_dir)
|
||||
if not os.path.isdir(absolute):
|
||||
return playlists
|
||||
|
||||
for entry in os.scandir(absolute):
|
||||
if entry.is_file() and entry.name.lower().endswith((".m3u", ".m3u8")):
|
||||
stem, _ = os.path.splitext(entry.name)
|
||||
try:
|
||||
with open(entry.path, "r", encoding="utf-8") as file:
|
||||
playlists[stem] = file.read()
|
||||
except OSError:
|
||||
playlists[stem] = ""
|
||||
return playlists
|
||||
|
||||
|
||||
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)
|
||||
base_text, base_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base_next.m3u8")
|
||||
)
|
||||
if not base_text:
|
||||
alt_text, _ = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "base_playlist.m3u8")
|
||||
)
|
||||
base_text = base_text or alt_text
|
||||
|
||||
remote_text, remote_exists = _read_text_if_exists(
|
||||
os.path.join(playlist_folder, "remote_input.m3u8")
|
||||
)
|
||||
|
||||
return base_text, remote_text, playlist_folder, remote_exists, base_exists
|
||||
|
||||
|
||||
def _sync_single_playlist(
|
||||
playlist: str,
|
||||
mode: SyncMode,
|
||||
local_text: str | None,
|
||||
base_text: str,
|
||||
remote_text: str,
|
||||
playlist_folder: str,
|
||||
remote_present: bool,
|
||||
) -> PlaylistSyncResult:
|
||||
local_present = local_text is not None
|
||||
local_text = local_text or ""
|
||||
|
||||
if not base_text:
|
||||
base_text = "#EXTM3U\n"
|
||||
if not remote_text:
|
||||
remote_text = "#EXTM3U\n"
|
||||
|
||||
if mode == SyncMode.LOCAL_FORCE:
|
||||
if not local_present:
|
||||
_write_delete_marker(playlist, playlist_folder)
|
||||
return PlaylistSyncResult(playlist, [], [], "deleted", playlist_folder)
|
||||
|
||||
base_paths, local_paths, _ = _normalize_inputs(
|
||||
base_text, local_text, remote_text, playlist_folder
|
||||
)
|
||||
merged_lines = list(local_paths)
|
||||
_write_results(merged_lines, playlist_folder)
|
||||
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
|
||||
|
||||
if mode == SyncMode.REMOTE_FORCE:
|
||||
if not remote_present:
|
||||
_write_delete_marker(playlist, playlist_folder)
|
||||
return PlaylistSyncResult(playlist, [], [], "deleted", playlist_folder)
|
||||
|
||||
base_paths, _, remote_paths = _normalize_inputs(
|
||||
base_text, local_text, remote_text, playlist_folder
|
||||
)
|
||||
merged_lines = list(remote_paths)
|
||||
_write_results(merged_lines, playlist_folder)
|
||||
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
|
||||
|
||||
if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY):
|
||||
raise ValueError(f"Unsupported sync mode: {mode}")
|
||||
|
||||
merge_strategy = (
|
||||
ConflictResolutionStrategy.LOCAL_PRIORITY
|
||||
if mode == SyncMode.MERGE_LOCAL_PRIMARY
|
||||
else ConflictResolutionStrategy.REMOTE_PRIORITY
|
||||
)
|
||||
|
||||
merge_result = merge_playlists(
|
||||
base_text=base_text,
|
||||
local_text=local_text,
|
||||
remote_text=remote_text,
|
||||
strategy=merge_strategy,
|
||||
test_folder=playlist_folder,
|
||||
)
|
||||
|
||||
if not merge_result.merged_paths and (not local_present or not remote_present):
|
||||
_write_delete_marker(playlist, playlist_folder)
|
||||
return PlaylistSyncResult(
|
||||
playlist, merge_result.merged_paths, merge_result.conflicts, "deleted", playlist_folder
|
||||
)
|
||||
|
||||
return PlaylistSyncResult(
|
||||
playlist, merge_result.merged_paths, merge_result.conflicts, "synced", playlist_folder
|
||||
)
|
||||
|
||||
|
||||
def sync_all_playlists(
|
||||
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR
|
||||
) -> list[PlaylistSyncResult]:
|
||||
"""Synchronize all playlists that can be matched by name."""
|
||||
|
||||
_ensure_test_dir(test_folder)
|
||||
local_playlists = _load_local_playlists(local_dir)
|
||||
playlist_names: set[str] = set(local_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):
|
||||
base_text, remote_text, playlist_folder, remote_exists, _ = _load_playlist_snapshots(
|
||||
playlist, test_folder
|
||||
)
|
||||
local_text = local_playlists.get(playlist)
|
||||
remote_present = bool(remote_text.strip()) or remote_exists
|
||||
|
||||
# Treat missing remote text as absent playlist.
|
||||
result = _sync_single_playlist(
|
||||
playlist=playlist,
|
||||
mode=mode,
|
||||
local_text=local_text,
|
||||
base_text=base_text,
|
||||
remote_text=remote_text,
|
||||
playlist_folder=playlist_folder,
|
||||
remote_present=remote_present,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user