Implement Regex Rules backend functionality for path mapping

- Add CompiledRegexRules dataclass for all four processing stages
- Update _compile_regex_rules to support both legacy (pattern/replacement)
  and new (search/replace) field names with proper empty string handling
- Add _compile_path_mapping_rules helper function
- Update _write_results to apply post-processing rules:
  - local_result.m3u8 with local_post rules
  - remote_result.m3u8 with remote_post rules
  - base_next.m3u8 unprocessed (normalized sync result)
- Update merge_playlists and _sync_single_playlist to pass compiled_rules
- Update sync_all_playlists to implement full processing flow:
  1. Detect REGEX mode from path_mapping config
  2. Apply local_pre rules to local playlists before sync
  3. Apply remote_pre rules to remote playlists before sync
  4. Perform sync/merge
  5. Apply post rules to results for respective outputs

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-02 20:08:06 +00:00
parent f9dbe733c3
commit fbb5bb55c7
+114 -19
View File
@@ -40,6 +40,15 @@ class PlaylistSyncResult:
output_dir: str output_dir: str
@dataclass
class CompiledRegexRules:
"""Holds compiled regex rules for all four processing stages."""
local_pre: list[tuple[re.Pattern[str], str]]
local_post: list[tuple[re.Pattern[str], str]]
remote_pre: list[tuple[re.Pattern[str], str]]
remote_post: list[tuple[re.Pattern[str], str]]
def load_paths(text: str) -> list[str]: def load_paths(text: str) -> list[str]:
"""Normalize playlist text into a list of absolute paths. """Normalize playlist text into a list of absolute paths.
@@ -72,12 +81,21 @@ def save_paths(paths: Sequence[str]) -> str:
def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]: def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]:
"""Compile regex rules into pattern/replacement pairs.
Supports both legacy format (pattern/replacement) and new format (search/replace).
"""
compiled: list[tuple[re.Pattern[str], str]] = [] compiled: list[tuple[re.Pattern[str], str]] = []
for rule in rules: for rule in rules:
pattern = rule.get("pattern") # Support both legacy (pattern/replacement) and new (search/replace) field names
# Use explicit None checks to allow empty strings as valid values
pattern = rule.get("pattern") if rule.get("pattern") is not None else rule.get("search")
if not pattern: if not pattern:
continue continue
replacement = rule.get("replacement", "") # For replacement, empty string is a valid value (for deletion)
replacement = rule.get("replacement") if rule.get("replacement") is not None else rule.get("replace")
if replacement is None:
replacement = ""
try: try:
compiled.append((re.compile(pattern), replacement)) compiled.append((re.compile(pattern), replacement))
except re.error as exc: except re.error as exc:
@@ -234,9 +252,31 @@ def _merge_chunks(
return chunks return chunks
def _write_results(merged_lines: Sequence[str], folder: str) -> None: def _write_results(
_save_playlist_to_folder("local_result.m3u8", merged_lines, folder) merged_lines: Sequence[str],
_save_playlist_to_folder("remote_result.m3u8", merged_lines, folder) folder: str,
compiled_rules: CompiledRegexRules | None = None
) -> None:
"""Write sync results to the test folder.
If compiled_rules is provided with post-processing rules:
- local_result.m3u8: merged_lines processed with local_post rules
- remote_result.m3u8: merged_lines processed with remote_post rules
- base_next.m3u8: unprocessed merged_lines (normalized sync result)
"""
# Apply post-processing regex rules if provided
if compiled_rules and compiled_rules.local_post:
local_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.local_post)
else:
local_lines = list(merged_lines)
if compiled_rules and compiled_rules.remote_post:
remote_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.remote_post)
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_to_folder("base_next.m3u8", merged_lines, folder)
@@ -379,12 +419,16 @@ def merge_playlists(
remote_text: str, remote_text: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY, strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
test_folder: str = TEST_PLAYLIST_DIR, test_folder: str = TEST_PLAYLIST_DIR,
compiled_rules: CompiledRegexRules | None = None,
) -> MergeResult: ) -> MergeResult:
"""Merge playlists using diff3 and resolve conflicts per strategy. """Merge playlists using diff3 and resolve conflicts per strategy.
The base, local, and remote normalized playlists are saved into ``test_folder`` The base, local, and remote normalized playlists are saved into ``test_folder``
for inspection. The merged playlist is also stored twice to simulate the for inspection. The merged playlist is also stored twice to simulate the
versions intended for local save and cloud upload. versions intended for local save and cloud upload.
If compiled_rules is provided, post-processing regex rules will be applied
to the results before writing.
""" """
base_paths, local_paths, remote_paths = _normalize_inputs( base_paths, local_paths, remote_paths = _normalize_inputs(
@@ -420,7 +464,7 @@ def merge_playlists(
merged_lines, base_paths, local_paths, remote_paths merged_lines, base_paths, local_paths, remote_paths
) )
_write_results(merged_lines, test_folder) _write_results(merged_lines, test_folder, compiled_rules)
return MergeResult(merged_paths=merged_lines, conflicts=conflicts) return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
@@ -517,6 +561,7 @@ def _sync_single_playlist(
remote_text: str, remote_text: str,
playlist_folder: str, playlist_folder: str,
remote_present: bool, remote_present: bool,
compiled_rules: CompiledRegexRules | None = None,
) -> PlaylistSyncResult: ) -> PlaylistSyncResult:
local_present = local_text is not None local_present = local_text is not None
local_text = local_text or "" local_text = local_text or ""
@@ -535,7 +580,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder base_text, local_text, remote_text, playlist_folder
) )
merged_lines = list(local_paths) merged_lines = list(local_paths)
_write_results(merged_lines, playlist_folder) _write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder) return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode == SyncMode.REMOTE_FORCE: if mode == SyncMode.REMOTE_FORCE:
@@ -547,7 +592,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder base_text, local_text, remote_text, playlist_folder
) )
merged_lines = list(remote_paths) merged_lines = list(remote_paths)
_write_results(merged_lines, playlist_folder) _write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder) return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY): if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY):
@@ -565,6 +610,7 @@ def _sync_single_playlist(
remote_text=remote_text, remote_text=remote_text,
strategy=merge_strategy, strategy=merge_strategy,
test_folder=playlist_folder, test_folder=playlist_folder,
compiled_rules=compiled_rules,
) )
if not merge_result.merged_paths and (not local_present or not remote_present): if not merge_result.merged_paths and (not local_present or not remote_present):
@@ -578,13 +624,48 @@ def _sync_single_playlist(
) )
def _compile_path_mapping_rules(path_mapping: dict) -> CompiledRegexRules:
"""Compile regex rules from path_mapping config for all four processing stages."""
regex_config = path_mapping.get("regex", {})
return CompiledRegexRules(
local_pre=_compile_regex_rules(regex_config.get("local_pre", [])),
local_post=_compile_regex_rules(regex_config.get("local_post", [])),
remote_pre=_compile_regex_rules(regex_config.get("remote_pre", [])),
remote_post=_compile_regex_rules(regex_config.get("remote_post", [])),
)
def sync_all_playlists( def sync_all_playlists(
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR
) -> list[PlaylistSyncResult]: ) -> list[PlaylistSyncResult]:
"""Synchronize all playlists that can be matched by name.""" """Synchronize all playlists that can be matched by name.
When path_mapping mode is REGEX, the following processing flow is applied:
1. local_pre rules are applied to local playlists before sync
2. remote_pre rules are applied to remote playlists before sync
3. Sync/merge is performed
4. local_post rules are applied to results before writing to local_result.m3u8
5. remote_post rules are applied to results before writing to remote_result.m3u8
"""
server_config.load() server_config.load()
compiled_rules = _compile_regex_rules(server_config.path_rules)
# Check if we should use the new path_mapping REGEX mode
path_mapping = server_config.path_mapping
use_regex_mode = path_mapping.get("mode") == "REGEX"
# Compile rules based on the mode
compiled_rules: CompiledRegexRules | None = None
legacy_compiled_rules: list[tuple[re.Pattern[str], str]] = []
if use_regex_mode:
compiled_rules = _compile_path_mapping_rules(path_mapping)
logger.info("Using REGEX mode for path mapping with 4 rule groups")
else:
# Use legacy path_rules for backward compatibility
legacy_compiled_rules = _compile_regex_rules(server_config.path_rules)
logger.info("Using legacy path_rules for preprocessing")
_ensure_test_dir(test_folder) _ensure_test_dir(test_folder)
logger.info(f"Syncing playlists to test folder: {test_folder}") logger.info(f"Syncing playlists to test folder: {test_folder}")
local_playlists = _load_local_playlists(local_dir) local_playlists = _load_local_playlists(local_dir)
@@ -613,16 +694,29 @@ def sync_all_playlists(
remote_text = snapshot_remote_text remote_text = snapshot_remote_text
remote_present = bool(remote_text.strip()) or remote_exists remote_present = bool(remote_text.strip()) or remote_exists
base_text = preprocess_playlist_text( if use_regex_mode and compiled_rules:
base_text, server_config.path_rules, compiled_rules # Apply pre-processing rules for REGEX mode
) # base_text doesn't need pre-processing as it's the normalized state
remote_text = preprocess_playlist_text( if local_text is not None and compiled_rules.local_pre:
remote_text, server_config.path_rules, compiled_rules local_text = preprocess_playlist_text(
) local_text, [], compiled_rules.local_pre
if local_text is not None: )
local_text = preprocess_playlist_text( if remote_text and compiled_rules.remote_pre:
local_text, server_config.path_rules, compiled_rules remote_text = preprocess_playlist_text(
remote_text, [], compiled_rules.remote_pre
)
else:
# Use legacy preprocessing for all texts
base_text = preprocess_playlist_text(
base_text, server_config.path_rules, legacy_compiled_rules
) )
remote_text = preprocess_playlist_text(
remote_text, server_config.path_rules, legacy_compiled_rules
)
if local_text is not None:
local_text = preprocess_playlist_text(
local_text, server_config.path_rules, legacy_compiled_rules
)
# Treat missing remote text as absent playlist. # Treat missing remote text as absent playlist.
result = _sync_single_playlist( result = _sync_single_playlist(
@@ -633,6 +727,7 @@ def sync_all_playlists(
remote_text=remote_text, remote_text=remote_text,
playlist_folder=playlist_folder, playlist_folder=playlist_folder,
remote_present=remote_present, remote_present=remote_present,
compiled_rules=compiled_rules if use_regex_mode else None,
) )
results.append(result) results.append(result)