6 Commits

49 changed files with 3065 additions and 787 deletions
-36
View File
@@ -92,28 +92,9 @@ class RegexRule(BaseModel):
replacement: str = "" replacement: str = ""
class ReplacementRule(BaseModel):
search: str
replace: str = ""
class RegexRulesGroup(BaseModel):
local_pre: list[ReplacementRule] = []
local_post: list[ReplacementRule] = []
remote_pre: list[ReplacementRule] = []
remote_post: list[ReplacementRule] = []
class PathMappingPayload(BaseModel):
mode: str = "SIMPLE"
simple: list[ReplacementRule] = []
regex: RegexRulesGroup = RegexRulesGroup()
class SyncSettingsResponse(BaseModel): class SyncSettingsResponse(BaseModel):
sync_mode: str sync_mode: str
path_rules: list[RegexRule] path_rules: list[RegexRule]
path_mapping: dict | None = None
local_path: str local_path: str
library_name: str | None = None library_name: str | None = None
server_url: str | None = None server_url: str | None = None
@@ -371,7 +352,6 @@ async def get_settings():
return SyncSettingsResponse( return SyncSettingsResponse(
sync_mode=server_config.sync_mode, sync_mode=server_config.sync_mode,
path_rules=rules, path_rules=rules,
path_mapping=server_config.path_mapping,
local_path=server_config.local_path, local_path=server_config.local_path,
library_name=server_config.library_name, library_name=server_config.library_name,
server_url=server_config.url, server_url=server_config.url,
@@ -400,22 +380,6 @@ async def update_regex_rules(payload: RegexRulePayload):
return {"rules": payload.rules} return {"rules": payload.rules}
@app.put("/api/settings/path-mapping")
async def update_path_mapping(payload: PathMappingPayload):
path_mapping_dict = {
"mode": payload.mode,
"simple": [{"search": rule.search, "replace": rule.replace} for rule in payload.simple],
"regex": {
"local_pre": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.local_pre],
"local_post": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.local_post],
"remote_pre": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_pre],
"remote_post": [{"search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_post],
}
}
server_config.set_and_save_config(path_mapping=path_mapping_dict)
return {"path_mapping": server_config.path_mapping}
@app.put("/api/settings/library") @app.put("/api/settings/library")
async def update_library(payload: LibrarySelection): async def update_library(payload: LibrarySelection):
server_config.set_and_save_config(library_name=payload.library_name) server_config.set_and_save_config(library_name=payload.library_name)
+1 -48
View File
@@ -3,16 +3,6 @@ import os
from app.utils.logger import logger from app.utils.logger import logger
DEFAULT_SYNC_MODE = "merge_local_primary" DEFAULT_SYNC_MODE = "merge_local_primary"
DEFAULT_PATH_MAPPING = {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
}
CONFIG_PATH = os.path.abspath( CONFIG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "config.json") os.path.join(os.path.dirname(__file__), "..", "config.json")
@@ -31,8 +21,7 @@ class ServerConfig:
self.library_name = "" self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE self.sync_mode = DEFAULT_SYNC_MODE
self.local_path = "playlist" self.local_path = "playlist"
self.path_rules: list[dict[str, str]] = [] # Legacy field for backward compatibility self.path_rules: list[dict[str, str]] = []
self.path_mapping: dict = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = "DISABLED" self.schedule_mode = "DISABLED"
self.schedule_cron = "" self.schedule_cron = ""
self.schedule_daily_time = "02:00" self.schedule_daily_time = "02:00"
@@ -66,23 +55,6 @@ class ServerConfig:
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE) self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
self.local_path = config.get("local_path", "playlist") self.local_path = config.get("local_path", "playlist")
self.path_rules = config.get("path_rules", []) or [] self.path_rules = config.get("path_rules", []) or []
# Load path_mapping with default fallback
path_mapping_config = config.get("path_mapping")
if path_mapping_config:
self.path_mapping = {
"mode": path_mapping_config.get("mode", "SIMPLE"),
"simple": path_mapping_config.get("simple", []),
"regex": {
"local_pre": path_mapping_config.get("regex", {}).get("local_pre", []),
"local_post": path_mapping_config.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping_config.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping_config.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = config.get("schedule_mode", "DISABLED") self.schedule_mode = config.get("schedule_mode", "DISABLED")
self.schedule_cron = config.get("schedule_cron", "") self.schedule_cron = config.get("schedule_cron", "")
self.schedule_daily_time = config.get("schedule_daily_time", "02:00") self.schedule_daily_time = config.get("schedule_daily_time", "02:00")
@@ -104,7 +76,6 @@ class ServerConfig:
"sync_mode": self.sync_mode, "sync_mode": self.sync_mode,
"local_path": self.local_path, "local_path": self.local_path,
"path_rules": self.path_rules, "path_rules": self.path_rules,
"path_mapping": self.path_mapping,
"schedule_mode": self.schedule_mode, "schedule_mode": self.schedule_mode,
"schedule_cron": self.schedule_cron, "schedule_cron": self.schedule_cron,
"schedule_daily_time": self.schedule_daily_time, "schedule_daily_time": self.schedule_daily_time,
@@ -150,21 +121,6 @@ class ServerConfig:
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None: def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
self.path_rules = path_rules or [] self.path_rules = path_rules or []
def set_path_mapping(self, path_mapping: dict) -> None:
if path_mapping:
self.path_mapping = {
"mode": path_mapping.get("mode", "SIMPLE"),
"simple": path_mapping.get("simple", []),
"regex": {
"local_pre": path_mapping.get("regex", {}).get("local_pre", []),
"local_post": path_mapping.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
def set_schedule( def set_schedule(
self, self,
mode: str, mode: str,
@@ -194,7 +150,6 @@ class ServerConfig:
sync_mode: str | None = None, sync_mode: str | None = None,
local_path: str | None = None, local_path: str | None = None,
path_rules: list[dict[str, str]] | None = None, path_rules: list[dict[str, str]] | None = None,
path_mapping: dict | None = None,
) -> None: ) -> None:
if theme is not None: if theme is not None:
self.set_theme(theme) self.set_theme(theme)
@@ -216,8 +171,6 @@ class ServerConfig:
self.set_local_path(local_path) self.set_local_path(local_path)
if path_rules is not None: if path_rules is not None:
self.set_path_rules(path_rules) self.set_path_rules(path_rules)
if path_mapping is not None:
self.set_path_mapping(path_mapping)
self.save() self.save()
+19 -219
View File
@@ -40,15 +40,6 @@ 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.
@@ -81,21 +72,12 @@ 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:
# Support both legacy (pattern/replacement) and new (search/replace) field names pattern = rule.get("pattern")
# 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
# For replacement, empty string is a valid value (for deletion) replacement = rule.get("replacement", "")
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:
@@ -252,31 +234,9 @@ def _merge_chunks(
return chunks return chunks
def _write_results( def _write_results(merged_lines: Sequence[str], folder: str) -> None:
merged_lines: Sequence[str], _save_playlist_to_folder("local_result.m3u8", merged_lines, folder)
folder: str, _save_playlist_to_folder("remote_result.m3u8", merged_lines, folder)
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)
@@ -419,16 +379,12 @@ 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(
@@ -464,7 +420,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, compiled_rules) _write_results(merged_lines, test_folder)
return MergeResult(merged_paths=merged_lines, conflicts=conflicts) return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
@@ -561,7 +517,6 @@ 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 ""
@@ -580,7 +535,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, compiled_rules) _write_results(merged_lines, playlist_folder)
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:
@@ -592,7 +547,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, compiled_rules) _write_results(merged_lines, playlist_folder)
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):
@@ -610,7 +565,6 @@ 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):
@@ -624,153 +578,13 @@ 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 _compile_simple_mapping_rules(simple_mappings: list[dict]) -> CompiledRegexRules:
"""Compile simple mapping pairs into four rule groups using UUID-based mapping_ids.
Each simple mapping has:
- id: UUID used as the mapping_id (unique identifier to prevent conflicts)
- search: Local path prefix
- replace: Cloud path prefix
This generates four rule sets:
- local_pre: Replace local path (search) with mapping_id
- remote_pre: Replace cloud path (replace) with mapping_id
- local_post: Replace mapping_id with local path (search)
- remote_post: Replace mapping_id with cloud path (replace)
The mapping_id is wrapped with special markers to prevent conflicts with actual paths.
"""
local_pre_rules: list[dict[str, str]] = []
local_post_rules: list[dict[str, str]] = []
remote_pre_rules: list[dict[str, str]] = []
remote_post_rules: list[dict[str, str]] = []
# UUID pattern for validation (accepts standard UUID format with or without hyphens)
uuid_pattern = re.compile(r'^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$')
for mapping in simple_mappings:
# Get the mapping values
mapping_id = mapping.get("id")
local_path = mapping.get("search", "") # Local path is stored in 'search' field
cloud_path = mapping.get("replace", "") # Cloud path is stored in 'replace' field
# Validate mapping_id is a proper UUID to prevent injection attacks
if not mapping_id or not isinstance(mapping_id, str):
logger.warning(f"Skipping mapping with missing or invalid id: {mapping}")
continue
if not uuid_pattern.match(mapping_id):
logger.warning(f"Skipping mapping with non-UUID id format: {mapping_id}")
continue
# Paths must be non-empty strings
if not local_path or not isinstance(local_path, str):
logger.warning(f"Skipping mapping with missing local path: {mapping}")
continue
if not cloud_path or not isinstance(cloud_path, str):
logger.warning(f"Skipping mapping with missing cloud path: {mapping}")
continue
# Create a unique placeholder using the validated UUID
# Using special markers to prevent conflicts with actual paths
placeholder = f"__MAPPING__{mapping_id}__"
# Pre-processing rules (use re.escape to treat paths as literal strings)
# local_pre: Replace local path with placeholder
local_pre_rules.append({
"pattern": re.escape(local_path),
"replacement": placeholder
})
# remote_pre: Replace cloud path with placeholder
remote_pre_rules.append({
"pattern": re.escape(cloud_path),
"replacement": placeholder
})
# Post-processing rules
# local_post: Replace placeholder with local path
local_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": local_path
})
# remote_post: Replace placeholder with cloud path
remote_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": cloud_path
})
logger.info(f"Compiled {len(local_pre_rules)} simple mapping pairs into rules")
return CompiledRegexRules(
local_pre=_compile_regex_rules(local_pre_rules),
local_post=_compile_regex_rules(local_post_rules),
remote_pre=_compile_regex_rules(remote_pre_rules),
remote_post=_compile_regex_rules(remote_post_rules),
)
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."""
Path mapping modes:
- SIMPLE: Uses UUID-based mapping_ids to convert between local and cloud paths
- local_pre: local_path -> mapping_id
- remote_pre: cloud_path -> mapping_id
- local_post: mapping_id -> local_path
- remote_post: mapping_id -> cloud_path
- REGEX: Uses custom regex rules for each processing stage
- local_pre, local_post, remote_pre, remote_post rules are applied directly
Processing flow:
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)
# Get path_mapping configuration
path_mapping = server_config.path_mapping
mapping_mode = path_mapping.get("mode", "SIMPLE")
# Compile rules based on the mode
compiled_rules: CompiledRegexRules | None = None
legacy_compiled_rules: list[tuple[re.Pattern[str], str]] = []
if mapping_mode == "REGEX":
compiled_rules = _compile_path_mapping_rules(path_mapping)
logger.info("Using REGEX mode for path mapping with 4 rule groups")
elif mapping_mode == "SIMPLE":
simple_mappings = path_mapping.get("simple", [])
if simple_mappings:
compiled_rules = _compile_simple_mapping_rules(simple_mappings)
logger.info(f"Using SIMPLE mode for path mapping with {len(simple_mappings)} mapping pairs")
else:
logger.info("SIMPLE mode with no mappings - no path transformations will be applied")
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)
@@ -799,29 +613,16 @@ 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
if compiled_rules: base_text = preprocess_playlist_text(
# Apply pre-processing rules for REGEX or SIMPLE mode base_text, server_config.path_rules, compiled_rules
# base_text doesn't need pre-processing as it's the normalized state )
if local_text is not None and compiled_rules.local_pre: remote_text = preprocess_playlist_text(
local_text = preprocess_playlist_text( remote_text, server_config.path_rules, compiled_rules
local_text, [], compiled_rules.local_pre )
) if local_text is not None:
if remote_text and compiled_rules.remote_pre: local_text = preprocess_playlist_text(
remote_text = preprocess_playlist_text( local_text, server_config.path_rules, compiled_rules
remote_text, [], compiled_rules.remote_pre
)
elif legacy_compiled_rules:
# 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(
@@ -832,7 +633,6 @@ 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,
) )
results.append(result) results.append(result)
+2 -1
View File
@@ -5,7 +5,8 @@ services:
ports: ports:
- "8888:8080" - "8888:8080"
volumes: volumes:
- path_to_your_playlist:/app/playlist - ./output_playlists:/app/app/test_playlists
- ./test_case/local_playlist:/app/playlist:ro
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
+22 -78
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types';
import { apiService } from './services/api'; import { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
STRIPE_DECEL_DURATION_MS, STRIPE_DECEL_DURATION_MS,
STRIPE_TILE_SIZE, STRIPE_TILE_SIZE,
@@ -9,13 +10,15 @@ import {
SYNC_SUCCESS_TOTAL_MS, SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS, SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS, TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS TOAST_EXIT_DURATION_MS,
SYNC_BANNER_PADDING_X,
SYNC_BANNER_PADDING_Y,
SYNC_BANNER_MIN_WIDTH,
} from './Config'; } from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
import ServerPanel from './components/ServerPanel'; import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector'; import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal'; import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2 } from 'lucide-react'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react';
interface Toast { interface Toast {
id: number; id: number;
@@ -138,17 +141,8 @@ const App: React.FC = () => {
// Strategy State // Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE); const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Path Mapping State (Includes Simple and Regex Rules) // Regex State
const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({ const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State // Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({ const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
@@ -232,7 +226,7 @@ const App: React.FC = () => {
const result = await apiService.getSettings(); const result = await apiService.getSettings();
if (result.status === 'success') { if (result.status === 'success') {
setCurrentStrategy(result.data.strategy); setCurrentStrategy(result.data.strategy);
setPathMappingConfig(result.data.pathMapping); setRegexReplacements(result.data.regex);
setLocalPath(result.data.localPath || 'playlist'); setLocalPath(result.data.localPath || 'playlist');
setConnectionSettings(result.data.connection); setConnectionSettings(result.data.connection);
} }
@@ -353,14 +347,14 @@ const App: React.FC = () => {
} }
}; };
// Handle Path Mapping Save // Handle Regex Save
const handleSavePathMapping = async (config: PathMappingConfig) => { const handleSaveRegex = async (replacements: RegexReplacement[]) => {
setPathMappingConfig(config); setRegexReplacements(replacements);
const result = await apiService.savePathMapping(config); const result = await apiService.saveRegexRules(replacements);
if (result.status === 'success') { if (result.status === 'success') {
addToast('Path mapping rules have been saved.'); addToast('Regex preprocessing rules have been saved.');
} else { } else {
addToast(result.message || 'Failed to save path mapping rules.'); addToast(result.message || 'Failed to save regex rules.');
} }
}; };
@@ -371,7 +365,7 @@ const App: React.FC = () => {
setSyncState(SyncState.SYNCING); setSyncState(SyncState.SYNCING);
manualSyncInProgress.current = true; manualSyncInProgress.current = true;
const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig, localPath || undefined); const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined);
manualSyncInProgress.current = false; manualSyncInProgress.current = false;
@@ -519,44 +513,6 @@ const App: React.FC = () => {
const scheduleInfo = getScheduleDisplayInfo(); const scheduleInfo = getScheduleDisplayInfo();
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let modeLabel = '';
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
} else {
modeLabel = 'Regex';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: 'Path Mapping',
value: 'Not Set',
active: false,
Icon: Icon
};
}
return {
label: 'Path Mapping',
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
};
};
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
return ( return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black"> <div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -618,7 +574,7 @@ const App: React.FC = () => {
{syncState === SyncState.IDLE ? ( {syncState === SyncState.IDLE ? (
<> <>
{/* Normal Toolbar Left */} {/* Normal Toolbar */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20"> <div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} /> <ArrowLeftRight size={24} strokeWidth={2.5} />
@@ -627,20 +583,9 @@ const App: React.FC = () => {
Plex<span className="text-plex-orange">Sync</span> Plex<span className="text-plex-orange">Sync</span>
</h1> </h1>
</div> </div>
{/* Normal Toolbar Right */} {/* Normal Toolbar Right */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Path Mapping Info */}
<div className="flex flex-col items-end hidden md:flex border-r border-gray-700/50 pr-4 mr-1">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{pathMappingInfo.label}
</span>
<div className={`text-xs font-mono flex items-center gap-1.5 ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{pathMappingInfo.active && <pathMappingInfo.Icon size={12} />}
<span>{pathMappingInfo.value}</span>
</div>
</div>
{/* Schedule Info */} {/* Schedule Info */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex"> <div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider"> <span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
@@ -680,7 +625,6 @@ const App: React.FC = () => {
</div> </div>
</> </>
) : ( ) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div <div
className="bg-black shadow-none rounded-none border-none" className="bg-black shadow-none rounded-none border-none"
@@ -747,8 +691,8 @@ const App: React.FC = () => {
<StrategySelector <StrategySelector
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedPathMapping={pathMappingConfig} savedRegexReplacements={regexReplacements}
onSavePathMapping={handleSavePathMapping} onSaveRegex={handleSaveRegex}
savedSchedule={scheduleSettings} savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule} onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
+152 -320
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from '../types'; import { SyncStrategy, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from '../types';
import { import {
ArrowRightCircle, ArrowRightCircle,
ArrowLeftCircle, ArrowLeftCircle,
@@ -17,9 +17,7 @@ import {
Clock, Clock,
Repeat, Repeat,
CheckSquare, CheckSquare,
Square, Square
Type,
Code2
} from 'lucide-react'; } from 'lucide-react';
interface StrategyOption { interface StrategyOption {
@@ -63,29 +61,6 @@ const STRATEGIES: StrategyOption[] = [
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// Color Theme Variables for Mapping Editors
const MAPPING_THEME = {
// Container Themes
local: {
borderColor: "border-blue-500/20",
bgColor: "bg-blue-900/10"
},
remote: {
borderColor: "border-green-500/20",
bgColor: "bg-green-900/10"
},
simple: {
borderColor: "border-gray-700/50",
bgColor: "bg-gray-900/40"
},
// Input Field Themes
inputs: {
default: "bg-gray-800 border-gray-700 text-gray-200 focus:border-plex-orange placeholder-gray-600",
local: "bg-blue-500/10 border-blue-500/30 text-blue-100 focus:border-blue-400 placeholder-blue-300/30",
cloud: "bg-green-500/10 border-green-500/30 text-green-100 focus:border-green-400 placeholder-green-300/30"
}
};
// Helper to determine the actual mode and settings that would be saved based on the current UI state // Helper to determine the actual mode and settings that would be saved based on the current UI state
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => { const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule }; const derived = { ...schedule };
@@ -105,114 +80,11 @@ const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode):
return derived; return derived;
}; };
// Sub-component for a single Mapping Group Editor
interface MappingGroupEditorProps {
title: string;
subtitle?: string;
rules: ReplacementRule[];
onChange: (newRules: ReplacementRule[]) => void;
isLocked: boolean;
borderColor?: string;
bgColor?: string;
// Input specific props
leftPlaceholder?: string;
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
title,
subtitle,
rules,
onChange,
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftInputClass,
rightInputClass
}) => {
const handleAdd = () => {
if (isLocked) return;
const newId = Date.now().toString() + Math.random().toString();
onChange([...rules, { id: newId, search: '', replace: '' }]);
};
const handleUpdate = (id: string, field: 'search' | 'replace', value: string) => {
if (isLocked) return;
onChange(rules.map(r => r.id === id ? { ...r, [field]: value } : r));
};
const handleDelete = (id: string) => {
if (isLocked) return;
onChange(rules.filter(r => r.id !== id));
};
// Default input style if not provided
const defaultInputStyle = MAPPING_THEME.inputs.default;
return (
<div className={`p-3 rounded-lg border ${borderColor} ${bgColor} flex flex-col h-full transition-colors`}>
<div className="flex items-center justify-between mb-2">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-gray-400">{title}</h4>
{subtitle && <p className="text-[9px] text-gray-500">{subtitle}</p>}
</div>
<button
onClick={handleAdd}
disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
>
<Plus size={12} />
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No rules defined.
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
/>
<ArrowRightCircle size={10} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
/>
<button
onClick={() => handleDelete(rule.id)}
className="text-gray-600 hover:text-red-400 p-1 rounded hover:bg-red-500/10 transition-colors flex-none"
>
<Trash2 size={12} />
</button>
</div>
))
)}
</div>
</div>
);
};
interface StrategySelectorProps { interface StrategySelectorProps {
currentStrategy: SyncStrategy; currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void; onSelect: (strategy: SyncStrategy, label: string) => void;
savedPathMapping: PathMappingConfig; savedRegexReplacements: RegexReplacement[];
onSavePathMapping: (config: PathMappingConfig) => void; onSaveRegex: (replacements: RegexReplacement[]) => void;
savedSchedule: ScheduleSettings; savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>; onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState; syncState: SyncState;
@@ -222,8 +94,8 @@ interface StrategySelectorProps {
const StrategySelector: React.FC<StrategySelectorProps> = ({ const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy, currentStrategy,
onSelect, onSelect,
savedPathMapping, savedRegexReplacements,
onSavePathMapping, onSaveRegex,
savedSchedule, savedSchedule,
onSaveSchedule, onSaveSchedule,
syncState, syncState,
@@ -232,16 +104,17 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for path mapping editing (stores all lists for both modes) // Local state for regex editing
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping); const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
const [isMappingDirty, setIsMappingDirty] = useState(false); const [isRegexDirty, setIsRegexDirty] = useState(false);
// Local state for Schedule editing // Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule); const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false); const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs // UI State for Schedule Tabs
const [activeScheduleTab, setActiveScheduleTab] = useState<ScheduleMode>( // We initialize active tab based on the saved mode. If DISABLED, default to CRON.
const [activeTab, setActiveTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
); );
@@ -250,30 +123,32 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Initialize local state when prop updates // Initialize local state when prop updates
useEffect(() => { useEffect(() => {
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping))); setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
setIsMappingDirty(false); setIsRegexDirty(false);
}, [savedPathMapping]); }, [savedRegexReplacements]);
useEffect(() => { useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule))); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
// If the saved mode is not disabled, ensure we show that tab.
if (savedSchedule.mode !== ScheduleMode.DISABLED) { if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode); setActiveTab(savedSchedule.mode);
} }
setIsScheduleDirty(false); setIsScheduleDirty(false);
}, [savedSchedule]); }, [savedSchedule]);
// Check dirty state whenever local mapping changes // Check dirty state whenever local changes
useEffect(() => { useEffect(() => {
const isDifferent = JSON.stringify(localPathMapping) !== JSON.stringify(savedPathMapping); const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements);
setIsMappingDirty(isDifferent); setIsRegexDirty(isDifferent);
}, [localPathMapping, savedPathMapping]); }, [localReplacements, savedRegexReplacements]);
// Check dirty state for Schedule (including Active Tab changes) // Check dirty state for Schedule (including Active Tab changes)
useEffect(() => { useEffect(() => {
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab); // We calculate what the "effective" schedule would be if we saved right now.
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule); const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent); setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeScheduleTab]); }, [localSchedule, savedSchedule, activeTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0]; const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
@@ -287,70 +162,47 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const isScheduleActionable = isScheduleDirty || (activeScheduleTab !== (savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode)); // Determine if tabs have changed from the saved state
const initialTab = savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode;
const hasTabChanged = activeTab !== initialTab;
const isScheduleActionable = isScheduleDirty || hasTabChanged;
const handleSelect = (strategy: StrategyOption) => { const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return; if (isLocked) return;
onSelect(strategy.value, strategy.label); onSelect(strategy.value, strategy.label);
}; };
// --- Path Mapping Handlers --- // --- Regex Handlers ---
const currentMappingMode = localPathMapping.mode; const handleAddRegex = () => {
const updateRegexGroup = (section: keyof PathMappingRules, newRules: ReplacementRule[]) => {
if (isLocked) return; if (isLocked) return;
setLocalPathMapping(prev => ({ const newId = Date.now().toString();
...prev, setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
regex: {
...prev.regex,
[section]: newRules
}
}));
}; };
const updateSimpleGroup = (newRules: ReplacementRule[]) => { const handleDeleteRegex = (id: string) => {
if (isLocked) return; if (isLocked) return;
setLocalPathMapping(prev => ({ setLocalReplacements(prev => prev.filter(r => r.id !== id));
...prev,
simple: newRules
}));
}; };
const setMappingMode = (mode: PathMappingMode) => { const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
if (isLocked) return; if (isLocked) return;
setLocalPathMapping(prev => ({ ...prev, mode })); setLocalReplacements(prev => prev.map(r =>
r.id === id ? { ...r, [field]: value } : r
));
}; };
const handleResetMapping = () => { const handleResetRegex = () => {
if (isLocked) return; if (isLocked) return;
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping))); setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
}; };
const handleSaveMappingClick = () => { const handleSaveRegex = () => {
if (isLocked) return; if (isLocked) return;
const clean = (rules: ReplacementRule[]) => rules.filter(r => r.search.trim() !== ''); const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
setLocalReplacements(validReplacements);
// Clean regex rules onSaveRegex(validReplacements);
const cleanRegex = (rules: PathMappingRules): PathMappingRules => ({
localPre: clean(rules.localPre),
localPost: clean(rules.localPost),
remotePre: clean(rules.remotePre),
remotePost: clean(rules.remotePost),
});
const cleanedConfig: PathMappingConfig = {
mode: localPathMapping.mode,
simple: clean(localPathMapping.simple),
regex: cleanRegex(localPathMapping.regex),
};
setLocalPathMapping(cleanedConfig);
onSavePathMapping(cleanedConfig);
}; };
const regexRules = localPathMapping.regex;
const simpleRules = localPathMapping.simple;
// --- Schedule Handlers --- // --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => { const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return; if (isLocked) return;
@@ -370,18 +222,24 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
if (isLocked) return; if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule))); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) { if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode); setActiveTab(savedSchedule.mode);
} else { } else {
setActiveScheduleTab(ScheduleMode.CRON); setActiveTab(ScheduleMode.CRON);
} }
}; };
const handleSaveScheduleClick = async () => { const handleSaveScheduleClick = async () => {
if (isLocked) return; if (isLocked) return;
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
// Determine the effective settings based on the current view (tab) and inputs
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeTab);
// Call API
const success = await onSaveSchedule(settingsToSave); const success = await onSaveSchedule(settingsToSave);
if (success) { if (success) {
setLocalSchedule(settingsToSave); setLocalSchedule(settingsToSave);
// Dirty state is cleared by the useEffect prop update, or we can clear it optimistically here if needed,
// but useEffect [savedSchedule] handles it correctly.
} }
}; };
@@ -390,6 +248,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSync(); onSync();
}; };
// Helper to toggle enable/disable for current active tab (Daily/Weekly)
const toggleScheduleEnable = (targetMode: ScheduleMode) => { const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return; if (isLocked) return;
if (localSchedule.mode === targetMode) { if (localSchedule.mode === targetMode) {
@@ -399,6 +258,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
} }
}; };
// If syncing or locked, apply grayscale filter to content sections
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all"; const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return ( return (
@@ -419,13 +279,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div <div
className={`absolute className={`absolute
top-14 top-14
/* Mobile: Open to left (max width of screen) */ /* Mobile: Open to left */
right-0 w-[90vw] max-w-[90vw] origin-top-right right-0 origin-top-right
/* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
/* Desktop: Center alignment, wider */ w-80 md:w-[32rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2 md:w-[60rem] md:max-w-[60rem]
bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`} ${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
> >
@@ -469,123 +328,96 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
</div> </div>
{/* Section 2: Path Mapping (Tabs + Grid) */} {/* Section 2: Regex Preprocessing */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Path Mapping</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
</div> {localReplacements.length === 0 && (
<button
{/* Tabs for Path Mapping Mode */} onClick={handleAddRegex}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4"> className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
{[ title="Add Rule"
{ id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type }, >
{ id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 }, <Plus size={14} />
].map((tab) => ( </button>
<button
key={tab.id}
onClick={() => setMappingMode(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${currentMappingMode === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className="mb-4">
{currentMappingMode === PathMappingMode.SIMPLE ? (
// Simple Mode: Single Editor
<div className="animate-in fade-in duration-200">
<MappingGroupEditor
title="Path Mapping"
subtitle="Map Local paths to Cloud paths using simple string matching"
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path"
rightPlaceholder="Cloud Path"
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
/>
</div>
) : (
// Regex Mode: 2x2 Grid
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Pre-Processing (Before Sync)"
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Pre-Processing (Before Sync)"
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Post-Processing (After Sync / Result)"
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Post-Processing (After Sync / Result)"
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
</div>
)} )}
</div> </div>
<div className="flex justify-end items-center gap-2"> <div className="space-y-2 mb-4 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
<button {localReplacements.length === 0 ? (
onClick={handleResetMapping} <div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
disabled={!isMappingDirty} No regex replacements configured.
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all </div>
${isMappingDirty ) : (
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white' localReplacements.map((regex) => (
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`} <div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
> <div className="flex-1 min-w-0">
<RotateCcw size={12} /> <input
<span>Revert</span> type="text"
</button> placeholder="Pattern"
<button value={regex.pattern}
onClick={handleSaveMappingClick} onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
disabled={!isMappingDirty} className={`w-full bg-gray-900/80 border rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all ${!regex.pattern && isRegexDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
${isMappingDirty />
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10' </div>
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`} <div className="flex-none text-gray-600">
> <ArrowRightCircle size={12} />
<Save size={12} /> </div>
<span>Save Rules</span> <div className="flex-1 min-w-0">
<input
type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
<div className="flex justify-between items-center gap-2">
<button
onClick={handleAddRegex}
className={`flex items-center space-x-1 px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide transition-colors ${localReplacements.length > 0 ? 'text-plex-orange hover:bg-plex-orange/10' : 'hidden'}`}
>
<Plus size={10} />
<span>Add</span>
</button> </button>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleResetRegex}
disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isRegexDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
</button>
<button
onClick={handleSaveRegex}
disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isRegexDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
</button>
</div>
</div> </div>
</div> </div>
@@ -604,9 +436,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveScheduleTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${activeScheduleTab === tab.id ${activeTab === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm' ? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5' : 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`} }`}
@@ -619,7 +451,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Tab Content */} {/* Tab Content */}
<div className="mb-4 min-h-[50px]"> <div className="mb-4 min-h-[50px]">
{activeScheduleTab === ScheduleMode.CRON && ( {activeTab === ScheduleMode.CRON && (
<div className="space-y-2 animate-in fade-in duration-200"> <div className="space-y-2 animate-in fade-in duration-200">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span> <span className="text-gray-500 font-mono text-xs">Cron:</span>
@@ -637,7 +469,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
)} )}
{activeScheduleTab === ScheduleMode.DAILY && ( {activeTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */} {/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2"> <div className="flex items-center justify-start space-x-2 mb-2">
@@ -664,7 +496,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
)} )}
{activeScheduleTab === ScheduleMode.WEEKLY && ( {activeTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */} {/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2"> <div className="flex items-center justify-start space-x-2 mb-2">
@@ -731,7 +563,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</button> </button>
</div> </div>
{/* Action Buttons */} {/* Action Buttons (Mirrored from Regex) */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5"> <div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button <button
onClick={handleResetSchedule} onClick={handleResetSchedule}
@@ -767,7 +599,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked ${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50' ? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isMappingDirty : isRegexDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' ? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]' : 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`} }`}
@@ -784,9 +616,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</> </>
)} )}
</button> </button>
{(isMappingDirty) && ( {(isRegexDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2"> <p className="text-[10px] text-plex-orange text-center mt-2">
Please save path mapping changes before syncing. Please save regex changes before syncing.
</p> </p>
)} )}
</div> </div>
+13 -61
View File
@@ -1,4 +1,4 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings } from '../types'; import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy, ScheduleSettings } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
@@ -41,69 +41,21 @@ const mapLibrary = (item: any): PlexLibrary => ({
type: item.type ?? 'artist', type: item.type ?? 'artist',
}); });
// Helper function to map raw rules array to ReplacementRule[] const mapRegexRules = (rules: any[]): RegexReplacement[] =>
const mapReplacementRules = (rules: any[]): ReplacementRule[] =>
(rules || []).map((rule, index) => ({ (rules || []).map((rule, index) => ({
id: rule.id || `${rule.search || 'rule'}-${index}-${Date.now()}`, id: rule.id || `${rule.pattern || 'rule'}-${index}`,
search: rule.search || rule.pattern || '', pattern: rule.pattern || '',
replace: rule.replace || rule.replacement || '', replacement: rule.replacement || '',
})); }));
// Helper function to map API path_mapping response to PathMappingConfig
const mapPathMappingConfig = (data: any): PathMappingConfig => {
const defaultConfig: PathMappingConfig = {
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
};
if (!data || !data.path_mapping) {
return defaultConfig;
}
const pm = data.path_mapping;
return {
mode: pm.mode === 'REGEX' ? PathMappingMode.REGEX : PathMappingMode.SIMPLE,
simple: mapReplacementRules(pm.simple || []),
regex: {
localPre: mapReplacementRules(pm.regex?.localPre || pm.regex?.local_pre || []),
localPost: mapReplacementRules(pm.regex?.localPost || pm.regex?.local_post || []),
remotePre: mapReplacementRules(pm.regex?.remotePre || pm.regex?.remote_pre || []),
remotePost: mapReplacementRules(pm.regex?.remotePost || pm.regex?.remote_post || [])
}
};
};
// Helper function to convert PathMappingConfig to API format
const pathMappingToApi = (config: PathMappingConfig) => {
const rulesToApi = (rules: ReplacementRule[]) =>
rules.map(({ search, replace }) => ({ search, replace }));
return {
mode: config.mode,
simple: rulesToApi(config.simple),
regex: {
local_pre: rulesToApi(config.regex.localPre),
local_post: rulesToApi(config.regex.localPost),
remote_pre: rulesToApi(config.regex.remotePre),
remote_post: rulesToApi(config.regex.remotePost)
}
};
};
export const apiService = { export const apiService = {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>> { async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>> {
const response = await fetch(`${API_BASE}/api/settings`); const response = await fetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
const mode = result.data.sync_mode as string; const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE; const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const pathMapping = mapPathMappingConfig(result.data); const regex = mapRegexRules(result.data.path_rules || []);
const connection: PlexConnectionSettings = { const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https', protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '', address: result.data.server_url || '',
@@ -111,9 +63,9 @@ export const apiService = {
token: result.data.token || '', token: result.data.token || '',
libraryName: result.data.library_name || '', libraryName: result.data.library_name || '',
}; };
return { status: 'success', data: { strategy, pathMapping, connection, localPath: result.data.local_path || '' } }; return { status: 'success', data: { strategy, regex, connection, localPath: result.data.local_path || '' } };
} }
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>; return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>;
}, },
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> { async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
@@ -126,9 +78,9 @@ export const apiService = {
return handleResponse(response); return handleResponse(response);
}, },
async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> { async saveRegexRules(replacements: RegexReplacement[]): Promise<ApiResponse<{ rules: RegexReplacement[] }>> {
const payload = pathMappingToApi(config); const payload = { rules: replacements.map(({ pattern, replacement }) => ({ pattern, replacement })) };
const response = await fetch(`${API_BASE}/api/settings/path-mapping`, { const response = await fetch(`${API_BASE}/api/settings/regex-rules`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -218,7 +170,7 @@ export const apiService = {
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>; return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
}, },
async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> { async syncPlaylists(strategy: SyncStrategy, _regexRules: RegexReplacement[], localPath?: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/sync`, { const response = await fetch(`${API_BASE}/api/sync`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
-24
View File
@@ -34,30 +34,6 @@ export enum SyncState {
ERROR = 'ERROR' ERROR = 'ERROR'
} }
export interface ReplacementRule {
id: string;
search: string;
replace: string;
}
export interface PathMappingRules {
localPre: ReplacementRule[];
localPost: ReplacementRule[];
remotePre: ReplacementRule[];
remotePost: ReplacementRule[];
}
export enum PathMappingMode {
SIMPLE = 'SIMPLE',
REGEX = 'REGEX'
}
export interface PathMappingConfig {
mode: PathMappingMode;
simple: ReplacementRule[];
regex: PathMappingRules;
}
export interface RegexReplacement { export interface RegexReplacement {
id: string; id: string;
pattern: string; pattern: string;
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 1 - Local playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\ピンポン - ED - 僕らについて\01. “あのヒーローと” 僕らについて - ピンポン - ED - 僕らについて.mp3
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 2 - Local playlist
# A comment that should be ignored
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ダンジョン飯\Party!! (期間生産限定盤)\01. Party!! - Party!! (期間生産限定盤).flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 3 - Local playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 4 - Local playlist
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
+5
View File
@@ -0,0 +1,5 @@
#EXTM3U
/mnt/music/Anime/CITY THE ANIMATION/Hello/01. Hello - Hello.flac
\\koha9-nas\koha9-nas\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\MUSIC\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\音ゲー\USAO\MEMORIES\11. Echoes - MEMORIES.flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 1 - Base playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 2 - Base playlist
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\リテラチュア\01. リテラチュア - リテラチュア.flac
N:\Music\Anime\ダンジョン飯\Party!! (期間生産限定盤)\01. Party!! - Party!! (期間生産限定盤).flac
+5
View File
@@ -0,0 +1,5 @@
#EXTM3U
# Case 3 - Base playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 4 - Base playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
+4
View File
@@ -0,0 +1,4 @@
#EXTM3U
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\音ゲー\USAO\MEMORIES\15. Empty Room (Srav3R Remix) - MEMORIES.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 1 - Local playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\ピンポン - ED - 僕らについて\01. “あのヒーローと” 僕らについて - ピンポン - ED - 僕らについて.mp3
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 2 - Local playlist
# A comment that should be ignored
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ダンジョン飯\Party!! (期間生産限定盤)\01. Party!! - Party!! (期間生産限定盤).flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 3 - Local playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 4 - Local playlist
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
+5
View File
@@ -0,0 +1,5 @@
#EXTM3U
/mnt/music/Anime/CITY THE ANIMATION/Hello/01. Hello - Hello.flac
\\koha9-nas\koha9-nas\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\MUSIC\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\音ゲー\USAO\MEMORIES\11. Echoes - MEMORIES.flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 1 - Remote playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\ピンポン - ED - 僕らについて\02. “あの夜明け前の” 僕らについて - ピンポン - ED - 僕らについて.mp3
+5
View File
@@ -0,0 +1,5 @@
#EXTM3U
# Case 2 - Remote playlist
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\リテラチュア\01. リテラチュア - リテラチュア.flac
N:\Music\Anime\ダンジョン飯\Party!! (期間生産限定盤)\01. Party!! - Party!! (期間生産限定盤).flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 3 - Remote playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 4 - Remote playlist
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
N:\Music\音ゲー\USAO\MEMORIES\15. Empty Room (Srav3R Remix) - MEMORIES.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\04. Theme of New PANTY & STOCKING (Long Version) - Theme of New PANTY & STOCKING.flac
+8
View File
@@ -0,0 +1,8 @@
#EXTM3U
# Case 1 - Expected merged result (merge_local_primary)
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\ピンポン - ED - 僕らについて\01. “あのヒーローと” 僕らについて - ピンポン - ED - 僕らについて.mp3
N:\Music\Anime\ピンポン - ED - 僕らについて\02. “あの夜明け前の” 僕らについて - ピンポン - ED - 僕らについて.mp3
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 1 - Expected merged result (merge_remote_primary)
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\ピンポン - ED - 僕らについて\02. “あの夜明け前の” 僕らについて - ピンポン - ED - 僕らについて.mp3
N:\Music\Anime\ピンポン - ED - 僕らについて\01. “あのヒーローと” 僕らについて - ピンポン - ED - 僕らについて.mp3
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 2 - Expected merged result (merge_local_primary)
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ダンジョン飯\Party!! (期間生産限定盤)\01. Party!! - Party!! (期間生産限定盤).flac
+5
View File
@@ -0,0 +1,5 @@
#EXTM3U
# Case 2 - Expected merged result (merge_remote_primary)
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\リテラチュア\01. リテラチュア - リテラチュア.flac
N:\Music\Anime\ダンジョン飯\Party!! (期間生産限定盤)\01. Party!! - Party!! (期間生産限定盤).flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 3 - Expected merged result (merge_local_primary)
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
# Case 3 - Expected merged result (merge_remote_primary)
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 4 - Expected merged result (merge_local_primary)
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
+7
View File
@@ -0,0 +1,7 @@
#EXTM3U
# Case 4 - Expected merged result (merge_remote_primary)
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\Anime\キルラキル\新世界交響楽\01. 新世界交響楽 - 新世界交響楽.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\ゆるキャン△ SEASON2\Seize The Day\01. Seize The Day - Seize The Day.flac
N:\Music\Anime\ゆるキャン△ SEASON2\はるのとなり\01. はるのとなり - はるのとなり.flac
+5
View File
@@ -0,0 +1,5 @@
#EXTM3U
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\音ゲー\USAO\MEMORIES\11. Echoes - MEMORIES.flac
@@ -0,0 +1,7 @@
#EXTM3U
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\音ゲー\USAO\MEMORIES\11. Echoes - MEMORIES.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\04. Theme of New PANTY & STOCKING (Long Version) - Theme of New PANTY & STOCKING.flac
@@ -0,0 +1,8 @@
#EXTM3U
N:\Music\音ゲー\USAO\MEMORIES\15. Empty Room (Srav3R Remix) - MEMORIES.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\04. Theme of New PANTY & STOCKING (Long Version) - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\02. ちゃんと - Hello.flac
N:\Music\音ゲー\USAO\MEMORIES\11. Echoes - MEMORIES.flac
+6
View File
@@ -0,0 +1,6 @@
#EXTM3U
N:\Music\音ゲー\USAO\MEMORIES\15. Empty Room (Srav3R Remix) - MEMORIES.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\Music\Anime\CITY THE ANIMATION\Hello\01. Hello - Hello.flac
N:\Music\Anime\少女終末旅行\動く、動く\01. 動く、動く - 動く、動く.flac
N:\Music\Anime\New PANTY & STOCKING with GARTERBELT\Theme of New PANTY & STOCKING\04. Theme of New PANTY & STOCKING (Long Version) - Theme of New PANTY & STOCKING.flac
+128
View File
@@ -0,0 +1,128 @@
# 🎯 正则路径替换测试 - 快速参考
## ✅ 测试状态
```
✅ 59/59 测试通过
⚡ 执行时间: 0.56s
📦 包含: 13 个合并测试 + 46 个正则测试
```
## 🚀 快速运行
```bash
# 运行所有测试
pytest tests/
# 只运行正则测试
pytest tests/test_regex_path_replacement.py -v
# 运行特定测试类
pytest tests/test_regex_path_replacement.py::TestApplyRegexRulesToPaths -v
# 查看覆盖率
pytest tests/test_regex_path_replacement.py --cov=app.utils.playlist_merge
```
## 📋 测试分类
| 测试类 | 数量 | 说明 |
|--------|------|------|
| TestCompileRegexRules | 7 | 正则编译和验证 |
| TestApplyCompiledRulesToPaths | 5 | 应用已编译规则 |
| TestApplyRegexRulesToPaths | 17 | 完整替换流程 |
| TestPreprocessPlaylistText | 7 | 播放列表预处理 |
| TestEdgeCases | 9 | 边界情况处理 |
| TestPerformance | 3 | 性能测试 |
## 💡 常用示例
### 简单替换
```python
rules = [{"pattern": r"/old/", "replacement": "/new/"}]
"/old/music/track.mp3" → "/new/music/track.mp3"
```
### Windows 路径
```python
rules = [{"pattern": r"C:\\Music", "replacement": r"D:\\Audio"}]
r"C:\Music\track.mp3" → r"D:\Audio\track.mp3"
```
### 捕获组
```python
rules = [{"pattern": r"/(\d+)/", "replacement": r"/year-\1/"}]
"/music/2024/track.mp3" → "/music/year-2024/track.mp3"
```
### NAS 路径转换(真实场景)
```python
rules = [
{"pattern": r"\\\\nas\\Music", "replacement": r"N:\\Music"},
{"pattern": r"\\", "replacement": "/"},
]
r"\\nas\Music\Album\track.mp3" → "N:/Music/Album/track.mp3"
```
## 🎨 测试覆盖的场景
### ✅ 路径类型
- Linux 路径 `/path/to/file`
- Windows 路径 `C:\path\to\file`
- UNC 路径 `\\server\share\file`
- 相对路径 `../path/./file`
- URL 编码 `/artist%20name/track.mp3`
- Unicode `/音乐/歌曲.mp3`
### ✅ 正则特性
- 简单匹配 `foo`
- 特殊字符 `\(\d+\)`
- 捕获组 `(pattern)``\1`
- 不区分大小写 `(?i)pattern`
- 字符类 `[A-Z]+` `\d+` `\w+`
### ✅ 边界情况
- 空输入(规则/路径)
- 无效正则表达式
- 超长路径 (1000+ 字符)
- 特殊字符 `[]()&#`
- 链式替换
### ✅ 性能测试
- 10,000 首歌曲的播放列表
- 5+ 条规则链式执行
- 复杂正则模式匹配
## 📖 相关文档
- 详细总结: `tests/REGEX_TESTS_SUMMARY.md`
- 测试文件: `tests/test_regex_path_replacement.py`
- 被测代码: `app/utils/playlist_merge.py`
## 🔍 调试技巧
```bash
# 显示详细输出
pytest tests/test_regex_path_replacement.py -v -s
# 遇到第一个失败就停止
pytest tests/test_regex_path_replacement.py -x
# 进入调试器
pytest tests/test_regex_path_replacement.py --pdb
# 只运行失败的测试
pytest tests/test_regex_path_replacement.py --lf
```
## 🎓 测试即文档
每个测试都是一个使用示例,查看测试代码了解如何使用正则替换功能!
```python
# 示例: 查看如何使用捕获组
def test_capture_group_replacement():
paths = ["/music/2024/album/track.mp3"]
rules = [{"pattern": r"/music/(\d+)/", "replacement": r"/archive/\1/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/archive/2024/album/track.mp3"]
```
+283
View File
@@ -0,0 +1,283 @@
# 正则路径替换功能测试总结
## 📊 测试统计
- **总测试数**: 46 个
- **测试状态**: ✅ 全部通过
- **执行时间**: ~0.4 秒
- **覆盖范围**: 正则编译、路径替换、预处理、边界情况、性能测试
## 🎯 测试文件
`tests/test_regex_path_replacement.py` - 正则路径替换功能的全面测试套件
## 📝 测试分类
### 1. TestCompileRegexRules (7 个测试)
测试正则规则编译功能
- ✅ `test_compile_simple_pattern` - 简单正则模式编译
- ✅ `test_compile_multiple_patterns` - 多个正则模式编译
- ✅ `test_compile_empty_pattern_skipped` - 跳过空模式
- ✅ `test_compile_missing_pattern_skipped` - 跳过缺失模式
- ✅ `test_compile_invalid_regex_skipped` - 跳过无效正则表达式
- ✅ `test_compile_empty_replacement` - 空替换字符串
- ✅ `test_compile_missing_replacement` - 缺失替换字符串(默认为空)
**关键测试点**:
- 编译过程容错性(跳过无效规则)
- 边界情况处理(空/缺失值)
### 2. TestApplyCompiledRulesToPaths (5 个测试)
测试应用已编译的正则规则到路径
- ✅ `test_apply_single_rule` - 应用单个规则
- ✅ `test_apply_multiple_rules_in_order` - 按顺序应用多个规则
- ✅ `test_apply_no_rules` - 没有规则时返回原路径
- ✅ `test_apply_no_match` - 规则不匹配时保持原路径
- ✅ `test_apply_partial_match` - 部分路径匹配
**关键测试点**:
- 规则顺序执行
- 链式替换(第一个规则的输出作为第二个规则的输入)
- 部分匹配处理
### 3. TestApplyRegexRulesToPaths (17 个测试)
测试完整的路径正则替换流程
#### 基础替换
- ✅ `test_simple_replacement` - 简单字符串替换
- ✅ `test_windows_path_replacement` - Windows 路径替换
- ✅ `test_unc_path_replacement` - UNC 网络路径替换
#### 高级正则功能
- ✅ `test_case_sensitive_replacement` - 大小写敏感替换
- ✅ `test_case_insensitive_replacement` - 大小写不敏感替换(`(?i)` 标志)
- ✅ `test_regex_special_characters` - 正则特殊字符处理
- ✅ `test_capture_group_replacement` - 捕获组替换 `\1`
- ✅ `test_multiple_capture_groups` - 多个捕获组交换位置
#### 实用场景
- ✅ `test_delete_pattern` - 删除匹配内容(替换为空)
- ✅ `test_multiple_matches_in_path` - 路径中多次匹配
- ✅ `test_chained_replacements` - 链式替换(NAS 路径转换)
- ✅ `test_url_encoding_path` - URL 编码路径处理
- ✅ `test_unicode_path` - Unicode 路径支持
#### 边界情况
- ✅ `test_empty_rules_list` - 空规则列表
- ✅ `test_empty_paths_list` - 空路径列表
**关键测试点**:
- 各种路径格式(Windows、Linux、UNC、URL 编码)
- 正则高级特性(捕获组、标志)
- 国际化支持(Unicode
### 4. TestPreprocessPlaylistText (7 个测试)
测试预处理播放列表文本(含正则替换)
- ✅ `test_preprocess_with_replacements` - 带替换的预处理
- ✅ `test_preprocess_removes_comments` - 移除注释
- ✅ `test_preprocess_empty_text` - 空文本处理
- ✅ `test_preprocess_with_blank_lines` - 处理空行
- ✅ `test_preprocess_real_world_scenario` - **真实场景:NAS 路径转换**
- ✅ `test_preprocess_with_compiled_rules` - 使用预编译规则
- ✅ `test_preprocess_preserves_order` - 保持顺序
**关键测试点**:
- 完整的播放列表处理流程
- 注释和空行过滤
- 真实使用场景验证
**真实场景示例**:
```python
# 输入
\\koha9-nas\koha9-nas\Music\Rock\track.flac
/music/cache/temp.flac
# 规则
1. \\koha9-nas\koha9-nas\Music → N:\Music
2. /music/cache/ → /data/music/
3. \ → /
# 输出
N:/Music/Rock/track.flac
/data/music/temp.flac
```
### 5. TestEdgeCases (9 个测试)
测试边界情况和异常场景
- ✅ `test_very_long_path` - 超长路径(1000+ 字符)
- ✅ `test_special_characters_in_path` - 特殊字符 `[]()&#`
- ✅ `test_dot_in_path` - 相对路径符号 `../` `./`
- ✅ `test_trailing_slash` - 尾部斜杠处理
- ✅ `test_duplicate_slashes` - 重复斜杠 `//` `///`
- ✅ `test_mixed_path_separators` - 混合路径分隔符 `\` `/`
- ✅ `test_regex_metacharacters_in_replacement` - 替换字符串中的元字符
- ✅ `test_empty_string_replacement` - 替换为空字符串
- ✅ `test_replacement_creates_invalid_path` - 可能产生无效路径
**关键测试点**:
- 极端输入处理
- 路径规范化场景
- 错误容忍性
### 6. TestPerformance (3 个测试)
测试性能相关场景
- ✅ `test_large_playlist` - 大型播放列表(10,000 首歌曲)
- ✅ `test_many_rules` - 大量规则(5+ 个规则链式执行)
- ✅ `test_complex_regex_pattern` - 复杂正则表达式
**性能示例**:
```python
# 复杂正则模式
Pattern: /music/(.+?) - (.+?) \((\d+)\) \[([^\]]+)\]/
Input: /music/Artist - Album (2024) [FLAC]/01. Track.flac
Output: /library/FLAC/2024/Artist/Album/01. Track.flac
```
**关键测试点**:
- 大数据量处理能力
- 复杂模式匹配性能
- 规则链执行效率
## 🔍 覆盖的功能点
### 核心功能
- ✅ 正则规则编译和验证
- ✅ 规则按顺序应用到路径
- ✅ 播放列表文本预处理
- ✅ 捕获组和反向引用
- ✅ 大小写敏感/不敏感匹配
### 路径类型
- ✅ Linux/Unix 绝对路径 `/path/to/file`
- ✅ Windows 绝对路径 `C:\path\to\file`
- ✅ UNC 网络路径 `\\server\share\file`
- ✅ 相对路径 `../path/./file`
- ✅ URL 编码路径 `/artist%20name/track.mp3`
- ✅ Unicode 路径 `/音乐/专辑/歌曲.mp3`
### 正则特性
- ✅ 简单字符串匹配
- ✅ 特殊字符转义 `()[].*+?`
- ✅ 捕获组 `(pattern)` 和引用 `\1`
- ✅ 不区分大小写 `(?i)`
- ✅ 量词 `*+?{n}`
- ✅ 字符类 `[^/]+` `\d+` `\w+`
### 边界情况
- ✅ 空输入(规则/路径)
- ✅ 无效正则表达式
- ✅ 不匹配的规则
- ✅ 超长路径
- ✅ 特殊字符
- ✅ 链式替换
### 容错性
- ✅ 跳过空模式
- ✅ 跳过无效正则
- ✅ 默认替换为空字符串
- ✅ 保留不匹配的路径
## 🎓 测试用例示例
### 基础替换
```python
paths = ["/old/path/file.mp3"]
rules = [{"pattern": r"/old/", "replacement": "/new/"}]
# 结果: ["/new/path/file.mp3"]
```
### 捕获组替换
```python
paths = ["/music/2024/album/track.mp3"]
rules = [{"pattern": r"/music/(\d+)/", "replacement": r"/archive/\1/"}]
# 结果: ["/archive/2024/album/track.mp3"]
```
### 链式替换(真实场景)
```python
paths = [r"\\nas\Music\Album\track.mp3"]
rules = [
{"pattern": r"\\\\nas\\Music", "replacement": "/mnt/music"},
{"pattern": r"\\", "replacement": "/"},
]
# 结果: ["/mnt/music/Album/track.mp3"]
```
### 复杂模式匹配
```python
paths = ["/music/Artist - Album (2024) [FLAC]/01. Track.flac"]
rules = [
{
"pattern": r"/music/(.+?) - (.+?) \((\d+)\) \[([^\]]+)\]/",
"replacement": r"/library/\4/\3/\1/\2/"
}
]
# 结果: ["/library/FLAC/2024/Artist/Album/01. Track.flac"]
```
## 🚀 运行测试
### 运行正则替换测试
```bash
pytest tests/test_regex_path_replacement.py -v
```
### 运行特定测试类
```bash
pytest tests/test_regex_path_replacement.py::TestApplyRegexRulesToPaths -v
```
### 运行特定测试
```bash
pytest tests/test_regex_path_replacement.py::TestPreprocessPlaylistText::test_preprocess_real_world_scenario -v
```
### 查看测试覆盖率
```bash
pytest tests/test_regex_path_replacement.py --cov=app.utils.playlist_merge --cov-report=term
```
## 💡 测试最佳实践
本测试套件遵循的最佳实践:
1. **分类清晰** - 按功能层级组织测试类
2. **命名规范** - 测试名称清楚描述测试内容
3. **独立性** - 每个测试独立运行,无依赖
4. **覆盖全面** - 正常流程、边界情况、错误处理全覆盖
5. **文档化** - 每个测试都有描述性文档字符串
6. **真实场景** - 包含实际使用场景的测试用例
7. **性能考虑** - 包含大数据量和复杂模式的性能测试
## 📈 测试价值
这套测试为正则路径替换功能提供了:
- **信心保证** - 46 个测试覆盖各种场景
- **回归防护** - 修改代码时快速验证功能完整性
- **文档作用** - 测试即使用示例和功能文档
- **重构支持** - 安全重构代码而不破坏功能
- **Bug 预防** - 边界情况测试防止潜在 Bug
## 🔧 维护建议
1. **添加新功能时**同步添加测试
2. **发现 Bug 时**先写失败测试,再修复
3. **定期运行**完整测试套件
4. **保持测试更新**与代码变更同步
5. **关注覆盖率**保持 80% 以上
## 相关文件
- 测试文件: `tests/test_regex_path_replacement.py`
- 被测代码: `app/utils/playlist_merge.py`
- 相关函数:
- `_compile_regex_rules()`
- `_apply_compiled_rules_to_paths()`
- `apply_regex_rules_to_paths()`
- `preprocess_playlist_text()`
+172
View File
@@ -0,0 +1,172 @@
# 🎭 UI 集成测试快速开始
## 📦 安装
```bash
# 1. 安装 Python 依赖
pip install -r requirements.txt
# 2. 安装 Playwright 浏览器
playwright install chromium
# 或安装所有浏览器
playwright install
```
## 🚀 运行测试
### 启动服务器
```bash
# 终端 1: 启动应用
uvicorn app.main:app --reload --port 8000
```
### 运行 UI 测试
```bash
# 终端 2: 运行测试
# 无头模式(后台运行,快速)
pytest tests/test_ui_regex_rules.py -v
# 有头模式(显示浏览器,便于调试)
pytest tests/test_ui_regex_rules.py -v --headed
# 慢速模式(每个操作间隔 500ms,方便观察)
pytest tests/test_ui_regex_rules.py -v --headed --slowmo=500
```
## 📊 测试内容
### ✅ 基础交互 (8 个测试)
- 页面加载
- 添加/删除规则
- 保存规则
- 规则持久化
- 表单验证
- 规则顺序
### ✅ 复杂场景 (2 个测试)
- Windows → Linux 路径转换
- NAS 路径规范化
### ✅ 性能测试 (1 个测试)
- 添加 20 个规则性能
## 🎯 快速示例
```bash
# 运行单个测试(有头模式,便于观察)
pytest tests/test_ui_regex_rules.py::TestRegexRulesUI::test_add_single_rule -v --headed
# 运行 NAS 场景测试
pytest tests/test_ui_regex_rules.py::TestComplexScenarios::test_nas_path_normalization -v --headed
# 调试模式(带 Playwright Inspector
PWDEBUG=1 pytest tests/test_ui_regex_rules.py::TestRegexRulesUI::test_add_single_rule
```
## 🐛 调试技巧
### 1. 截图调试
测试失败时会自动保存截图到 `tests/screenshots/`
### 2. 慢速观察
```bash
pytest tests/test_ui_regex_rules.py --headed --slowmo=1000 -v
```
### 3. 交互式调试
```bash
PWDEBUG=1 pytest tests/test_ui_regex_rules.py -k test_add_single_rule
```
### 4. 查看追踪
```python
# 在测试中添加
context.tracing.start(screenshots=True, snapshots=True)
# ... 测试代码 ...
context.tracing.stop(path="trace.zip")
```
然后查看:
```bash
playwright show-trace trace.zip
```
## 📝 编写新测试
```python
def test_my_feature(page: Page):
"""测试我的功能"""
# 1. 与元素交互
page.locator("#myButton").click()
# 2. 填写表单
page.locator("input[name='pattern']").fill("test")
# 3. 验证结果
expect(page.locator("#result")).to_have_text("Success")
```
## 🎨 选择器参考
```python
# 通过 ID
page.locator("#addRuleBtn")
# 通过文本
page.locator("button:has-text('保存规则')")
# 通过 CSS 类
page.locator(".rule-row")
# 通过属性
page.locator("input[name='pattern']")
# 组合选择器
page.locator(".rule-row input[name='pattern']")
# 获取第一个/最后一个
page.locator(".rule-row").first
page.locator(".rule-row").last
# 获取第 N 个
page.locator(".rule-row").nth(2)
```
## ⚡ 常用命令
```bash
# 运行所有 UI 测试
pytest tests/test_ui_regex_rules.py -v
# 运行特定测试类
pytest tests/test_ui_regex_rules.py::TestRegexRulesUI -v
# 运行标记为 slow 的测试
pytest tests/test_ui_regex_rules.py -m slow -v
# 跳过 slow 测试
pytest tests/test_ui_regex_rules.py -m "not slow" -v
# 失败时进入调试器
pytest tests/test_ui_regex_rules.py --pdb
# 只运行失败的测试
pytest tests/test_ui_regex_rules.py --lf -v
```
## 📚 相关文档
- 详细指南: `tests/UI_TESTING_GUIDE.md`
- Playwright 文档: https://playwright.dev/python/
- pytest-playwright: https://github.com/microsoft/playwright-pytest
## 🎉 开始测试
```bash
# 一行命令开始
uvicorn app.main:app --port 8000 & \
sleep 3 && \
pytest tests/test_ui_regex_rules.py -v --headed
```
+356
View File
@@ -0,0 +1,356 @@
# UI 集成测试指南
## 📋 概述
UI 集成测试使用 **Playwright** 框架来测试正则路径替换功能的用户界面交互。
## 🚀 快速开始
### 1. 安装依赖
```bash
# 安装 Playwright 和浏览器驱动
pip install pytest-playwright
playwright install chromium
# 或安装所有浏览器
playwright install
```
### 2. 启动应用服务器
在运行 UI 测试前,需要先启动应用:
```bash
# 方式 1: 直接运行
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 方式 2: 使用 Docker
docker compose up
```
### 3. 运行 UI 测试
```bash
# 无头模式(不显示浏览器)
pytest tests/test_ui_regex_rules.py -v
# 有头模式(显示浏览器,便于调试)
pytest tests/test_ui_regex_rules.py -v --headed
# 慢速模式(方便观察)
pytest tests/test_ui_regex_rules.py -v --headed --slowmo=500
# 运行特定测试
pytest tests/test_ui_regex_rules.py::TestRegexRulesUI::test_add_single_rule -v --headed
```
## 📊 测试覆盖
### 基础 UI 交互测试 (TestRegexRulesUI)
| 测试 | 描述 |
|------|------|
| `test_page_loads_successfully` | 页面成功加载 |
| `test_add_single_rule` | 添加单个规则 |
| `test_add_multiple_rules` | 添加多个规则 |
| `test_remove_rule` | 删除规则 |
| `test_save_rules` | 保存规则 |
| `test_rules_persist_after_save` | 规则持久化验证 |
| `test_empty_pattern_validation` | 空模式验证 |
| `test_rule_order_preserved` | 规则顺序保持 |
### 复杂场景测试 (TestComplexScenarios)
| 测试 | 描述 |
|------|------|
| `test_windows_to_linux_path_conversion` | Windows → Linux 路径转换 |
| `test_nas_path_normalization` | NAS 路径规范化 |
### 性能测试 (TestPerformance)
| 测试 | 描述 |
|------|------|
| `test_add_many_rules_performance` | 添加大量规则性能 |
## 🎯 测试场景示例
### 场景 1: 添加单个规则
```python
# 1. 点击"添加规则"按钮
# 2. 填写正则表达式: /old/path/
# 3. 填写替换文本: /new/path/
# 4. 验证输入框内容正确
```
### 场景 2: NAS 路径规范化
```python
# 添加三条规则:
# 1. \\koha9-nas\koha9-nas\Music → N:\Music
# 2. /music/cache/ → /data/music/
# 3. \ → /
#
# 保存并验证规则持久化
```
### 场景 3: 规则持久化验证
```python
# 1. 添加规则
# 2. 保存
# 3. 刷新页面
# 4. 验证规则仍然存在
```
## 🔧 配置选项
### pytest.ini 配置
```ini
[pytest]
# Playwright 配置
addopts =
--browser=chromium
--headed
--slowmo=100
```
### 环境变量
```bash
# 设置测试服务器地址
export TEST_SERVER_URL="http://localhost:8000"
# 设置浏览器类型
export BROWSER=chromium # 或 firefox, webkit
```
## 🐛 调试技巧
### 1. 使用有头模式
```bash
pytest tests/test_ui_regex_rules.py --headed
```
### 2. 使用慢速模式
```bash
pytest tests/test_ui_regex_rules.py --headed --slowmo=1000
```
### 3. 截图调试
在测试中添加截图:
```python
def test_something(page: Page):
page.screenshot(path="debug_screenshot.png")
```
### 4. 使用 Playwright Inspector
```bash
# 启动调试模式
PWDEBUG=1 pytest tests/test_ui_regex_rules.py::test_add_single_rule
```
### 5. 查看追踪
```python
# 在 conftest.py 中添加
@pytest.fixture
def context(browser):
context = browser.new_context()
context.tracing.start(screenshots=True, snapshots=True)
yield context
context.tracing.stop(path="trace.zip")
```
然后查看:
```bash
playwright show-trace trace.zip
```
## 📝 编写新的 UI 测试
### 基本模板
```python
def test_my_feature(page: Page):
"""测试我的功能"""
# 1. 导航到页面
page.goto("http://localhost:8000")
# 2. 与元素交互
button = page.locator("#myButton")
button.click()
# 3. 验证结果
expect(page.locator("#result")).to_have_text("Success")
```
### 等待策略
```python
# 等待元素可见
page.wait_for_selector("#element", state="visible")
# 等待网络空闲
page.wait_for_load_state("networkidle")
# 等待特定时间(尽量避免)
page.wait_for_timeout(1000) # 1 秒
```
### 选择器策略
```python
# 推荐: 使用 data-testid
page.locator("[data-testid='add-rule-btn']")
# 通过文本
page.locator("button:has-text('保存规则')")
# 通过 ID
page.locator("#addRuleBtn")
# 通过 CSS 类
page.locator(".rule-row")
# 组合选择器
page.locator(".rule-row input[name='pattern']")
```
## 🎨 最佳实践
### 1. 使用 Page Object 模式
```python
class RulesPage:
def __init__(self, page: Page):
self.page = page
self.add_button = page.locator("#addRuleBtn")
self.save_button = page.locator("button:has-text('保存规则')")
def add_rule(self, pattern: str, replacement: str):
self.add_button.click()
self.page.wait_for_timeout(100)
patterns = self.page.locator("input[name='pattern']")
replacements = self.page.locator("input[name='replacement']")
patterns.last.fill(pattern)
replacements.last.fill(replacement)
def save(self):
self.save_button.click()
self.page.wait_for_load_state("networkidle")
# 使用
def test_with_page_object(page: Page):
rules_page = RulesPage(page)
rules_page.add_rule(r"/old/", r"/new/")
rules_page.save()
```
### 2. 使用 Fixtures 清理状态
```python
@pytest.fixture
def clean_rules(page: Page):
"""清除所有规则"""
page.goto("http://localhost:8000")
while page.locator(".rule-row").count() > 0:
page.locator(".rule-row button[title='删除此规则']").first.click()
page.wait_for_timeout(50)
yield
```
### 3. 避免硬编码等待时间
```python
# ❌ 不好
page.wait_for_timeout(2000)
# ✅ 好
page.wait_for_selector("#element", state="visible")
page.wait_for_load_state("networkidle")
```
### 4. 使用断言而非 if 判断
```python
# ❌ 不好
assert page.locator("#element").count() > 0
# ✅ 好
expect(page.locator("#element")).to_be_visible()
```
## 🔄 CI/CD 集成
### GitHub Actions 示例
```yaml
name: UI Tests
on: [push, pull_request]
jobs:
ui-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.10'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest-playwright
playwright install --with-deps chromium
- name: Start application
run: |
uvicorn app.main:app --host 0.0.0.0 --port 8000 &
sleep 5
- name: Run UI tests
run: pytest tests/test_ui_regex_rules.py -v
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v2
with:
name: screenshots
path: screenshots/
```
## 📚 参考资料
- [Playwright 官方文档](https://playwright.dev/python/)
- [pytest-playwright 插件](https://github.com/microsoft/playwright-pytest)
- [Playwright 最佳实践](https://playwright.dev/python/docs/best-practices)
## 🆘 常见问题
### Q: 测试运行时找不到浏览器?
A: 运行 `playwright install chromium`
### Q: 测试失败,如何调试?
A: 使用 `--headed --slowmo=500` 参数可视化执行过程
### Q: 如何在测试中等待异步操作?
A: 使用 `page.wait_for_load_state("networkidle")``page.wait_for_selector()`
### Q: 如何处理动态加载的内容?
A: 使用 `expect().to_be_visible()` 会自动等待元素出现
### Q: 测试很慢怎么办?
A: 减少不必要的 `wait_for_timeout()`,使用事件驱动的等待方法
+175
View File
@@ -0,0 +1,175 @@
# UI测试迁移指南
## 概述
本文档说明了UI测试从旧版本迁移到新React前端的主要变更。
## 主要变更
### 1. 输出目录变更
**旧版本:**
```
dockerapp/test_playlists/{playlist_name}/
```
**新版本:**
```
output_playlists/{playlist_name}/
```
### 2. 服务器端口变更
**旧版本:**
- 测试服务器: `http://localhost:8000`
**新版本:**
- Docker映射端口: `http://localhost:8888`
- 容器内端口: `8080`
### 3. UI架构变更
**旧版本:**
- 传统的HTML模板 (Jinja2)
- 选择器: `#addRuleBtn`, `select[name='mode']`, `input[name='pattern']`
**新版本:**
- React + TypeScript + Vite
- 策略选择器: 中间位置的圆形按钮下拉菜单
- 正则规则在StrategySelector组件中管理
- 选择器: `button[title='Add Rule']`, `input[placeholder='Regex Pattern']`
### 4. 测试文件修改
#### `conftest_ui.py`
- 更新BASE_URL为`http://localhost:8888`
- 修改server fixture为仅验证服务器运行状态
- 要求手动启动Docker Compose服务
#### `test_ui_case_mix.py`
- 添加`SyncStrategy`枚举类
- 实现`_open_strategy_selector()``_close_strategy_selector()`辅助函数
- 更新策略选择逻辑以适配React UI
- 更新正则规则添加逻辑
- 修改输出路径为`output_playlists/case_mix/`
#### `test_ui_regex_rules.py`
- 完全重写所有测试用例以适配React UI
- 添加辅助方法`_open_strategy_selector()``_close_strategy_selector()`
- 更新所有选择器以匹配新UI结构
- 适配Toast通知验证
## 运行测试
### 前提条件
1. **启动Docker Compose服务:**
```powershell
docker compose up -d
```
2. **验证服务运行:**
```powershell
# 在浏览器中访问
# http://localhost:8888
```
3. **安装测试依赖:**
```powershell
pip install pytest-playwright requests
playwright install
```
### 运行测试
**显示浏览器模式 (调试用):**
```powershell
pytest tests/test_ui_case_mix.py --headed
pytest tests/test_ui_regex_rules.py --headed
```
**无头模式 (CI/CD):**
```powershell
pytest tests/test_ui_case_mix.py
pytest tests/test_ui_regex_rules.py
```
**运行所有UI测试:**
```powershell
pytest tests/test_ui_*.py --headed
```
## 新UI元素定位
### 策略选择器
- **触发按钮:** `button` with SVG icon (圆形按钮)
- **下拉菜单:** `div.absolute.top-14`
- **策略选项:** `div:has-text('{strategy_label}')` with SVG
### 正则规则
- **添加按钮:** `button[title='Add Rule']``button:has-text('Add Rule')`
- **删除按钮:** `button[title='Delete Rule']`
- **模式输入:** `input[placeholder='Regex Pattern']`
- **替换输入:** `input[placeholder='Replacement']`
- **保存按钮:** `button:has-text('Save Changes')`
- **重置按钮:** `button:has-text('Revert')`
### Toast通知
- **成功通知:** `div:has-text('Regex preprocessing rules have been saved')`
- **策略保存:** `div:has-text('Selected strategy "{label}" has been saved')`
## 策略映射
| UI显示名称 | SyncStrategy枚举 | 旧版mode值 | 预期输出文件 |
|-----------|-----------------|-----------|-------------|
| Local Overwrite | LOCAL_OVERWRITE | local_force | case_mix_local_force.m3u |
| Cloud Overwrite | CLOUD_OVERWRITE | remote_force | case_mix_remote_force.m3u |
| Two-way Merge (Local Priority) | MERGE_LOCAL | merge_local_primary | case_mix_merge_local_primary.m3u |
| Two-way Merge (Cloud Priority) | MERGE_CLOUD | merge_remote_primary | case_mix_merge_remote_primary.m3u |
## 已知问题和注意事项
1. **同步触发:** 新UI需要通过API显式触发同步操作。测试中使用 `POST /api/sync` 端点:
```python
import requests
sync_response = requests.post(
f"{BASE_URL}/api/sync",
json={"mode": None} # 使用当前配置的策略
)
```
2. **Toast通知:** Toast通知有动画效果,需要适当的等待时间 (300-500ms)。
3. **下拉菜单:** 策略选择器的下拉菜单需要通过ESC键关闭,或点击外部区域。
4. **测试隔离:** 每个测试应该清理自己添加的规则,避免影响后续测试。
## 故障排除
### 服务器未运行
```
错误: 无法连接到测试服务器: http://localhost:8888
解决: docker compose up -d
```
### 元素未找到
```
错误: Timeout waiting for locator('button[title="Add Rule"]')
解决: 检查策略选择器是否已打开,确保调用了_open_strategy_selector()
```
### 输出文件未生成
```
错误: AssertionError: {strategy_label}: local_result.m3u8 未生成
解决:
1. 检查output_playlists/case_mix/目录是否存在
2. 验证Docker volume映射配置
3. 检查后端日志: docker compose logs
```
## 更新日期
2024-11-29 - 初始迁移完成
+131
View File
@@ -0,0 +1,131 @@
"""
Pytest fixtures for UI testing
注意: 此测试套件假设服务已通过 Docker Compose 启动
运行前请确保: docker compose up -d
"""
import os
import time
from pathlib import Path
import pytest
from playwright.sync_api import Browser, Page
# 测试服务器配置 - Docker映射端口8888到容器内8080
TEST_SERVER_HOST = os.getenv("TEST_SERVER_HOST", "localhost")
TEST_SERVER_PORT = int(os.getenv("TEST_SERVER_PORT", "8888"))
BASE_URL = f"http://{TEST_SERVER_HOST}:{TEST_SERVER_PORT}"
@pytest.fixture(scope="session")
def test_server():
"""
验证测试服务器是否运行
此fixture不启动服务器,而是检查Docker Compose服务是否已启动
请在运行测试前手动启动: docker compose up -d
"""
# 检查服务器是否已经在运行
max_retries = 10
for i in range(max_retries):
try:
import requests
response = requests.get(BASE_URL, timeout=3)
if response.status_code < 500:
print(f"✓ 服务器已在运行: {BASE_URL}")
yield BASE_URL
return
except Exception as e:
if i == max_retries - 1:
raise RuntimeError(
f"无法连接到测试服务器: {BASE_URL}\n"
f"请确保已启动 Docker Compose 服务: docker compose up -d\n"
f"错误: {e}"
)
time.sleep(2)
yield BASE_URL
@pytest.fixture
def browser_context_args(browser_context_args):
"""配置浏览器上下文"""
return {
**browser_context_args,
"viewport": {"width": 1920, "height": 1080},
"locale": "zh-CN",
}
@pytest.fixture
def page(page: Page, test_server):
"""
配置页面并导航到首页
自动导航到测试服务器的首页并等待页面加载完成
"""
page.goto(test_server)
page.wait_for_load_state("networkidle")
# 设置默认超时
page.set_default_timeout(10000) # 10 秒
yield page
# 测试失败时截图
if page.context.browser.is_connected():
try:
screenshots_dir = Path(__file__).parent / "screenshots"
screenshots_dir.mkdir(exist_ok=True)
test_name = os.environ.get("PYTEST_CURRENT_TEST", "unknown").split(":")[-1].split(" ")[0]
screenshot_path = screenshots_dir / f"{test_name}.png"
page.screenshot(path=str(screenshot_path))
print(f"截图保存至: {screenshot_path}")
except Exception as e:
print(f"截图失败: {e}")
@pytest.fixture
def clean_rules(page: Page):
"""
清除所有规则的 fixture
在测试前清除所有现有的正则规则确保测试从干净状态开始
"""
# 清除所有规则
while page.locator(".rule-row").count() > 0:
try:
remove_btn = page.locator(".rule-row button[title='删除此规则']").first
remove_btn.click()
page.wait_for_timeout(50)
except:
break # 如果没有更多规则可删除
yield
# 测试后不清理,让下一个测试自己清理
# 这样可以在浏览器中查看测试结果
@pytest.fixture
def sample_rules():
"""提供示例规则数据"""
return [
{"pattern": r"/old/path/", "replacement": r"/new/path/"},
{"pattern": r"C:\\Music", "replacement": r"D:\\Audio"},
{"pattern": r"\\\\nas\\share", "replacement": r"Z:"},
]
@pytest.fixture
def nas_conversion_rules():
"""提供 NAS 路径转换规则"""
return [
{"pattern": r"\\\\koha9-nas\\koha9-nas\\Music", "replacement": r"N:\\Music"},
{"pattern": r"/music/cache/", "replacement": r"/data/music/"},
{"pattern": r"\\", "replacement": r"/"},
]
+554
View File
@@ -0,0 +1,554 @@
"""
Unit tests for regex path replacement functionality.
测试正则替换路径功能的各种场景
"""
import re
from pathlib import Path
import pytest
from app.utils.playlist_merge import (
_compile_regex_rules,
_apply_compiled_rules_to_paths,
apply_regex_rules_to_paths,
preprocess_playlist_text,
)
class TestCompileRegexRules:
"""测试正则规则编译功能"""
def test_compile_simple_pattern(self):
"""测试编译简单正则模式"""
rules = [{"pattern": r"foo", "replacement": "bar"}]
compiled = _compile_regex_rules(rules)
assert len(compiled) == 1
assert isinstance(compiled[0][0], re.Pattern)
assert compiled[0][1] == "bar"
def test_compile_multiple_patterns(self):
"""测试编译多个正则模式"""
rules = [
{"pattern": r"foo", "replacement": "bar"},
{"pattern": r"\d+", "replacement": "NUM"},
{"pattern": r"[A-Z]+", "replacement": "UPPER"},
]
compiled = _compile_regex_rules(rules)
assert len(compiled) == 3
def test_compile_empty_pattern_skipped(self):
"""测试跳过空模式"""
rules = [
{"pattern": "", "replacement": "bar"},
{"pattern": r"foo", "replacement": "bar"},
]
compiled = _compile_regex_rules(rules)
assert len(compiled) == 1
def test_compile_missing_pattern_skipped(self):
"""测试跳过缺失模式"""
rules = [
{"replacement": "bar"}, # no pattern
{"pattern": r"foo", "replacement": "bar"},
]
compiled = _compile_regex_rules(rules)
assert len(compiled) == 1
def test_compile_invalid_regex_skipped(self):
"""测试跳过无效正则表达式"""
rules = [
{"pattern": r"[invalid(", "replacement": "bar"}, # invalid regex
{"pattern": r"foo", "replacement": "bar"},
]
compiled = _compile_regex_rules(rules)
# Invalid pattern should be skipped
assert len(compiled) == 1
def test_compile_empty_replacement(self):
"""测试空替换字符串"""
rules = [{"pattern": r"foo", "replacement": ""}]
compiled = _compile_regex_rules(rules)
assert len(compiled) == 1
assert compiled[0][1] == ""
def test_compile_missing_replacement(self):
"""测试缺失替换字符串(默认为空)"""
rules = [{"pattern": r"foo"}]
compiled = _compile_regex_rules(rules)
assert len(compiled) == 1
assert compiled[0][1] == ""
class TestApplyCompiledRulesToPaths:
"""测试应用已编译的正则规则到路径"""
def test_apply_single_rule(self):
"""测试应用单个规则"""
paths = ["/music/album/track1.mp3", "/music/album/track2.mp3"]
compiled = [(re.compile(r"/music/"), "/data/")]
result = _apply_compiled_rules_to_paths(paths, compiled)
assert result == ["/data/album/track1.mp3", "/data/album/track2.mp3"]
def test_apply_multiple_rules_in_order(self):
"""测试按顺序应用多个规则"""
paths = ["/temp/music/file.mp3"]
compiled = [
(re.compile(r"/temp/"), "/data/"),
(re.compile(r"/data/"), "/storage/"),
]
result = _apply_compiled_rules_to_paths(paths, compiled)
# Should apply both rules in sequence
assert result == ["/storage/music/file.mp3"]
def test_apply_no_rules(self):
"""测试没有规则时返回原路径"""
paths = ["/music/track.mp3"]
compiled = []
result = _apply_compiled_rules_to_paths(paths, compiled)
assert result == paths
def test_apply_no_match(self):
"""测试规则不匹配时保持原路径"""
paths = ["/music/track.mp3"]
compiled = [(re.compile(r"/video/"), "/data/")]
result = _apply_compiled_rules_to_paths(paths, compiled)
assert result == paths
def test_apply_partial_match(self):
"""测试部分路径匹配"""
paths = [
"/music/rock/song.mp3",
"/video/movie.mp4",
"/music/jazz/tune.mp3",
]
compiled = [(re.compile(r"/music/"), "/audio/")]
result = _apply_compiled_rules_to_paths(paths, compiled)
assert result == [
"/audio/rock/song.mp3",
"/video/movie.mp4",
"/audio/jazz/tune.mp3",
]
class TestApplyRegexRulesToPaths:
"""测试完整的路径正则替换流程(含编译)"""
def test_simple_replacement(self):
"""测试简单字符串替换"""
paths = ["/old/path/file.mp3"]
rules = [{"pattern": r"/old/", "replacement": "/new/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/new/path/file.mp3"]
def test_windows_path_replacement(self):
"""测试 Windows 路径替换"""
paths = [r"C:\Music\Album\track.mp3"]
rules = [{"pattern": r"C:\\Music", "replacement": r"D:\\Audio"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == [r"D:\Audio\Album\track.mp3"]
def test_unc_path_replacement(self):
"""测试 UNC 网络路径替换"""
paths = [r"\\server\share\music\track.mp3"]
rules = [
{"pattern": r"\\\\server\\share", "replacement": r"Z:"}
]
result = apply_regex_rules_to_paths(paths, rules)
assert result == [r"Z:\music\track.mp3"]
def test_case_sensitive_replacement(self):
"""测试大小写敏感替换"""
paths = ["/Music/Track.mp3", "/music/track.mp3"]
rules = [{"pattern": r"/Music/", "replacement": "/Audio/"}]
result = apply_regex_rules_to_paths(paths, rules)
# Only exact case match should be replaced
assert result == ["/Audio/Track.mp3", "/music/track.mp3"]
def test_case_insensitive_replacement(self):
"""测试大小写不敏感替换"""
paths = ["/Music/Track.mp3", "/music/track.mp3"]
rules = [{"pattern": r"(?i)/music/", "replacement": "/Audio/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/Audio/Track.mp3", "/Audio/track.mp3"]
def test_regex_special_characters(self):
"""测试正则特殊字符"""
paths = ["/music (2024)/album/track.mp3"]
rules = [{"pattern": r"/music \(\d+\)/", "replacement": "/music/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/music/album/track.mp3"]
def test_capture_group_replacement(self):
"""测试捕获组替换"""
paths = ["/music/2024/album/track.mp3"]
rules = [{"pattern": r"/music/(\d+)/", "replacement": r"/archive/\1/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/archive/2024/album/track.mp3"]
def test_multiple_capture_groups(self):
"""测试多个捕获组"""
paths = ["/music/Rock/2024/album.mp3"]
rules = [
{"pattern": r"/music/([^/]+)/(\d+)/", "replacement": r"/\2/\1/"}
]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/2024/Rock/album.mp3"]
def test_delete_pattern(self):
"""测试删除匹配内容(替换为空)"""
paths = ["/music/temp/album/track.mp3"]
rules = [{"pattern": r"/temp", "replacement": ""}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/music/album/track.mp3"]
def test_multiple_matches_in_path(self):
"""测试路径中多次匹配"""
paths = ["/old/path/old/file.mp3"]
rules = [{"pattern": r"old", "replacement": "new"}]
result = apply_regex_rules_to_paths(paths, rules)
# Should replace all occurrences
assert result == ["/new/path/new/file.mp3"]
def test_chained_replacements(self):
"""测试链式替换"""
paths = [r"\\nas\Music\Album\track.mp3"]
rules = [
{"pattern": r"\\\\nas\\Music", "replacement": "/mnt/music"},
{"pattern": r"\\", "replacement": "/"},
]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/mnt/music/Album/track.mp3"]
def test_url_encoding_path(self):
"""测试 URL 编码路径处理"""
paths = ["/music/artist%20name/track.mp3"]
rules = [{"pattern": r"%20", "replacement": " "}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/music/artist name/track.mp3"]
def test_unicode_path(self):
"""测试 Unicode 路径"""
paths = ["/音乐/专辑/歌曲.mp3"]
rules = [{"pattern": r"/音乐/", "replacement": "/music/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == ["/music/专辑/歌曲.mp3"]
def test_empty_rules_list(self):
"""测试空规则列表"""
paths = ["/music/track.mp3"]
rules = []
result = apply_regex_rules_to_paths(paths, rules)
assert result == paths
def test_empty_paths_list(self):
"""测试空路径列表"""
paths = []
rules = [{"pattern": r"foo", "replacement": "bar"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result == []
class TestPreprocessPlaylistText:
"""测试预处理播放列表文本(含正则替换)"""
def test_preprocess_with_replacements(self):
"""测试带替换的预处理"""
text = """#EXTM3U
/old/path/track1.mp3
/old/path/track2.mp3
"""
rules = [{"pattern": r"/old/", "replacement": "/new/"}]
result = preprocess_playlist_text(text, rules)
assert "#EXTM3U" in result
assert "/new/path/track1.mp3" in result
assert "/new/path/track2.mp3" in result
assert "/old/" not in result
def test_preprocess_removes_comments(self):
"""测试预处理移除注释"""
text = """#EXTM3U
# This is a comment
/music/track1.mp3
#EXTINF:123,Artist - Track
/music/track2.mp3
"""
rules = []
result = preprocess_playlist_text(text, rules)
lines = [l for l in result.splitlines() if l and not l.startswith("#")]
assert len(lines) == 2
assert "/music/track1.mp3" in lines
assert "/music/track2.mp3" in lines
def test_preprocess_empty_text(self):
"""测试预处理空文本"""
text = ""
rules = [{"pattern": r"foo", "replacement": "bar"}]
result = preprocess_playlist_text(text, rules)
assert "#EXTM3U" in result
def test_preprocess_with_blank_lines(self):
"""测试预处理包含空行的文本"""
text = """#EXTM3U
/music/track1.mp3
/music/track2.mp3
"""
rules = []
result = preprocess_playlist_text(text, rules)
lines = [l for l in result.splitlines() if l and not l.startswith("#")]
assert len(lines) == 2
def test_preprocess_real_world_scenario(self):
"""测试真实场景:NAS 路径转换"""
text = """#EXTM3U
\\\\koha9-nas\\koha9-nas\\Music\\Rock\\track1.flac
\\\\koha9-nas\\koha9-nas\\Music\\Jazz\\track2.mp3
/music/cache/temp.flac
"""
rules = [
{"pattern": r"\\\\koha9-nas\\koha9-nas\\Music", "replacement": r"N:\\Music"},
{"pattern": r"/music/cache/", "replacement": "/data/music/"},
{"pattern": r"\\", "replacement": "/"},
]
result = preprocess_playlist_text(text, rules)
lines = [l for l in result.splitlines() if l and not l.startswith("#")]
# After all replacements, backslashes should be converted to forward slashes
assert "N:/Music/Rock/track1.flac" in lines
assert "N:/Music/Jazz/track2.mp3" in lines
assert "/data/music/temp.flac" in lines
def test_preprocess_with_compiled_rules(self):
"""测试使用预编译规则"""
text = """#EXTM3U
/old/path/track.mp3
"""
rules = [{"pattern": r"/old/", "replacement": "/new/"}]
compiled = _compile_regex_rules(rules)
result = preprocess_playlist_text(text, rules, compiled_rules=compiled)
assert "/new/path/track.mp3" in result
def test_preprocess_preserves_order(self):
"""测试预处理保持顺序"""
text = """#EXTM3U
/path/track1.mp3
/path/track2.mp3
/path/track3.mp3
"""
rules = [{"pattern": r"/path/", "replacement": "/new/"}]
result = preprocess_playlist_text(text, rules)
lines = [l for l in result.splitlines() if l and not l.startswith("#")]
assert lines[0] == "/new/track1.mp3"
assert lines[1] == "/new/track2.mp3"
assert lines[2] == "/new/track3.mp3"
class TestEdgeCases:
"""测试边界情况和异常场景"""
def test_very_long_path(self):
"""测试超长路径"""
long_path = "/music/" + "a" * 1000 + "/track.mp3"
paths = [long_path]
rules = [{"pattern": r"/music/", "replacement": "/audio/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0].startswith("/audio/")
assert len(result[0]) > 1000
def test_special_characters_in_path(self):
"""测试路径中的特殊字符"""
paths = [
"/music/artist [2024]/track (remix).mp3",
"/music/artist & band/song #1.mp3",
]
rules = [{"pattern": r"/music/", "replacement": "/audio/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/audio/artist [2024]/track (remix).mp3"
assert result[1] == "/audio/artist & band/song #1.mp3"
def test_dot_in_path(self):
"""测试路径中的点号"""
paths = ["/music/../audio/track.mp3", "/music/./track.mp3"]
rules = [{"pattern": r"\.\./", "replacement": ""}]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/music/audio/track.mp3"
def test_trailing_slash(self):
"""测试尾部斜杠"""
paths = ["/music/album/", "/music/track.mp3"]
rules = [{"pattern": r"/$", "replacement": ""}]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/music/album"
assert result[1] == "/music/track.mp3"
def test_duplicate_slashes(self):
"""测试重复斜杠"""
paths = ["/music//album///track.mp3"]
rules = [{"pattern": r"/+", "replacement": "/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/music/album/track.mp3"
def test_mixed_path_separators(self):
"""测试混合路径分隔符"""
paths = [r"C:\Music/Album\track.mp3"]
rules = [
{"pattern": r"\\", "replacement": "/"},
]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "C:/Music/Album/track.mp3"
def test_regex_metacharacters_in_replacement(self):
"""测试替换字符串中的正则元字符"""
paths = ["/music/track.mp3"]
rules = [{"pattern": r"/music/", "replacement": r"/audio$/"}]
result = apply_regex_rules_to_paths(paths, rules)
# $ in replacement should be literal
assert result[0] == r"/audio$/track.mp3"
def test_empty_string_replacement(self):
"""测试替换为空字符串"""
paths = ["/music/temp/album/track.mp3"]
rules = [{"pattern": r"temp/", "replacement": ""}]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/music/album/track.mp3"
def test_replacement_creates_invalid_path(self):
"""测试替换可能产生无效路径(但仍应执行)"""
paths = ["/music/track.mp3"]
rules = [{"pattern": r"/", "replacement": ""}]
result = apply_regex_rules_to_paths(paths, rules)
# Should still perform replacement even if result is odd
assert result[0] == "musictrack.mp3"
class TestPerformance:
"""测试性能相关场景"""
def test_large_playlist(self):
"""测试大型播放列表"""
paths = [f"/music/track{i}.mp3" for i in range(10000)]
rules = [{"pattern": r"/music/", "replacement": "/audio/"}]
result = apply_regex_rules_to_paths(paths, rules)
assert len(result) == 10000
assert all(p.startswith("/audio/") for p in result)
def test_many_rules(self):
"""测试大量规则"""
paths = ["/music/rock/2024/album/track.mp3"]
rules = [
{"pattern": r"music", "replacement": "audio"},
{"pattern": r"rock", "replacement": "genre1"},
{"pattern": r"2024", "replacement": "year"},
{"pattern": r"album", "replacement": "collection"},
{"pattern": r"track", "replacement": "song"},
]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/audio/genre1/year/collection/song.mp3"
def test_complex_regex_pattern(self):
"""测试复杂正则表达式"""
paths = [
"/music/Artist - Album (2024) [FLAC]/01. Track.flac",
"/music/Another Artist - Another Album (2023) [MP3]/02. Song.mp3",
]
rules = [
{
"pattern": r"/music/(.+?) - (.+?) \((\d+)\) \[([^\]]+)\]/",
"replacement": r"/library/\4/\3/\1/\2/"
}
]
result = apply_regex_rules_to_paths(paths, rules)
assert result[0] == "/library/FLAC/2024/Artist/Album/01. Track.flac"
assert result[1] == "/library/MP3/2023/Another Artist/Another Album/02. Song.mp3"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+291
View File
@@ -0,0 +1,291 @@
"""
UI 集成测试 - case_mix清空规则设置规则并执行四种同步策略
运行前准备:
1. 启动Docker服务: docker compose up -d
2. 确保服务运行在 http://localhost:8888
运行:
pytest tests/test_ui_case_mix.py --headed # 显示浏览器
pytest tests/test_ui_case_mix.py # 无头模式
"""
from enum import Enum
from pathlib import Path
import shutil
import time
from playwright.sync_api import Page, expect
BASE_URL = "http://localhost:8888"
PROJECT_ROOT = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_ROOT / "output_playlists" / "case_mix"
EXPECTED_DIR = PROJECT_ROOT / "test_res"
class SyncStrategy(str, Enum):
"""同步策略枚举"""
LOCAL_OVERWRITE = "LOCAL_OVERWRITE"
CLOUD_OVERWRITE = "CLOUD_OVERWRITE"
MERGE_LOCAL = "MERGE_LOCAL"
MERGE_CLOUD = "MERGE_CLOUD"
def _handle_connection_modal(page: Page):
"""处理登录模态框:如果存在则关闭"""
# 检查模态框是否存在 (根据 ConnectionModal.tsx 的结构)
# 模态框通常有一个全屏的遮罩层
modal_overlay = page.locator("div.fixed.inset-0.z-50")
if modal_overlay.is_visible():
print("检测到登录模态框,尝试关闭...")
# 尝试找到关闭按钮 (通常在右上角,包含 X 图标)
# 在 ConnectionModal.tsx 中,关闭按钮在 Header 里
close_button = modal_overlay.locator("button").filter(has=page.locator("svg")).last
if close_button.is_visible():
close_button.click()
else:
# 如果找不到关闭按钮,尝试按 ESC
page.keyboard.press("Escape")
page.wait_for_timeout(500) # 等待模态框关闭动画
def _open_strategy_selector(page: Page):
"""打开策略选择器下拉菜单"""
# 1. 先处理可能遮挡的登录模态框
_handle_connection_modal(page)
# 2. 检查下拉菜单是否已经打开
# 下拉菜单的特征类名
dropdown = page.locator("div.absolute.top-14")
if dropdown.is_visible():
return # 已经打开,无需操作
# 3. 查找并点击策略选择器按钮
# 使用 title 属性定位更准确 (StrategySelector.tsx 中定义了 title="Current Strategy: ...")
strategy_button = page.locator("button[title^='Current Strategy']")
if strategy_button.count() == 0:
# 备用定位方式:查找包含特定图标的圆形按钮
# 注意:页面上可能有多个按钮,需要小心
# 策略按钮在中间,且包含 ChevronDown 小图标
strategy_button = page.locator("button").filter(has=page.locator("svg")).nth(1)
# nth(0) 可能是 Header 里的连接按钮
if strategy_button.is_visible():
strategy_button.click()
page.wait_for_timeout(300) # 等待下拉菜单动画完成
else:
print("警告: 无法找到策略选择器按钮")
def _clear_all_rules(page: Page):
"""清空所有正则规则"""
_open_strategy_selector(page)
# 等待下拉菜单打开
dropdown = page.locator("div.absolute.top-14")
expect(dropdown).to_be_visible()
# 查找并点击所有删除按钮
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
try:
delete_buttons.first.click()
page.wait_for_timeout(100)
except Exception:
break
# 关闭下拉菜单
page.keyboard.press("Escape")
page.wait_for_timeout(200)
def _normalize_playlist_lines(file_path: Path) -> list[str]:
"""读取播放列表并返回规范化曲目路径列表(忽略注释与空行)"""
if not file_path.exists():
return []
with open(file_path, "r", encoding="utf-8") as f:
lines = [
line.strip()
for line in f
if line.strip() and not line.startswith("#")
]
return lines
def _compare_playlists(actual: Path, expected: Path) -> tuple[bool, str]:
"""对比实际输出与期望结果,返回 (是否匹配, 差异描述)"""
actual_lines = _normalize_playlist_lines(actual)
expected_lines = _normalize_playlist_lines(expected)
if actual_lines == expected_lines:
return True, ""
# 生成差异报告
diff_lines = []
diff_lines.append(f"实际曲目数: {len(actual_lines)}, 期望曲目数: {len(expected_lines)}")
only_actual = set(actual_lines) - set(expected_lines)
only_expected = set(expected_lines) - set(actual_lines)
if only_actual:
diff_lines.append(f"仅在实际输出中: {only_actual}")
if only_expected:
diff_lines.append(f"仅在期望结果中: {only_expected}")
return False, "\n".join(diff_lines)
def test_case_mix_run_all_modes(page: Page):
"""
1) 清空当前正则规则
2) 填写并保存 case_mix 所用规则
3) 依次执行四种同步策略每次同步后立即验证输出并与期望对比
"""
# 导航到首页并确认加载(端口为 8080)
page.goto(BASE_URL + "/")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1000) # 等待React应用初始化
expect(page).to_have_url(BASE_URL + "/")
# 处理可能出现的登录模态框
_handle_connection_modal(page)
# 1. 清空规则
_clear_all_rules(page)
# 2. 添加并保存 case_mix 所用规则(顺序很重要)
rules = [
(r"^\\\\koha9-nas\\koha9-nas\\Music", r"N:\\Music"), # UNC 到盘符
(r"^/mnt/music", r"N:\\Music"), # Linux 挂载到盘符
(r"(?i)^N:\\MUSIC", r"N:\\Music"), # 大小写规范化
(r"/", r"\\"), # 斜杠统一为反斜杠(需在映射后)
]
# 打开策略选择器
_open_strategy_selector(page)
# 添加规则
for pattern, replacement in rules:
# 点击 "Add Rule" 按钮
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
# 如果没有"Add Rule"按钮,尝试使用带文本的按钮
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
# 填写最后一组输入框
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
replacement_inputs = page.locator("input[placeholder='Replacement']")
pattern_inputs.last.fill(pattern)
replacement_inputs.last.fill(replacement)
page.wait_for_timeout(100)
# 保存规则 - 点击 "Save Changes" 按钮
save_button = page.locator("button:has-text('Save Changes')")
expect(save_button).to_be_enabled()
save_button.click()
page.wait_for_timeout(500) # 等待保存完成
# 验证保存成功 - 检查toast通知
toast = page.locator("div:has-text('Regex preprocessing rules have been saved')")
if toast.count() > 0:
expect(toast.first).to_be_visible()
# 关闭下拉菜单
page.keyboard.press("Escape")
page.wait_for_timeout(300)
# 3. 依次执行四种同步模式,每次执行后立即验证
# 策略名称映射: UI中的策略值 -> 测试用例名称
strategy_mappings = [
(SyncStrategy.LOCAL_OVERWRITE, "Local Overwrite", "case_mix_local_force.m3u"),
(SyncStrategy.CLOUD_OVERWRITE, "Cloud Overwrite", "case_mix_remote_force.m3u"),
(SyncStrategy.MERGE_LOCAL, "Two-way Merge (Local Priority)", "case_mix_merge_local_primary.m3u"),
(SyncStrategy.MERGE_CLOUD, "Two-way Merge (Cloud Priority)", "case_mix_merge_remote_primary.m3u"),
]
# 准备初始 Base(每次测试前恢复)
initial_base_content = """#EXTM3U
N:\\Music\\Anime\\New PANTY & STOCKING with GARTERBELT\\Theme of New PANTY & STOCKING\\02. Reckless - Theme of New PANTY & STOCKING.flac
N:\\Music\\Anime\\CITY THE ANIMATION\\Hello\\01. Hello - Hello.flac
N:\\Music\\音ゲー\\USAO\\MEMORIES\\15. Empty Room (Srav3R Remix) - MEMORIES.flac
"""
for strategy_value, strategy_label, expected_file in strategy_mappings:
print(f"\n==== 执行同步策略: {strategy_label} ====")
# 恢复初始 Base(避免前次同步影响)
base_next_path = OUTPUT_DIR / "base_next.m3u8"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
with open(base_next_path, "w", encoding="utf-8") as f:
f.write(initial_base_content)
print(f"已恢复初始 Base: {base_next_path}")
# 选择策略 - 打开下拉菜单
_open_strategy_selector(page)
# 点击对应的策略选项 - 更精确的定位
# 找到包含策略名称的可点击div (class包含cursor-pointer)
strategy_option = page.locator("div.cursor-pointer").filter(has_text=strategy_label)
expect(strategy_option.first).to_be_visible()
strategy_option.first.click()
page.wait_for_timeout(500) # 等待策略保存
# 验证策略选择成功的toast
toast = page.locator(f"div:has-text('Selected strategy \"{strategy_label}\" has been saved')")
if toast.count() > 0:
expect(toast.first).to_be_visible(timeout=3000)
# 关闭下拉菜单
page.keyboard.press("Escape")
page.wait_for_timeout(300)
# 执行同步 - 通过API触发同步操作
# 新UI需要显式调用同步API
import requests
sync_response = requests.post(
f"{BASE_URL}/api/sync",
json={"mode": None} # 使用当前配置的策略
)
assert sync_response.status_code == 200, f"同步API调用失败: {sync_response.text}"
print(f"同步API响应: {sync_response.json()}")
time.sleep(1) # 确保文件写入完成
# 验证输出文件生成
local_result = OUTPUT_DIR / "local_result.m3u8"
remote_result = OUTPUT_DIR / "remote_result.m3u8"
base_next = OUTPUT_DIR / "base_next.m3u8"
assert local_result.exists(), f"{strategy_label}: local_result.m3u8 未生成"
assert remote_result.exists(), f"{strategy_label}: remote_result.m3u8 未生成"
assert base_next.exists(), f"{strategy_label}: base_next.m3u8 未生成"
# 与期望结果对比(使用 local_result.m3u8 作为主要输出)
expected_path = EXPECTED_DIR / expected_file
match, diff = _compare_playlists(local_result, expected_path)
# 备份当前输出以便后续检查
backup_dir = OUTPUT_DIR / f"backup_{strategy_value}"
backup_dir.mkdir(exist_ok=True)
shutil.copy(local_result, backup_dir / "local_result.m3u8")
shutil.copy(remote_result, backup_dir / "remote_result.m3u8")
shutil.copy(base_next, backup_dir / "base_next.m3u8")
print(f"输出已备份到: {backup_dir}")
# 断言匹配
assert match, f"{strategy_label} 输出与期望不符:\n{diff}"
print(f"{strategy_label} 验证通过")
print("\n==== 全部四种策略测试通过 ====")
+566
View File
@@ -0,0 +1,566 @@
"""
UI 集成测试 - 使用 Playwright 测试正则路径替换功能
运行前准备:
1. 启动Docker服务: docker compose up -d
2. 确保服务运行在 http://localhost:8888
安装:
pip install pytest-playwright
playwright install
运行:
pytest tests/test_ui_regex_rules.py --headed # 显示浏览器
pytest tests/test_ui_regex_rules.py # 无头模式
"""
import re
import time
from pathlib import Path
import pytest
from playwright.sync_api import Page, expect
# 测试服务器地址 - Docker映射端口
BASE_URL = "http://localhost:8888"
@pytest.fixture(scope="session")
def test_server():
"""启动测试服务器(可选,如果服务器未运行)"""
# 如果你的服务器已经在运行,直接返回
# 否则可以在这里启动服务器进程
yield BASE_URL
@pytest.fixture
def page(page: Page, test_server):
"""配置页面并导航到首页"""
page.goto(test_server)
page.wait_for_load_state("networkidle")
return page
class TestRegexRulesUI:
"""测试正则路径替换规则的 UI 交互 - 适配新React UI"""
def _handle_connection_modal(self, page: Page):
"""处理登录模态框"""
modal_overlay = page.locator("div.fixed.inset-0.z-50")
if modal_overlay.is_visible():
close_button = modal_overlay.locator("button").filter(has=page.locator("svg")).last
if close_button.is_visible():
close_button.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(500)
def _open_strategy_selector(self, page: Page):
"""打开策略选择器下拉菜单"""
self._handle_connection_modal(page)
dropdown = page.locator("div.absolute.top-14")
if dropdown.is_visible():
return
strategy_button = page.locator("button[title^='Current Strategy']")
if strategy_button.count() == 0:
strategy_button = page.locator("button").filter(has=page.locator("svg")).nth(1)
if strategy_button.is_visible():
strategy_button.click()
page.wait_for_timeout(300)
def _close_strategy_selector(self, page: Page):
"""关闭策略选择器"""
page.keyboard.press("Escape")
page.wait_for_timeout(200)
def test_page_loads_successfully(self, page: Page):
"""测试页面成功加载"""
expect(page).to_have_title(re.compile("Plex.*Sync", re.I))
# 检查关键元素存在 - 新UI的主要元素
# 检查策略选择器按钮存在
strategy_button = page.locator("button").filter(has=page.locator("svg")).first
expect(strategy_button).to_be_visible()
def test_add_single_rule(self, page: Page):
"""测试添加单个规则"""
# 打开策略选择器
self._open_strategy_selector(page)
# 等待下拉菜单可见
dropdown = page.locator("div.absolute.top-14")
expect(dropdown).to_be_visible()
# 获取初始规则数量
initial_count = page.locator("input[placeholder='Regex Pattern']").count()
# 点击添加规则按钮
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
# 验证规则数量增加
new_count = page.locator("input[placeholder='Regex Pattern']").count()
assert new_count == initial_count + 1
# 填写规则内容
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
replacement_inputs = page.locator("input[placeholder='Replacement']")
pattern_inputs.last.fill(r"/old/path/")
replacement_inputs.last.fill(r"/new/path/")
# 验证填写成功
assert pattern_inputs.last.input_value() == r"/old/path/"
assert replacement_inputs.last.input_value() == r"/new/path/"
self._close_strategy_selector(page)
def test_add_multiple_rules(self, page: Page):
"""测试添加多个规则"""
self._open_strategy_selector(page)
rules = [
(r"\\\\nas\\Music", r"N:\\Music"),
(r"/music/cache/", r"/data/music/"),
(r"\\", r"/"),
]
# 添加多个规则
for pattern, replacement in rules:
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
replacement_inputs = page.locator("input[placeholder='Replacement']")
pattern_inputs.last.fill(pattern)
replacement_inputs.last.fill(replacement)
# 验证所有规则都已添加
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
assert pattern_inputs.count() >= len(rules)
# 验证规则内容
for i in range(len(rules)):
idx = pattern_inputs.count() - len(rules) + i
assert rules[i][0] in pattern_inputs.nth(idx).input_value()
self._close_strategy_selector(page)
def test_remove_rule(self, page: Page):
"""测试删除规则"""
self._open_strategy_selector(page)
# 获取初始规则数量
initial_count = page.locator("input[placeholder='Regex Pattern']").count()
# 添加一个规则
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
new_count = page.locator("input[placeholder='Regex Pattern']").count()
assert new_count == initial_count + 1
# 找到删除按钮(最后一个规则的删除按钮)
remove_buttons = page.locator("button[title='Delete Rule']")
if remove_buttons.count() > 0:
remove_buttons.last.click()
page.wait_for_timeout(200)
# 验证规则已删除
final_count = page.locator("input[placeholder='Regex Pattern']").count()
assert final_count == initial_count
self._close_strategy_selector(page)
def test_save_rules(self, page: Page):
"""测试保存规则"""
self._open_strategy_selector(page)
# 清除现有规则
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
delete_buttons.first.click()
page.wait_for_timeout(100)
delete_buttons = page.locator("button[title='Delete Rule']")
# 添加测试规则
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
pattern_input = page.locator("input[placeholder='Regex Pattern']").last
replacement_input = page.locator("input[placeholder='Replacement']").last
test_pattern = r"/test/path/"
test_replacement = r"/new/path/"
pattern_input.fill(test_pattern)
replacement_input.fill(test_replacement)
page.wait_for_timeout(100)
# 点击保存按钮
save_button = page.locator("button:has-text('Save Changes')")
expect(save_button).to_be_enabled()
save_button.click()
page.wait_for_timeout(500)
# 验证成功消息
toast = page.locator("div:has-text('Regex preprocessing rules have been saved')")
if toast.count() > 0:
expect(toast.first).to_be_visible(timeout=3000)
self._close_strategy_selector(page)
def test_rules_persist_after_save(self, page: Page):
"""测试规则保存后持久化"""
self._open_strategy_selector(page)
# 清除并添加新规则
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
delete_buttons.first.click()
page.wait_for_timeout(100)
delete_buttons = page.locator("button[title='Delete Rule']")
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
test_pattern = r"C:\\Music"
test_replacement = r"D:\\Audio"
page.locator("input[placeholder='Regex Pattern']").last.fill(test_pattern)
page.locator("input[placeholder='Replacement']").last.fill(test_replacement)
page.wait_for_timeout(100)
# 保存
save_button = page.locator("button:has-text('Save Changes')")
save_button.click()
page.wait_for_timeout(500)
self._close_strategy_selector(page)
# 刷新页面
page.reload()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1000)
# 重新打开策略选择器
self._open_strategy_selector(page)
# 验证规则仍然存在
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
# 检查是否有匹配的规则
found = False
for i in range(pattern_inputs.count()):
if test_pattern in pattern_inputs.nth(i).input_value():
found = True
# 验证对应的替换值
replacement_inputs = page.locator("input[placeholder='Replacement']")
replacement_value = replacement_inputs.nth(i).input_value()
assert test_replacement in replacement_value
break
assert found, f"未找到保存的规则: {test_pattern}"
self._close_strategy_selector(page)
def test_empty_pattern_validation(self, page: Page):
"""测试空模式验证 - 新UI在保存时会过滤空规则"""
self._open_strategy_selector(page)
# 添加规则但不填写
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
# 只填写替换,不填写模式
replacement_input = page.locator("input[placeholder='Replacement']").last
replacement_input.fill("/new/path/")
page.wait_for_timeout(100)
# 尝试保存 - 新UI会自动过滤空模式的规则
save_button = page.locator("button:has-text('Save Changes')")
if save_button.is_enabled():
initial_count = page.locator("input[placeholder='Regex Pattern']").count()
save_button.click()
page.wait_for_timeout(500)
# 验证空规则被过滤(如果实现了这个逻辑)
# 注意: 这取决于后端实现
self._close_strategy_selector(page)
def test_rule_order_preserved(self, page: Page):
"""测试规则顺序保持"""
self._open_strategy_selector(page)
# 清除现有规则
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
delete_buttons.first.click()
page.wait_for_timeout(100)
delete_buttons = page.locator("button[title='Delete Rule']")
# 按顺序添加多个规则
rules = [
("rule1", "replacement1"),
("rule2", "replacement2"),
("rule3", "replacement3"),
]
for pattern, replacement in rules:
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
page.locator("input[placeholder='Regex Pattern']").last.fill(pattern)
page.locator("input[placeholder='Replacement']").last.fill(replacement)
page.wait_for_timeout(100)
# 验证顺序
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
count = pattern_inputs.count()
for i, (pattern, _) in enumerate(rules):
idx = count - len(rules) + i
assert pattern in pattern_inputs.nth(idx).input_value()
self._close_strategy_selector(page)
class TestComplexScenarios:
"""测试复杂场景"""
def _handle_connection_modal(self, page: Page):
"""处理登录模态框"""
modal_overlay = page.locator("div.fixed.inset-0.z-50")
if modal_overlay.is_visible():
close_button = modal_overlay.locator("button").filter(has=page.locator("svg")).last
if close_button.is_visible():
close_button.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(500)
def _open_strategy_selector(self, page: Page):
"""打开策略选择器下拉菜单"""
self._handle_connection_modal(page)
dropdown = page.locator("div.absolute.top-14")
if dropdown.is_visible():
return
strategy_button = page.locator("button[title^='Current Strategy']")
if strategy_button.count() == 0:
strategy_button = page.locator("button").filter(has=page.locator("svg")).nth(1)
if strategy_button.is_visible():
strategy_button.click()
page.wait_for_timeout(300)
def _close_strategy_selector(self, page: Page):
"""关闭策略选择器"""
page.keyboard.press("Escape")
page.wait_for_timeout(200)
def test_windows_to_linux_path_conversion(self, page: Page):
"""测试 Windows 到 Linux 路径转换场景"""
self._open_strategy_selector(page)
# 清除规则
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
delete_buttons.first.click()
page.wait_for_timeout(100)
delete_buttons = page.locator("button[title='Delete Rule']")
# 添加转换规则
conversion_rules = [
(r"C:\\Music", r"/mnt/music"),
(r"\\", r"/"),
]
for pattern, replacement in conversion_rules:
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
page.locator("input[placeholder='Regex Pattern']").last.fill(pattern)
page.locator("input[placeholder='Replacement']").last.fill(replacement)
page.wait_for_timeout(100)
# 保存
save_button = page.locator("button:has-text('Save Changes')")
save_button.click()
page.wait_for_timeout(500)
# 验证保存成功
toast = page.locator("div:has-text('Regex preprocessing rules have been saved')")
if toast.count() > 0:
expect(toast.first).to_be_visible(timeout=3000)
self._close_strategy_selector(page)
def test_nas_path_normalization(self, page: Page):
"""测试 NAS 路径规范化"""
self._open_strategy_selector(page)
# 清除规则
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
delete_buttons.first.click()
page.wait_for_timeout(100)
delete_buttons = page.locator("button[title='Delete Rule']")
# NAS 路径规范化规则
nas_rules = [
(r"\\\\koha9-nas\\koha9-nas\\Music", r"N:\\Music"),
(r"/music/cache/", r"/data/music/"),
(r"\\", r"/"),
]
for pattern, replacement in nas_rules:
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
add_button.click()
page.wait_for_timeout(200)
page.locator("input[placeholder='Regex Pattern']").last.fill(pattern)
page.locator("input[placeholder='Replacement']").last.fill(replacement)
page.wait_for_timeout(100)
# 保存并验证
save_button = page.locator("button:has-text('Save Changes')")
save_button.click()
page.wait_for_timeout(500)
# 验证成功
toast = page.locator("div:has-text('Regex preprocessing rules have been saved')")
if toast.count() > 0:
expect(toast.first).to_be_visible(timeout=3000)
self._close_strategy_selector(page)
page.wait_for_load_state("networkidle")
# 刷新验证持久化
page.reload()
page.wait_for_load_state("networkidle")
# 重新打开策略选择器
self._open_strategy_selector(page)
# 验证所有规则都保存了
pattern_inputs = page.locator("input[placeholder='Regex Pattern']")
saved_patterns = [pattern_inputs.nth(i).input_value() for i in range(pattern_inputs.count())]
for pattern, _ in nas_rules:
assert any(pattern in saved for saved in saved_patterns), f"规则未保存: {pattern}"
@pytest.mark.slow
class TestPerformance:
"""性能测试"""
def _handle_connection_modal(self, page: Page):
"""处理登录模态框"""
modal_overlay = page.locator("div.fixed.inset-0.z-50")
if modal_overlay.is_visible():
close_button = modal_overlay.locator("button").filter(has=page.locator("svg")).last
if close_button.is_visible():
close_button.click()
else:
page.keyboard.press("Escape")
page.wait_for_timeout(500)
def _open_strategy_selector(self, page: Page):
"""打开策略选择器下拉菜单"""
self._handle_connection_modal(page)
dropdown = page.locator("div.absolute.top-14")
if dropdown.is_visible():
return
strategy_button = page.locator("button[title^='Current Strategy']")
if strategy_button.count() == 0:
strategy_button = page.locator("button").filter(has=page.locator("svg")).nth(1)
if strategy_button.is_visible():
strategy_button.click()
page.wait_for_timeout(300)
def test_add_many_rules_performance(self, page: Page):
"""测试添加大量规则的性能"""
self._open_strategy_selector(page)
# 清除规则
delete_buttons = page.locator("button[title='Delete Rule']")
while delete_buttons.count() > 0:
delete_buttons.first.click()
page.wait_for_timeout(50)
delete_buttons = page.locator("button[title='Delete Rule']")
# 测试添加 20 个规则
add_button = page.locator("button[title='Add Rule']")
if add_button.count() == 0:
add_button = page.locator("button:has-text('Add Rule')")
start_time = time.time()
for i in range(20):
# 重新定位按钮以确保引用的有效性
current_add_btn = page.locator("button[title='Add Rule']")
if current_add_btn.count() == 0:
current_add_btn = page.locator("button:has-text('Add Rule')")
current_add_btn.click()
# 给一点时间让 React 更新 DOM,避免操作过快导致浏览器崩溃或状态不同步
page.wait_for_timeout(100)
page.locator("input[placeholder='Regex Pattern']").last.fill(f"pattern{i}")
page.locator("input[placeholder='Replacement']").last.fill(f"replacement{i}")
end_time = time.time()
# 验证时间合理
elapsed = end_time - start_time
assert elapsed < 20, f"添加 20 个规则耗时过长: {elapsed:.2f}s" # 验证数量
assert page.locator("input[placeholder='Regex Pattern']").count() >= 20
if __name__ == "__main__":
pytest.main([__file__, "-v", "--headed"])