33 Commits

Author SHA1 Message Date
Koha9 0629ffc3bc Merge branch 'main' into UI-internationalization 2025-12-14 01:06:56 +09:00
Koha9 a6f0d1c73c fix: CRLF error.
add CRLF to LF in Dockerfile.
2025-12-14 01:06:17 +09:00
Koha9 a7c3b544fa feat: Added the OverflowMarquee, implemented auto-scrolling text. 2025-12-14 01:04:26 +09:00
Koha9 5c6b0b0444 Merge branch 'main' into UI-internationalization 2025-12-13 20:40:39 +09:00
Koha9 b1c9fa5f8e feat: Add timezone setting 2025-12-13 17:51:20 +09:00
Koha9 cae08acab3 feat: Implement i18n infrastructure 2025-12-13 17:29:27 +09:00
Koha9 2fc8a32b5f PlexPlaylist_UI subtree merge
feat: Implement internationalization and rename project

Merge commit 'a745adc1ab02adbd17ed19574f47070f87eba50b'
2025-12-09 05:19:21 +09:00
Koha9 a745adc1ab Squashed 'sample-front-end/' changes from 9a32272..32f6ed7
32f6ed7 feat: Implement internationalization and rename project

git-subtree-dir: sample-front-end
git-subtree-split: 32f6ed743b43e001e6d1170cc370521f6b4173a2
2025-12-09 05:19:21 +09:00
Koha9 aa95c6bb3b feat: Improved status display. 2025-12-09 04:53:44 +09:00
Koha9 2520c2b248 Squashed 'sample-front-end/' changes from 800cea6..9a32272
9a32272 feat: Add backup status display feat: Improve readability of the status

git-subtree-dir: sample-front-end
git-subtree-split: 9a32272023ea256a35332463386a557424828946
2025-12-08 21:15:55 +09:00
Koha9 7e0baebc20 PlexPlaylist_UI subtree merge
feat: Add backup status display
feat: Improve readability of the status

Merge commit '2520c2b248c7b4e680e45edccd0a194b11f03ffa'
2025-12-08 21:15:55 +09:00
Koha9 fcbf534f5d Fix: Fix Library selection wont show after server connected 2025-12-06 15:20:10 +09:00
Koha9 06f4c0683a Merge branch 'copilot/adjust-ui-and-sync-strategy' 2025-12-06 00:16:13 +09:00
Koha9 588c84c2c8 feat: Implement playlist synchronization result writeback functionality. 2025-12-05 23:08:50 +09:00
copilot-swe-agent[bot] b483edae74 Implement backup functionality with UI and backend support
Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-04 23:21:26 +00:00
Koha9 df4f5dde17 Fix: Resolved an issue where Cron scheduled tasks failed to auto-sync due to an overly short trigger grace period.
Set `misfire_grace_time=60, coalesce=True`
2025-12-05 08:07:51 +09:00
copilot-swe-agent[bot] 7b14445387 Port UI changes from sample-front-end: toggle switches, Eye icon, Link icon
Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-04 07:13:19 +00:00
copilot-swe-agent[bot] 1bb07d7f68 Initial plan 2025-12-04 07:04:29 +00:00
Koha9 0667fac940 Squashed 'sample-front-end/' changes from c58ef74..800cea6
800cea6 feat: Add backup settings functionality

git-subtree-dir: sample-front-end
git-subtree-split: 800cea6f86938884f0ee97d4f540b038fb2489e4
2025-12-04 15:37:34 +09:00
Koha9 28b68fa9eb PlexPlaylist_UI subtree merge
feat: Add backup settings functionality

Merge commit '0667fac9401254dd9b26043408cb6b204a894184'
2025-12-04 15:37:34 +09:00
Koha9 bc155d781a feat(ui): Allow closing ConnectionModal by clicking backdrop 2025-12-04 14:45:58 +09:00
Koha9 9f1fe20c16 Squashed 'sample-front-end/' changes from 8ae211a..c58ef74
c58ef74 feat(ui): Allow closing ConnectionModal by clicking backdrop

git-subtree-dir: sample-front-end
git-subtree-split: c58ef74ad2bcbd08b117aaee750bdba0dca6d571
2025-12-04 08:11:19 +09:00
Koha9 dffcaca668 PlexPlaylist_UI subtree merge
feat(ui): Allow closing ConnectionModal by clicking backdrop

Merge commit '9f1fe20c164a200ed795f90e3cfa60d8c985a557'
2025-12-04 08:11:19 +09:00
Koha9 86d0adebda Merge branch 'copilot/update-regex-replacement-strategy' 2025-12-04 08:07:53 +09:00
copilot-swe-agent[bot] 304e973db1 Fix Simple Mapping Windows path handling with double backslashes
- Normalize Windows paths by replacing \\\\ with \\ before pattern matching
- Escape backslashes in replacement strings for post-processing
- Add debug logging to help diagnose path matching issues

Root cause: UI stored escaped paths (\\\\Koha9-Main\\\\Music) but playlist
content uses single backslashes (\\Koha9-Main\\Music). Now normalizes paths
before compiling regex patterns.

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 22:14:03 +00:00
copilot-swe-agent[bot] 6c84112d29 Reset config.json to clean defaults for testing
Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 14:36:51 +00:00
copilot-swe-agent[bot] 1131b81454 Fix Simple Mapping not applying during sync - preserve id field
Root cause: The UUID (id) field was being stripped when saving path mapping:
- Backend ReplacementRule model was missing id field
- Frontend pathMappingToApi() didn't include id in conversion
- Backend update_path_mapping endpoint didn't save id

Changes:
- Add id field to ReplacementRule model in main.py
- Include id when saving path mapping rules in update_path_mapping
- Include id in frontend pathMappingToApi conversion

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 13:46:37 +00:00
copilot-swe-agent[bot] 6a1780bcee Fix Simple Mapping to use proper UUIDs for mapping IDs
- Add generateUUID() function using crypto.randomUUID() with fallback
- Update handleAdd to use UUID instead of Date.now() + Math.random()
- UUIDs are now properly validated in backend to prevent injection
- mapping_id is persisted when creating mapping pairs for reuse

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-03 12:44:27 +00:00
copilot-swe-agent[bot] fbafe75fae Implement Simple Mapping backend functionality
- Add _compile_simple_mapping_rules() that generates four rule sets from mapping pairs
- Each mapping uses UUID as unique mapping_id with special markers (__MAPPING__uuid__)
- local_pre: local_path → mapping_id
- remote_pre: cloud_path → mapping_id
- local_post: mapping_id → local_path
- remote_post: mapping_id → cloud_path
- Add UUID validation to prevent injection attacks
- Update sync_all_playlists() to detect and use SIMPLE mode

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-02 21:15:14 +00:00
copilot-swe-agent[bot] fbb5bb55c7 Implement Regex Rules backend functionality for path mapping
- Add CompiledRegexRules dataclass for all four processing stages
- Update _compile_regex_rules to support both legacy (pattern/replacement)
  and new (search/replace) field names with proper empty string handling
- Add _compile_path_mapping_rules helper function
- Update _write_results to apply post-processing rules:
  - local_result.m3u8 with local_post rules
  - remote_result.m3u8 with remote_post rules
  - base_next.m3u8 unprocessed (normalized sync result)
- Update merge_playlists and _sync_single_playlist to pass compiled_rules
- Update sync_all_playlists to implement full processing flow:
  1. Detect REGEX mode from path_mapping config
  2. Apply local_pre rules to local playlists before sync
  3. Apply remote_pre rules to remote playlists before sync
  4. Perform sync/merge
  5. Apply post rules to results for respective outputs

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-12-02 20:08:06 +00:00
Koha9 f9dbe733c3 Merge commit '3f43662c1f19056e81f107357b661b435ee3a876' into copilot/update-regex-replacement-strategy 2025-12-02 10:16:44 +09:00
copilot-swe-agent[bot] 350f1d97e6 Add Path Mapping UI with Simple Mapping and Regex Rules modes
- Updated frontend/types.ts with new types: ReplacementRule, PathMappingRules, PathMappingMode, PathMappingConfig
- Replaced StrategySelector.tsx with new UI featuring:
  - Simple Mapping tab for local/cloud path pairs
  - Regex Rules tab with 4 rule groups (localPre, localPost, remotePre, remotePost)
  - MappingGroupEditor sub-component for editing rule lists
- Updated App.tsx to use PathMappingConfig state instead of RegexReplacement[]
- Updated api.ts to handle new PathMappingConfig structure
- Updated backend config.py with path_mapping field support
- Added /api/settings/path-mapping endpoint in main.py

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-11-30 22:11:29 +00:00
copilot-swe-agent[bot] c18ff5b2ef Initial plan 2025-11-30 22:00:19 +00:00
84 changed files with 3208 additions and 3459 deletions
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings to avoid CRLF issues in Linux containers
* text=auto
# Shell scripts must be LF for correct shebang parsing
*.sh text eol=lf
# PowerShell scripts are typically CRLF on Windows
*.ps1 text eol=crlf
+7 -1
View File
@@ -6,9 +6,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& apt-get install -y --no-install-recommends build-essential tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
@@ -17,4 +21,6 @@ COPY frontend ./frontend
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+16
View File
@@ -26,6 +26,22 @@ PlexPlaylistSync 是一个用于同步 Plex 播放列表和本地 `.m3u`/`.m3u8`
docker compose up --build
```
### 配置容器时区
本项目支持通过 `docker-compose.yml` 的环境变量 `TZ` 配置容器运行时区(需要使用有效的 IANA 时区名,例如 `Asia/Shanghai``UTC``America/New_York`)。
- 临时指定(当前终端会话生效):
```bash
TZ=UTC docker compose up --build
```
- 或在项目根目录创建 `.env`
```env
TZ=Asia/Shanghai
```
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
+20 -4
View File
@@ -2,11 +2,27 @@
"theme": "auto",
"token": "",
"server_url": "",
"server_scheme": "http",
"server_port": "32400",
"server_scheme": "https",
"timeout": 9,
"library_name": "",
"sync_mode": "merge_local_primary",
"local_path": "playlist",
"path_rules": []
"sync_mode": "local_force",
"local_path": "playlists",
"path_rules": [],
"path_mapping": {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
},
"schedule_mode": "DISABLED",
"schedule_cron": "",
"schedule_daily_time": "00:00",
"schedule_weekly_days": [0],
"schedule_weekly_time": "00:00",
"schedule_auto_watch": false
}
+61
View File
@@ -92,9 +92,29 @@ class RegexRule(BaseModel):
replacement: str = ""
class ReplacementRule(BaseModel):
id: str = ""
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):
sync_mode: str
path_rules: list[RegexRule]
path_mapping: dict | None = None
local_path: str
library_name: str | None = None
server_url: str | None = None
@@ -124,6 +144,30 @@ class ScheduleSettings(BaseModel):
autoWatch: bool
class BackupSettingsPayload(BaseModel):
enabled: bool
retention_count: int
@app.get("/api/backup/settings")
async def get_backup_settings():
server_config.load()
return {
"enabled": server_config.backup_enabled,
"retention_count": server_config.backup_retention_count
}
@app.put("/api/backup/settings")
async def save_backup_settings(settings: BackupSettingsPayload):
server_config.set_backup(
enabled=settings.enabled,
retention_count=settings.retention_count
)
logger.info(f"Backup settings updated. Enabled: {settings.enabled}, Retention: {settings.retention_count}")
return {"status": "success", "message": "Backup settings saved"}
@app.get("/api/schedule")
async def get_schedule():
next_run = get_next_run_time()
@@ -352,6 +396,7 @@ async def get_settings():
return SyncSettingsResponse(
sync_mode=server_config.sync_mode,
path_rules=rules,
path_mapping=server_config.path_mapping,
local_path=server_config.local_path,
library_name=server_config.library_name,
server_url=server_config.url,
@@ -380,6 +425,22 @@ async def update_regex_rules(payload: RegexRulePayload):
return {"rules": payload.rules}
@app.put("/api/settings/path-mapping")
async def update_path_mapping(payload: PathMappingPayload):
path_mapping_dict = {
"mode": payload.mode,
"simple": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.simple],
"regex": {
"local_pre": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.local_pre],
"local_post": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.local_post],
"remote_pre": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_pre],
"remote_post": [{"id": rule.id, "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")
async def update_library(payload: LibrarySelection):
server_config.set_and_save_config(library_name=payload.library_name)
+270
View File
@@ -0,0 +1,270 @@
import os
import zipfile
from datetime import datetime
from typing import List
from app.utils.logger import logger
from app.utils.config import server_config
from app.utils.local_playlist import load_local_playlist
from app.utils.plex_client import plex_client
# Default backup directory
BACKUP_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "backups")
)
def ensure_backup_dir():
"""Ensure the backup directory exists."""
if not os.path.exists(BACKUP_DIR):
os.makedirs(BACKUP_DIR, exist_ok=True)
logger.info(f"Created backup directory: {BACKUP_DIR}")
def get_timestamp() -> str:
"""Generate a timestamp string for backup filenames."""
return datetime.now().strftime("%Y%m%d_%H%M%S")
def get_sorted_backup_files(prefix: str) -> List[str]:
"""Get backup files sorted by modification time (oldest first).
Args:
prefix: The prefix to filter backup files (e.g., 'local_backup_' or 'cloud_backup_')
Returns:
List of backup file paths sorted by modification time (oldest first)
"""
ensure_backup_dir()
backup_files = []
for filename in os.listdir(BACKUP_DIR):
if filename.startswith(prefix) and filename.endswith('.zip'):
filepath = os.path.join(BACKUP_DIR, filename)
backup_files.append(filepath)
# Sort by modification time (oldest first)
backup_files.sort(key=lambda x: os.path.getmtime(x))
return backup_files
def cleanup_old_backups(prefix: str, retention_count: int):
"""Delete old backup files exceeding the retention count.
Args:
prefix: The prefix to filter backup files (e.g., 'local_backup_' or 'cloud_backup_')
retention_count: Maximum number of backups to keep (0 means no auto-delete)
"""
if retention_count <= 0:
logger.debug(f"Backup retention count is {retention_count}, skipping cleanup for {prefix}")
return
backup_files = get_sorted_backup_files(prefix)
# Delete oldest files if we exceed retention count
files_to_delete = len(backup_files) - retention_count
if files_to_delete > 0:
for filepath in backup_files[:files_to_delete]:
try:
os.remove(filepath)
logger.info(f"Deleted old backup: {filepath}")
except Exception as e:
logger.warning(f"Failed to delete backup {filepath}: {e}")
def backup_local_playlists(local_path: str) -> str | None:
"""Create a backup of local playlists.
Reads all playlist files from the local path and writes them to a zip file
without any modifications.
Args:
local_path: Path to the local playlist directory
Returns:
Path to the created backup file, or None if backup failed
"""
ensure_backup_dir()
if not local_path or not os.path.isdir(local_path):
logger.warning(f"Local path does not exist or is not a directory: {local_path}")
return None
timestamp = get_timestamp()
backup_filename = f"local_backup_{timestamp}.zip"
backup_path = os.path.join(BACKUP_DIR, backup_filename)
try:
playlist_count = 0
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for entry in os.scandir(local_path):
if not entry.is_file():
continue
if not entry.name.lower().endswith((".m3u", ".m3u8")):
continue
# Read the original file content
try:
with open(entry.path, 'r', encoding='utf-8') as f:
content = f.read()
except UnicodeDecodeError:
# Try with different encoding
with open(entry.path, 'r', encoding='latin-1') as f:
content = f.read()
# Get the playlist name without extension and add .m3u8 extension
playlist_name = os.path.splitext(entry.name)[0]
archive_name = f"{playlist_name}.m3u8"
# Write to zip
zipf.writestr(archive_name, content)
playlist_count += 1
if playlist_count == 0:
# Remove empty zip file
os.remove(backup_path)
logger.info("No playlists found for local backup")
return None
logger.info(f"Created local backup with {playlist_count} playlists: {backup_path}")
return backup_path
except Exception as e:
logger.error(f"Failed to create local backup: {e}")
# Clean up partial backup file if it exists
if os.path.exists(backup_path):
try:
os.remove(backup_path)
except OSError:
pass
return None
def backup_cloud_playlists(library_name: str) -> str | None:
"""Create a backup of cloud playlists.
Fetches all playlists from the Plex server and writes them to a zip file
without any modifications.
Args:
library_name: Name of the Plex library to backup playlists from
Returns:
Path to the created backup file, or None if backup failed
"""
ensure_backup_dir()
if not plex_client.connected:
logger.warning("Plex client not connected, cannot backup cloud playlists")
return None
if not library_name:
logger.warning("No library name specified for cloud backup")
return None
timestamp = get_timestamp()
backup_filename = f"cloud_backup_{timestamp}.zip"
backup_path = os.path.join(BACKUP_DIR, backup_filename)
try:
playlists = plex_client.get_lib_playlists(library_name)
if not playlists:
logger.info("No playlists found for cloud backup")
return None
playlist_count = 0
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for playlist in playlists:
try:
# Get playlist items
items = playlist.items()
# Build m3u8 content
lines = ["#EXTM3U"]
for item in items:
# Try to get file path from the track
try:
if hasattr(item, 'media') and item.media:
for media in item.media:
if hasattr(media, 'parts') and media.parts:
for part in media.parts:
if hasattr(part, 'file') and part.file:
# Add extended info if available
duration = getattr(item, 'duration', 0) or 0
duration_seconds = duration // 1000 if duration else -1
title = getattr(item, 'title', 'Unknown')
artist = ''
if hasattr(item, 'grandparentTitle'):
artist = item.grandparentTitle
elif hasattr(item, 'artist'):
artist_attr = getattr(item, 'artist')
if callable(artist_attr):
artist = str(artist_attr())
else:
artist = str(artist_attr)
extinf = f"#EXTINF:{duration_seconds},{artist} - {title}"
lines.append(extinf)
lines.append(part.file)
break
except Exception as e:
logger.debug(f"Could not get file path for track: {e}")
continue
if len(lines) > 1: # More than just #EXTM3U
content = "\n".join(lines)
archive_name = f"{playlist.title}.m3u8"
zipf.writestr(archive_name, content)
playlist_count += 1
except Exception as e:
logger.warning(f"Failed to backup playlist '{playlist.title}': {e}")
continue
if playlist_count == 0:
# Remove empty zip file
os.remove(backup_path)
logger.info("No playlists with valid tracks found for cloud backup")
return None
logger.info(f"Created cloud backup with {playlist_count} playlists: {backup_path}")
return backup_path
except Exception as e:
logger.error(f"Failed to create cloud backup: {e}")
# Clean up partial backup file if it exists
if os.path.exists(backup_path):
try:
os.remove(backup_path)
except OSError:
pass
return None
def perform_backup_before_sync(local_path: str, library_name: str):
"""Perform backup of both local and cloud playlists before sync.
This function should be called before sync if backup is enabled.
It also handles cleanup of old backups based on retention settings.
Args:
local_path: Path to the local playlist directory
library_name: Name of the Plex library
"""
server_config.load()
if not server_config.backup_enabled:
logger.debug("Backup is disabled, skipping pre-sync backup")
return
logger.info("Starting pre-sync backup...")
# Backup local playlists
local_backup = backup_local_playlists(local_path)
if local_backup:
cleanup_old_backups("local_backup_", server_config.backup_retention_count)
# Backup cloud playlists
cloud_backup = backup_cloud_playlists(library_name)
if cloud_backup:
cleanup_old_backups("cloud_backup_", server_config.backup_retention_count)
logger.info("Pre-sync backup completed")
+65 -2
View File
@@ -3,6 +3,16 @@ import os
from app.utils.logger import logger
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(
os.path.join(os.path.dirname(__file__), "..", "config.json")
@@ -21,13 +31,16 @@ class ServerConfig:
self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE
self.local_path = "playlist"
self.path_rules: list[dict[str, str]] = []
self.path_rules: list[dict[str, str]] = [] # Legacy field for backward compatibility
self.path_mapping: dict = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = "DISABLED"
self.schedule_cron = ""
self.schedule_daily_time = "02:00"
self.schedule_weekly_days = [0]
self.schedule_weekly_time = "03:00"
self.schedule_auto_watch = False
self.backup_enabled = False
self.backup_retention_count = 5
self.load()
def load(self) -> None:
@@ -55,12 +68,31 @@ class ServerConfig:
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
self.local_path = config.get("local_path", "playlist")
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_cron = config.get("schedule_cron", "")
self.schedule_daily_time = config.get("schedule_daily_time", "02:00")
self.schedule_weekly_days = config.get("schedule_weekly_days", [0])
self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00")
self.schedule_auto_watch = config.get("schedule_auto_watch", False)
self.backup_enabled = config.get("backup_enabled", False)
self.backup_retention_count = config.get("backup_retention_count", 5)
logger.info(f"Server config loaded.")
logger.debug(f"Current server config: {self.__dict__}")
@@ -76,16 +108,20 @@ class ServerConfig:
"sync_mode": self.sync_mode,
"local_path": self.local_path,
"path_rules": self.path_rules,
"path_mapping": self.path_mapping,
"schedule_mode": self.schedule_mode,
"schedule_cron": self.schedule_cron,
"schedule_daily_time": self.schedule_daily_time,
"schedule_weekly_days": self.schedule_weekly_days,
"schedule_weekly_time": self.schedule_weekly_time,
"schedule_auto_watch": self.schedule_auto_watch,
"backup_enabled": self.backup_enabled,
"backup_retention_count": self.backup_retention_count,
}
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved: {config}")
logger.info(f"Server config saved.")
logger.debug(f"Saved server config: {config}")
def set_url(self, url: str) -> None:
self.url = url
@@ -121,6 +157,21 @@ class ServerConfig:
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
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(
self,
mode: str,
@@ -138,6 +189,15 @@ class ServerConfig:
self.schedule_auto_watch = auto_watch
self.save()
def set_backup(
self,
enabled: bool,
retention_count: int,
) -> None:
self.backup_enabled = enabled
self.backup_retention_count = retention_count
self.save()
def set_and_save_config(
self,
theme: str = None,
@@ -150,6 +210,7 @@ class ServerConfig:
sync_mode: str | None = None,
local_path: str | None = None,
path_rules: list[dict[str, str]] | None = None,
path_mapping: dict | None = None,
) -> None:
if theme is not None:
self.set_theme(theme)
@@ -171,6 +232,8 @@ class ServerConfig:
self.set_local_path(local_path)
if path_rules is not None:
self.set_path_rules(path_rules)
if path_mapping is not None:
self.set_path_mapping(path_mapping)
self.save()
+44
View File
@@ -66,3 +66,47 @@ def scan_local_playlists(base_path: str) -> list[dict]:
playlists.sort(key=lambda item: item["name"].lower())
logger.info(f"Found {len(playlists)} playlists under {absolute_path}.")
return playlists
def write_local_playlist(playlist_path: str, tracks: List[str]) -> bool:
"""
Write a list of tracks to a local playlist file.
Args:
playlist_path (str): The path to the playlist file.
tracks (list): A list of songs to write to the playlist.
Returns:
bool: True if successful, False otherwise.
"""
try:
with open(playlist_path, 'w', encoding="utf-8") as file:
file.write("#EXTM3U\n")
for track in tracks:
file.write(f"{track}\n")
logger.info(f"Written {len(tracks)} songs to the playlist: {playlist_path}")
return True
except Exception as e:
logger.error(f"An error occurred while writing the playlist {playlist_path}: {e}")
return False
def delete_local_playlist(playlist_path: str) -> bool:
"""
Delete a local playlist file.
Args:
playlist_path (str): The path to the playlist file.
Returns:
bool: True if successful, False otherwise.
"""
try:
if os.path.exists(playlist_path):
os.remove(playlist_path)
logger.info(f"Deleted playlist: {playlist_path}")
return True
else:
logger.warning(f"Playlist not found for deletion: {playlist_path}")
return False
except Exception as e:
logger.error(f"An error occurred while deleting the playlist {playlist_path}: {e}")
return False
+243 -13
View File
@@ -40,6 +40,15 @@ class PlaylistSyncResult:
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]:
"""Normalize playlist text into a list of absolute paths.
@@ -72,12 +81,21 @@ def save_paths(paths: Sequence[str]) -> str:
def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]:
"""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]] = []
for rule in rules:
pattern = rule.get("pattern")
# Support both legacy (pattern/replacement) and new (search/replace) field names
# Use explicit None checks to allow empty strings as valid values
pattern = rule.get("pattern") if rule.get("pattern") is not None else rule.get("search")
if not pattern:
continue
replacement = rule.get("replacement", "")
# For replacement, empty string is a valid value (for deletion)
replacement = rule.get("replacement") if rule.get("replacement") is not None else rule.get("replace")
if replacement is None:
replacement = ""
try:
compiled.append((re.compile(pattern), replacement))
except re.error as exc:
@@ -234,9 +252,31 @@ def _merge_chunks(
return chunks
def _write_results(merged_lines: Sequence[str], folder: str) -> None:
_save_playlist_to_folder("local_result.m3u8", merged_lines, folder)
_save_playlist_to_folder("remote_result.m3u8", merged_lines, folder)
def _write_results(
merged_lines: Sequence[str],
folder: str,
compiled_rules: CompiledRegexRules | None = None
) -> None:
"""Write sync results to the test folder.
If compiled_rules is provided with post-processing rules:
- local_result.m3u8: merged_lines processed with local_post rules
- remote_result.m3u8: merged_lines processed with remote_post rules
- base_next.m3u8: unprocessed merged_lines (normalized sync result)
"""
# Apply post-processing regex rules if provided
if compiled_rules and compiled_rules.local_post:
local_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.local_post)
else:
local_lines = list(merged_lines)
if compiled_rules and compiled_rules.remote_post:
remote_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.remote_post)
else:
remote_lines = list(merged_lines)
_save_playlist_to_folder("local_result.m3u8", local_lines, folder)
_save_playlist_to_folder("remote_result.m3u8", remote_lines, folder)
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder)
@@ -379,12 +419,16 @@ def merge_playlists(
remote_text: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
test_folder: str = TEST_PLAYLIST_DIR,
compiled_rules: CompiledRegexRules | None = None,
) -> MergeResult:
"""Merge playlists using diff3 and resolve conflicts per strategy.
The base, local, and remote normalized playlists are saved into ``test_folder``
for inspection. The merged playlist is also stored twice to simulate the
versions intended for local save and cloud upload.
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(
@@ -420,7 +464,7 @@ def merge_playlists(
merged_lines, base_paths, local_paths, remote_paths
)
_write_results(merged_lines, test_folder)
_write_results(merged_lines, test_folder, compiled_rules)
return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
@@ -517,6 +561,7 @@ def _sync_single_playlist(
remote_text: str,
playlist_folder: str,
remote_present: bool,
compiled_rules: CompiledRegexRules | None = None,
) -> PlaylistSyncResult:
local_present = local_text is not None
local_text = local_text or ""
@@ -535,7 +580,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder
)
merged_lines = list(local_paths)
_write_results(merged_lines, playlist_folder)
_write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode == SyncMode.REMOTE_FORCE:
@@ -547,7 +592,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder
)
merged_lines = list(remote_paths)
_write_results(merged_lines, playlist_folder)
_write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY):
@@ -565,6 +610,7 @@ def _sync_single_playlist(
remote_text=remote_text,
strategy=merge_strategy,
test_folder=playlist_folder,
compiled_rules=compiled_rules,
)
if not merge_result.merged_paths and (not local_present or not remote_present):
@@ -578,13 +624,177 @@ 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
# Normalize Windows paths: Replace double backslashes with single backslashes
# This handles cases where users enter escaped paths like \\Koha9-Main\\Music
# when the actual playlist content uses \Koha9-Main\Music
original_local = local_path
original_cloud = cloud_path
local_path = local_path.replace("\\\\", "\\")
cloud_path = cloud_path.replace("\\\\", "\\")
if local_path != original_local or cloud_path != original_cloud:
logger.info(f"Normalized Windows paths:")
logger.info(f" Local: {repr(original_local)} -> {repr(local_path)}")
logger.info(f" Cloud: {repr(original_cloud)} -> {repr(cloud_path)}")
# Create a unique placeholder using the validated UUID
# Using special markers to prevent conflicts with actual paths
placeholder = f"__MAPPING__{mapping_id}__"
# Debug logging for path mapping
logger.debug(f"Simple mapping pair:")
logger.debug(f" Local path (search): {repr(local_path)}")
logger.debug(f" Cloud path (replace): {repr(cloud_path)}")
logger.debug(f" Placeholder: {placeholder}")
# Pre-processing rules (use re.escape to treat paths as literal strings)
# local_pre: Replace local path with placeholder
local_pattern = re.escape(local_path)
logger.debug(f" Local pre pattern: {repr(local_pattern)}")
local_pre_rules.append({
"pattern": local_pattern,
"replacement": placeholder
})
# remote_pre: Replace cloud path with placeholder
remote_pattern = re.escape(cloud_path)
logger.debug(f" Remote pre pattern: {repr(remote_pattern)}")
remote_pre_rules.append({
"pattern": remote_pattern,
"replacement": placeholder
})
# Post-processing rules
# local_post: Replace placeholder with local path
# Note: In regex replacement, backslashes need to be escaped
local_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": local_path.replace("\\", "\\\\")
})
# remote_post: Replace placeholder with cloud path
remote_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": cloud_path.replace("\\", "\\\\")
})
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(
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR
) -> 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()
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)
logger.info(f"Syncing playlists to test folder: {test_folder}")
local_playlists = _load_local_playlists(local_dir)
@@ -613,15 +823,34 @@ def sync_all_playlists(
remote_text = snapshot_remote_text
remote_present = bool(remote_text.strip()) or remote_exists
if compiled_rules:
# Apply pre-processing rules for REGEX or SIMPLE mode
# base_text doesn't need pre-processing as it's the normalized state
if local_text is not None and compiled_rules.local_pre:
logger.debug(f"Applying local_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(local_text[:200])}")
local_text = preprocess_playlist_text(
local_text, [], compiled_rules.local_pre
)
logger.debug(f" After preprocessing (first 200 chars): {repr(local_text[:200])}")
if remote_text and compiled_rules.remote_pre:
logger.debug(f"Applying remote_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(remote_text[:200])}")
remote_text = preprocess_playlist_text(
remote_text, [], compiled_rules.remote_pre
)
logger.debug(f" After preprocessing (first 200 chars): {repr(remote_text[:200])}")
elif legacy_compiled_rules:
# Use legacy preprocessing for all texts
base_text = preprocess_playlist_text(
base_text, server_config.path_rules, compiled_rules
base_text, server_config.path_rules, legacy_compiled_rules
)
remote_text = preprocess_playlist_text(
remote_text, server_config.path_rules, compiled_rules
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, compiled_rules
local_text, server_config.path_rules, legacy_compiled_rules
)
# Treat missing remote text as absent playlist.
@@ -633,6 +862,7 @@ def sync_all_playlists(
remote_text=remote_text,
playlist_folder=playlist_folder,
remote_present=remote_present,
compiled_rules=compiled_rules,
)
results.append(result)
+90
View File
@@ -311,4 +311,94 @@ class PlexClient:
)
return local_2_plex
def get_playlist(self, title: str):
"""Get a playlist by title."""
self._connect_check()
try:
# Exact match search for playlist
playlists = self.server.playlists(title=title)
if playlists:
return playlists[0]
return None
except Exception as e:
logger.error(f"Error fetching playlist {title}: {e}")
return None
def create_playlist(self, title: str, items: list):
"""Create a new playlist with the given items."""
self._connect_check()
try:
self.server.createPlaylist(title, items=items)
logger.info(f"Created playlist {title} with {len(items)} items.")
return True
except Exception as e:
logger.error(f"Error creating playlist {title}: {e}")
return False
def delete_playlist(self, title: str):
"""Delete a playlist by title."""
self._connect_check()
try:
playlist = self.get_playlist(title)
if playlist:
playlist.delete()
logger.info(f"Deleted playlist {title}.")
return True
else:
logger.warning(f"Playlist {title} not found for deletion.")
return False
except Exception as e:
logger.error(f"Error deleting playlist {title}: {e}")
return False
def update_playlist(self, title: str, items: list):
"""
Update a playlist with a new list of items.
This implementation replaces the existing items with the new ones.
"""
self._connect_check()
try:
playlist = self.get_playlist(title)
if not playlist:
return self.create_playlist(title, items)
# Remove all items and add new ones
playlist.removeItems(playlist.items())
if items:
playlist.addItems(items)
logger.info(f"Updated playlist {title} with {len(items)} items.")
return True
except Exception as e:
logger.error(f"Error updating playlist {title}: {e}")
return False
def get_items_by_paths(self, library_name: str, paths: list[str]) -> list:
"""
Find Plex items (tracks) by their file paths.
"""
self._connect_check()
if not paths:
return []
try:
path_map = self.match_tracks(library_name, paths)
except FileNotFoundError:
logger.info(f"Cache not found for {library_name}, creating it...")
self.cache_lib_tracks(library_name)
path_map = self.match_tracks(library_name, paths)
items = []
for path in paths:
rating_key = path_map.get(path)
if rating_key and rating_key != UNMATCHED_TRACK_RATING_KEY:
try:
item = self.server.fetchItem(rating_key)
items.append(item)
except Exception as e:
logger.warning(f"Failed to fetch item for ratingKey {rating_key}: {e}")
else:
logger.warning(f"Track not found in Plex library (or unmatched): {path}")
return items
plex_client = PlexClient()
+1 -1
View File
@@ -143,7 +143,7 @@ def update_scheduler_job():
trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time)
if trigger:
scheduler.add_job(job_function, trigger)
scheduler.add_job(job_function, trigger, misfire_grace_time=60, coalesce=True)
logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
else:
logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.")
+55 -1
View File
@@ -1,10 +1,14 @@
import threading
import asyncio
import json
import os
from datetime import datetime
from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode
from app.utils.config import server_config
from app.utils.backup import perform_backup_before_sync
from app.utils.local_playlist import load_local_playlist, write_local_playlist, delete_local_playlist
from app.utils.plex_client import plex_client
class SyncManager:
def __init__(self):
@@ -110,8 +114,58 @@ class SyncManager:
if sync_kwargs:
kwargs.update(sync_kwargs)
# Perform backup before sync if enabled
local_dir = kwargs.get("local_dir", server_config.local_path)
perform_backup_before_sync(local_dir, server_config.library_name)
# Execute sync
return sync_all_playlists(**kwargs)
results = sync_all_playlists(**kwargs)
# Apply results (write to local and remote)
self._apply_sync_results(results)
return results
def _apply_sync_results(self, results):
logger.info("Applying sync results to local and remote...")
for result in results:
playlist_name = result.name
action = result.action
output_dir = result.output_dir
try:
if action == "synced":
# 1. Write Local
local_result_path = os.path.join(output_dir, "local_result.m3u8")
if os.path.exists(local_result_path):
tracks = load_local_playlist(local_result_path)
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
# Ensure directory exists
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
write_local_playlist(dest_path, tracks)
# 2. Write Remote (Plex)
remote_result_path = os.path.join(output_dir, "remote_result.m3u8")
if os.path.exists(remote_result_path):
tracks = load_local_playlist(remote_result_path)
if server_config.library_name:
items = plex_client.get_items_by_paths(server_config.library_name, tracks)
plex_client.update_playlist(playlist_name, items)
else:
logger.warning("Library name not configured, skipping Plex update.")
elif action == "deleted":
# Delete Local
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
delete_local_playlist(dest_path)
# Also check for .m3u
dest_path_m3u = os.path.join(server_config.local_path, f"{playlist_name}.m3u")
delete_local_playlist(dest_path_m3u)
# Delete Remote
plex_client.delete_playlist(playlist_name)
except Exception as e:
logger.error(f"Error applying sync result for playlist {playlist_name}: {e}")
def _complete_sync(self, status, error=None):
with self._lock:
+3 -2
View File
@@ -5,10 +5,11 @@ services:
ports:
- "8888:8080"
volumes:
- ./output_playlists:/app/app/test_playlists
- ./test_case/local_playlist:/app/playlist:ro
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
- PATH_TO_YOUR_BACKUP:/app/backup
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- LOG_LEVEL=INFO
- TZ=${TZ:-Asia/Tokyo}
restart: unless-stopped
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
# Configure timezone inside the container if TZ is provided.
# This avoids relying on host mounts like /etc/localtime, which are awkward on Windows.
if [ "${TZ:-}" != "" ]; then
ZONEINFO="/usr/share/zoneinfo/${TZ}"
if [ -e "$ZONEINFO" ]; then
ln -snf "$ZONEINFO" /etc/localtime
echo "$TZ" > /etc/timezone
else
echo "[entrypoint] Warning: TZ='$TZ' not found at $ZONEINFO; keeping existing timezone." >&2
fi
fi
exec "$@"
+211 -63
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api';
import {
STRIPE_BASE_SPEED,
@@ -10,15 +9,15 @@ import {
SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS,
SYNC_BANNER_PADDING_X,
SYNC_BANNER_PADDING_Y,
SYNC_BANNER_MIN_WIDTH,
TOAST_EXIT_DURATION_MS
} from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react';
import OverflowMarquee from './components/OverflowMarquee';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
id: number;
@@ -115,6 +114,7 @@ const useStripeAnimation = (syncState: SyncState) => {
};
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -137,12 +137,22 @@ const App: React.FC = () => {
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
// Path Mapping State (Includes Simple and Regex Rules)
const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
@@ -155,6 +165,12 @@ const App: React.FC = () => {
});
const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined);
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -226,7 +242,7 @@ const App: React.FC = () => {
const result = await apiService.getSettings();
if (result.status === 'success') {
setCurrentStrategy(result.data.strategy);
setRegexReplacements(result.data.regex);
setPathMappingConfig(result.data.pathMapping);
setLocalPath(result.data.localPath || 'playlist');
setConnectionSettings(result.data.connection);
}
@@ -240,6 +256,13 @@ const App: React.FC = () => {
}
}, []);
const loadBackupSettings = useCallback(async () => {
const result = await apiService.getBackupSettings();
if (result.status === 'success') {
setBackupSettings(result.data);
}
}, []);
// Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
const result = await apiService.saveScheduleSettings(settings);
@@ -250,17 +273,30 @@ const App: React.FC = () => {
loadSchedule();
if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled.");
addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast(t('toasts.scheduleEmpty'));
} else {
addToast("Scheduled task updated successfully.");
addToast(t('toasts.scheduleStarted'));
}
return true;
} else {
addToast(result.message || "Failed to update schedule.");
addToast(result.message || t('toasts.scheduleFailed'));
return false;
}
};
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast(t('toasts.backupSaved'));
} else {
addToast(result.message || t('toasts.backupFailed'));
}
};
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (localAbortRef.current) localAbortRef.current.abort();
@@ -281,7 +317,7 @@ const App: React.FC = () => {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast("Local refresh cancelled.");
addToast(t('toasts.localRefreshCancelled'));
}
};
@@ -315,7 +351,7 @@ const App: React.FC = () => {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast("Cloud refresh cancelled.");
addToast(t('toasts.cloudRefreshCancelled'));
}
};
@@ -323,7 +359,8 @@ const App: React.FC = () => {
useEffect(() => {
loadSettings();
loadSchedule();
}, [loadSettings, loadSchedule]);
loadBackupSettings();
}, [loadSettings, loadSchedule, loadBackupSettings]);
// Initial Load
useEffect(() => {
@@ -341,20 +378,20 @@ const App: React.FC = () => {
setCurrentStrategy(strategy);
const result = await apiService.updateSyncStrategy(strategy);
if (result.status === 'success') {
addToast(`Selected strategy "${label}" has been saved.`);
addToast(t('toasts.strategySaved', { strategy: label }));
} else {
addToast(result.message || 'Failed to save sync strategy.');
addToast(result.message || t('toasts.strategySaveFailed'));
}
};
// Handle Regex Save
const handleSaveRegex = async (replacements: RegexReplacement[]) => {
setRegexReplacements(replacements);
const result = await apiService.saveRegexRules(replacements);
// Handle Path Mapping Save
const handleSavePathMapping = async (config: PathMappingConfig) => {
setPathMappingConfig(config);
const result = await apiService.savePathMapping(config);
if (result.status === 'success') {
addToast('Regex preprocessing rules have been saved.');
addToast(t('toasts.mappingSaved'));
} else {
addToast(result.message || 'Failed to save regex rules.');
addToast(result.message || t('toasts.mappingSaveFailed'));
}
};
@@ -365,7 +402,7 @@ const App: React.FC = () => {
setSyncState(SyncState.SYNCING);
manualSyncInProgress.current = true;
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined);
const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig, localPath || undefined);
manualSyncInProgress.current = false;
@@ -379,7 +416,7 @@ const App: React.FC = () => {
}, SYNC_SUCCESS_TOTAL_MS);
} else {
setSyncState(SyncState.ERROR);
addToast(result.message || 'Sync failed. Please check connection.');
addToast(result.message || t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
@@ -426,11 +463,11 @@ const App: React.FC = () => {
setSyncState(SyncState.SUCCESS);
refreshLocal();
refreshCloud();
addToast("Background sync completed successfully.");
addToast(t('toasts.backgroundSyncSuccess'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
} else if (status === 'error') {
setSyncState(SyncState.ERROR);
addToast(`Background sync failed: ${error}`);
addToast(t('toasts.backgroundSyncFailed', { error: error || '' }));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
} else {
@@ -488,31 +525,85 @@ const App: React.FC = () => {
const getScheduleDisplayInfo = () => {
const result = {
label: 'Schedule',
value: 'Not configured',
label: t('dashboard.autoSync'),
value: t('schedule.notConfigured'),
active: false,
autoWatch: scheduleSettings.autoWatch
autoWatch: scheduleSettings.autoWatch,
};
if (scheduleSettings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync';
result.value = 'Disabled';
result.value = t('common.disabled');
return result;
}
let label = 'Schedule';
if (scheduleSettings.mode === ScheduleMode.CRON) label = 'Cron Schedule';
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule';
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule';
if (scheduleSettings.mode === ScheduleMode.CRON && scheduleSettings.cronExpression.trim() === '') {
result.value = t('dashboard.notSet');
} else {
result.value = nextRunTime ? `${nextRunTime}` : t('common.loading');
}
result.label = label;
result.value = nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...';
result.active = true;
return result;
};
const scheduleInfo = getScheduleDisplayInfo();
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
count = config.simple.length;
Icon = Type;
} else {
count =
config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon,
};
}
const modeLabel = config.mode === PathMappingMode.SIMPLE ? t('mapping.simple') : t('mapping.regex');
return {
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon,
};
};
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false,
};
}
return {
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true,
};
};
const backupInfo = getBackupDisplayInfo(backupSettings);
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">
@@ -574,7 +665,7 @@ const App: React.FC = () => {
{syncState === SyncState.IDLE ? (
<>
{/* Normal Toolbar */}
{/* Normal Toolbar Left */}
<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">
<ArrowLeftRight size={24} strokeWidth={2.5} />
@@ -586,45 +677,100 @@ const App: React.FC = () => {
{/* Normal Toolbar Right */}
<div className="flex items-center gap-4">
{/* Schedule Info */}
<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">
{scheduleInfo.label}
</span>
<div className="text-xs font-mono flex items-center gap-1.5">
{/* Schedule Part */}
<div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{scheduleInfo.active && <Clock size={12} />}
<span>{scheduleInfo.value}</span>
{/* Unified Status Dock */}
<div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
{/* Path Mapping Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[120px] group/item">
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{pathMappingInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
<pathMappingInfo.Icon size={12} strokeWidth={2.5} className="flex-shrink-0" />
<OverflowMarquee>
{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
</OverflowMarquee>
</div>
</div>
{/* Watch Part */}
<span className="text-gray-700 mx-0.5">|</span>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[100px] group/item">
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${backupInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{backupInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
<Archive size={12} strokeWidth={2.5} className="flex-shrink-0" />
<OverflowMarquee>
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
</OverflowMarquee>
</div>
</div>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 w-[180px] group/item">
<div className="flex items-center justify-between">
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{scheduleInfo.label}</span>
{/* Watch Indicator Badge */}
<div
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"}
className={`flex items-center gap-1 px-1 rounded-[2px] transition-colors ${scheduleInfo.autoWatch ? 'text-plex-orange bg-plex-orange/10' : 'text-gray-700 bg-gray-800'}`}
title={scheduleInfo.autoWatch ? t('dashboard.watchModeActive') : t('dashboard.watchModeDisabled')}
>
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />}
<span className="text-[10px] font-sans font-bold">WATCH</span>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">{t('dashboard.watch')}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
<Clock size={12} strokeWidth={2.5} className="flex-shrink-0" />
<OverflowMarquee>
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
</OverflowMarquee>
</div>
</div>
</div>
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('common.switchLanguage')}
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md ${
isConnected
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</div>
</>
) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div
className="bg-black shadow-none rounded-none border-none"
@@ -634,7 +780,7 @@ const App: React.FC = () => {
}}
>
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}
{syncState === SyncState.SYNCING ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1>
</div>
</div>
@@ -691,8 +837,10 @@ const App: React.FC = () => {
<StrategySelector
currentStrategy={currentStrategy}
onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex}
savedPathMapping={pathMappingConfig}
onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState}
@@ -717,7 +865,7 @@ const App: React.FC = () => {
{/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
<p>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer>
{/* Modals */}
+63
View File
@@ -0,0 +1,63 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { translations, Language } from './translations';
interface LanguageContextProps {
language: Language;
setLanguage: (lang: Language) => void;
t: (path: string, params?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextProps | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguageState] = useState<Language>('en');
useEffect(() => {
const savedLang = localStorage.getItem('plexsync-language') as Language;
if (savedLang && translations[savedLang]) {
setLanguageState(savedLang);
}
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('plexsync-language', lang);
};
const t = (path: string, params?: Record<string, string | number>): string => {
const keys = path.split('.');
let current: any = translations[language];
for (const key of keys) {
if (current[key] === undefined) {
console.warn(`Missing translation for key: ${path} in language: ${language}`);
return path;
}
current = current[key];
}
let text = current as string;
if (params) {
Object.entries(params).forEach(([key, value]) => {
text = text.replace(`{${key}}`, String(value));
});
}
return text;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+44 -30
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps {
isOpen: boolean;
@@ -13,6 +14,7 @@ interface ConnectionModalProps {
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
@@ -35,10 +37,12 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null);
const prevIsOpenRef = useRef(isOpen);
// Reset state when opening
useEffect(() => {
if (isOpen) {
// Only execute reset logic when modal opens (isOpen changes from false to true)
if (isOpen && !prevIsOpenRef.current) {
setError(null);
setConnectedServerInfo(null);
setLibraries([]);
@@ -54,12 +58,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
}));
}
}
return () => {
// Cleanup any pending request if modal closes
// Cleanup when closing
if (!isOpen && prevIsOpenRef.current) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}
prevIsOpenRef.current = isOpen;
}, [isOpen, initialSettings]);
if (!isOpen) return null;
@@ -85,9 +92,9 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(updatedInfo);
const saveResult = await apiService.updateLibrary(lib.title);
if (saveResult.status !== 'success') {
onShowMessage(saveResult.message || 'Failed to save library selection');
onShowMessage(saveResult.message || t('toasts.librarySaveFailed'));
} else {
onShowMessage(`Library switched to ${lib.title}`);
onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
}
}
};
@@ -107,7 +114,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError("Connection cancelled by user.");
setError(t('toasts.connectionCancelled'));
}
return;
}
@@ -136,13 +143,14 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || [];
setLibraries(libs);
if (libs.length > 0) {
const musicLibraries = libs.filter((lib) => lib.type === 'artist').sort((a, b) => a.title.localeCompare(b.title));
setLibraries(musicLibraries);
if (musicLibraries.length > 0) {
const preferred = info.libraryName || formData.libraryName;
const defaultLib = libs.find(lib => lib.title === preferred) || libs[0];
const defaultLib = musicLibraries.find(lib => lib.title === preferred) || musicLibraries[0];
setSelectedLibraryId(defaultLib.id);
setFormData(prev => ({ ...prev, libraryName: defaultLib.title }));
onConnectSuccess({
@@ -151,27 +159,33 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
});
const saveResult = await apiService.updateLibrary(defaultLib.title);
if (saveResult.status !== 'success') {
setError(saveResult.message || 'Failed to save library selection');
setError(saveResult.message || t('toasts.librarySaveFailed'));
}
} else {
onConnectSuccess(info);
}
} else {
setError(result.message || "Connection failed");
setError(result.message || t('server.connectionFailed'));
}
};
const isConnected = !!connectedServerInfo;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
{isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
@@ -190,7 +204,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
@@ -214,7 +228,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
name="address"
required
disabled={isConnected || isConnecting}
placeholder="IP Address or Domain"
placeholder={t('connection.address')}
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -228,7 +242,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)"
placeholder={t('connection.port')}
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -240,7 +254,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */}
<div className="relative">
@@ -251,7 +265,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)"
placeholder={t('connection.token')}
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -273,7 +287,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder="Username / Email"
placeholder={t('connection.username')}
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -289,7 +303,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder="Password"
placeholder={t('connection.password')}
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -317,7 +331,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>Advanced Options</span>
<span>{t('connection.advanced')}</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
@@ -325,7 +339,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label>
<label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input
type="number"
min="1"
@@ -354,15 +368,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
<span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</>
) : 'Connect Server'}
) : t('connection.connectBtn')}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
Connected Successfully
{t('connection.connectedSuccess')}
</p>
</div>
)}
@@ -371,7 +385,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
@@ -395,7 +409,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
>
Done
{t('common.done')}
</button>
</div>
</div>
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
type OverflowMarqueeProps = {
children: React.ReactNode;
className?: string;
textClassName?: string;
title?: string;
speedPxPerSec?: number;
minDurationSec?: number;
};
type MarqueeMetrics = {
isOverflowing: boolean;
overflowPx: number;
durationSec: number;
};
const DEFAULT_SPEED_PX_PER_SEC = 24;
const DEFAULT_MIN_DURATION_SEC = 4;
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
children,
className,
textClassName,
title,
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
minDurationSec = DEFAULT_MIN_DURATION_SEC,
}) => {
const containerRef = useRef<HTMLSpanElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [metrics, setMetrics] = useState<MarqueeMetrics>({
isOverflowing: false,
overflowPx: 0,
durationSec: minDurationSec,
});
const fallbackTitle = useMemo(() => {
if (title) return title;
return typeof children === 'string' ? children : undefined;
}, [children, title]);
const recompute = () => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Ensure we measure with current layout.
const available = container.clientWidth;
const content = text.scrollWidth;
const overflowPx = Math.ceil(content - available);
if (overflowPx > 1) {
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
setMetrics({ isOverflowing: true, overflowPx, durationSec });
} else {
// Avoid re-render loops if already not overflowing.
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
}
};
useLayoutEffect(() => {
recompute();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children]);
useEffect(() => {
recompute();
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
const ro = new ResizeObserver(() => recompute());
ro.observe(container);
ro.observe(text);
const onResize = () => recompute();
window.addEventListener('resize', onResize);
return () => {
ro.disconnect();
window.removeEventListener('resize', onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
? ({
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
['--marquee-duration' as any]: `${metrics.durationSec}s`,
} satisfies React.CSSProperties)
: undefined;
return (
<span
ref={containerRef}
className={['overflow-marquee', className].filter(Boolean).join(' ')}
title={fallbackTitle}
>
<span
ref={textRef}
className={[
'overflow-marquee__text',
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
textClassName,
]
.filter(Boolean)
.join(' ')}
style={textStyle}
>
{children}
</span>
</span>
);
};
export default OverflowMarquee;
+8 -3
View File
@@ -1,26 +1,31 @@
import React from 'react';
import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface PlaylistCardProps {
playlist: Playlist;
}
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
<h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
<OverflowMarquee>
{playlist.title}
</OverflowMarquee>
</h4>
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count">
<span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title="Last Updated">
<span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
+19 -11
View File
@@ -3,6 +3,8 @@ import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface ServerPanelProps {
type: ServerType;
@@ -14,6 +16,7 @@ interface ServerPanelProps {
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud;
@@ -28,39 +31,44 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
let displaySubtitle: React.ReactNode = null;
if (isLocal) {
displayTitle = 'Local Server';
displayTitle = t('server.local');
displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists
{t('server.playlists', { count: playlists.length })}
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server';
displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
<span className="text-plex-orange font-semibold min-w-0 max-w-full">
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
<OverflowMarquee className="hidden md:inline-block">
{serverInfo.libraryName}
</OverflowMarquee>
</span>
<span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div>
);
} else {
displayTitle = 'Not Connected';
displayTitle = t('server.notConnected');
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed
{t('server.connectionFailed')}
</p>
);
}
} else {
displayTitle = 'Cloud Server';
displayTitle = t('server.cloud');
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'}
{isLoading ? t('server.connecting') : t('server.waiting')}
</p>
);
}
@@ -121,7 +129,7 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"}
title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
@@ -141,11 +149,11 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p>
<p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p>
<p className="text-sm">{t('server.noPlaylists')}</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
+498 -190
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from '../types';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import {
ArrowRightCircle,
ArrowLeftCircle,
@@ -16,14 +16,33 @@ import {
Calendar,
Clock,
Repeat,
CheckSquare,
Square
Type,
Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react';
import { useLanguage } from '../LanguageContext';
// Generate a UUID for mapping rules
const generateUUID = (): string => {
// Use crypto.randomUUID() if available (modern browsers)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback to manual UUID v4 generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
interface StrategyOption {
value: SyncStrategy;
label: string;
description: string;
labelKey: string;
descKey: string;
icon: React.ElementType;
color: string;
}
@@ -31,29 +50,29 @@ interface StrategyOption {
const STRATEGIES: StrategyOption[] = [
{
value: SyncStrategy.LOCAL_OVERWRITE,
label: 'Local Overwrite',
description: 'Local playlist completely overwrites Cloud. (No Diff)',
labelKey: 'strategies.localOverwrite.label',
descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle,
color: 'text-blue-400'
},
{
value: SyncStrategy.CLOUD_OVERWRITE,
label: 'Cloud Overwrite',
description: 'Cloud playlist completely overwrites Local. (No Diff)',
labelKey: 'strategies.cloudOverwrite.label',
descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle,
color: 'text-green-400'
},
{
value: SyncStrategy.MERGE_LOCAL,
label: 'Two-way Merge (Local Priority)',
description: 'Merge both. Conflicts resolve to Local version.',
labelKey: 'strategies.mergeLocal.label',
descKey: 'strategies.mergeLocal.desc',
icon: GitMerge,
color: 'text-blue-300'
},
{
value: SyncStrategy.MERGE_CLOUD,
label: 'Two-way Merge (Cloud Priority)',
description: 'Merge both. Conflicts resolve to Cloud version.',
labelKey: 'strategies.mergeCloud.label',
descKey: 'strategies.mergeCloud.desc',
icon: GitMerge,
color: 'text-green-300'
}
@@ -61,30 +80,154 @@ const STRATEGIES: StrategyOption[] = [
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
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule };
if (tab === ScheduleMode.CRON) {
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED;
} else {
// For Daily/Weekly
// If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was CRON or DISABLED), then in the context of this tab, it is effectively Disabled until the user checks the box.
// Unified logic: If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was DISABLED), then in the context of this tab, it remains Disabled until the user toggles the switch.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
}
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 { t } = useLanguage();
const handleAdd = () => {
if (isLocked) return;
const newId = generateUUID();
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={t('common.add')}
>
<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">
{t('mapping.noRules')}
</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 || t('mapping.pattern')}
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}`}
/>
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder || t('mapping.replace')}
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 {
currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void;
savedRegexReplacements: RegexReplacement[];
onSaveRegex: (replacements: RegexReplacement[]) => void;
savedPathMapping: PathMappingConfig;
onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState;
@@ -94,27 +237,33 @@ interface StrategySelectorProps {
const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy,
onSelect,
savedRegexReplacements,
onSaveRegex,
savedPathMapping,
onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule,
onSaveSchedule,
syncState,
onSync
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for regex editing
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
const [isRegexDirty, setIsRegexDirty] = useState(false);
// Local state for path mapping editing (stores all lists for both modes)
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isMappingDirty, setIsMappingDirty] = useState(false);
// Local state for Backup Settings
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
const [isBackupDirty, setIsBackupDirty] = useState(false);
// Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs
// We initialize active tab based on the saved mode. If DISABLED, default to CRON.
const [activeTab, setActiveTab] = useState<ScheduleMode>(
const [activeScheduleTab, setActiveScheduleTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
);
@@ -123,32 +272,41 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Initialize local state when prop updates
useEffect(() => {
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
setIsRegexDirty(false);
}, [savedRegexReplacements]);
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
setIsMappingDirty(false);
}, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
// If the saved mode is not disabled, ensure we show that tab.
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveTab(savedSchedule.mode);
setActiveScheduleTab(savedSchedule.mode);
}
setIsScheduleDirty(false);
}, [savedSchedule]);
// Check dirty state whenever local changes
// Check dirty state whenever local mapping changes
useEffect(() => {
const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements);
setIsRegexDirty(isDifferent);
}, [localReplacements, savedRegexReplacements]);
const isDifferent = JSON.stringify(localPathMapping) !== JSON.stringify(savedPathMapping);
setIsMappingDirty(isDifferent);
}, [localPathMapping, savedPathMapping]);
// Check dirty state for backup
useEffect(() => {
const isDifferent = JSON.stringify(localBackup) !== JSON.stringify(savedBackup);
setIsBackupDirty(isDifferent);
}, [localBackup, savedBackup]);
// Check dirty state for Schedule (including Active Tab changes)
useEffect(() => {
// We calculate what the "effective" schedule would be if we saved right now.
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeTab);
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeTab]);
}, [localSchedule, savedSchedule, activeScheduleTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
@@ -162,45 +320,84 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 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 isScheduleActionable = isScheduleDirty || (activeScheduleTab !== (savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode));
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, strategy.label);
onSelect(strategy.value, t(strategy.labelKey));
};
// --- Regex Handlers ---
const handleAddRegex = () => {
// --- Path Mapping Handlers ---
const currentMappingMode = localPathMapping.mode;
const updateRegexGroup = (section: keyof PathMappingRules, newRules: ReplacementRule[]) => {
if (isLocked) return;
const newId = Date.now().toString();
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
setLocalPathMapping(prev => ({
...prev,
regex: {
...prev.regex,
[section]: newRules
}
}));
};
const handleDeleteRegex = (id: string) => {
const updateSimpleGroup = (newRules: ReplacementRule[]) => {
if (isLocked) return;
setLocalReplacements(prev => prev.filter(r => r.id !== id));
setLocalPathMapping(prev => ({
...prev,
simple: newRules
}));
};
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
const setMappingMode = (mode: PathMappingMode) => {
if (isLocked) return;
setLocalReplacements(prev => prev.map(r =>
r.id === id ? { ...r, [field]: value } : r
));
setLocalPathMapping(prev => ({ ...prev, mode }));
};
const handleResetRegex = () => {
const handleResetMapping = () => {
if (isLocked) return;
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
};
const handleSaveRegex = () => {
const handleSaveMappingClick = () => {
if (isLocked) return;
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
setLocalReplacements(validReplacements);
onSaveRegex(validReplacements);
const clean = (rules: ReplacementRule[]) => rules.filter(r => r.search.trim() !== '');
// Clean regex rules
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;
// --- Backup Handlers ---
const handleUpdateBackup = (field: keyof BackupSettings, value: any) => {
if (isLocked) return;
setLocalBackup(prev => ({ ...prev, [field]: value }));
};
const handleResetBackup = () => {
if (isLocked) return;
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
};
const handleSaveBackupClick = () => {
if (isLocked) return;
onSaveBackup(localBackup);
};
// --- Schedule Handlers ---
@@ -222,24 +419,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveTab(savedSchedule.mode);
setActiveScheduleTab(savedSchedule.mode);
} else {
setActiveTab(ScheduleMode.CRON);
setActiveScheduleTab(ScheduleMode.CRON);
}
};
const handleSaveScheduleClick = async () => {
if (isLocked) return;
// Determine the effective settings based on the current view (tab) and inputs
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeTab);
// Call API
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const success = await onSaveSchedule(settingsToSave);
if (success) {
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.
}
};
@@ -248,7 +439,6 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSync();
};
// Helper to toggle enable/disable for current active tab (Daily/Weekly)
const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return;
if (localSchedule.mode === targetMode) {
@@ -258,7 +448,6 @@ 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";
return (
@@ -267,7 +456,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`}
title={`${t('strategies.title')}: ${t(selectedOption.labelKey)}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
@@ -279,12 +468,13 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div
className={`absolute
top-14
/* Mobile: Open to left */
right-0 origin-top-right
/* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
/* Mobile: Open to left (max width of screen) */
right-0 w-[90vw] max-w-[90vw] origin-top-right
w-80 md:w-[32rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
/* Desktop: Center alignment, wider */
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
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
>
@@ -292,7 +482,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
@@ -307,7 +497,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
{t(strategy.labelKey)}
</span>
</div>
@@ -315,7 +505,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
{t(strategy.descKey)}
</div>
</div>
@@ -328,117 +518,220 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div>
</div>
{/* Section 2: Regex Preprocessing */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
{/* Section 1.5: Backup Retention */}
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
{localReplacements.length === 0 && (
<button
onClick={handleAddRegex}
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={14} />
</button>
)}
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('backup.title')}</h3>
</div>
<div className="space-y-2 mb-4 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
{localReplacements.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No regex replacements configured.
<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400">
<Archive size={16} />
</div>
) : (
localReplacements.map((regex) => (
<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">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('backup.enable')}</span>
<span className="text-[10px] text-gray-500">{t('backup.enableDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateBackup('enabled', !localBackup.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localBackup.enabled ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localBackup.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Expanded Config */}
<div className={`overflow-hidden transition-all duration-300 ${localBackup.enabled ? 'max-h-20 opacity-100' : 'max-h-0 opacity-50'}`}>
<div className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
<div className="flex items-center space-x-2">
<History size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">{t('backup.maxVersions')}</span>
</div>
<div className="flex items-center space-x-2">
<input
type="text"
placeholder="Pattern"
value={regex.pattern}
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
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
${!regex.pattern && isRegexDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
type="number"
min="0"
max="100"
value={localBackup.retentionCount}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
handleUpdateBackup('retentionCount', isNaN(value) ? 0 : Math.max(0, value));
}}
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
/>
<span className="text-[10px] text-gray-600 italic">{localBackup.retentionCount === 0 ? t('backup.noAutoDelete') : t('backup.autoDelete')}</span>
</div>
<div className="flex-none text-gray-600">
<ArrowRightCircle size={12} />
</div>
<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">
<div className="flex justify-end items-center gap-2 pt-1">
<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>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleResetRegex}
disabled={!isRegexDirty}
onClick={handleResetBackup}
disabled={!isBackupDirty}
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
${isBackupDirty
? '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>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveRegex}
disabled={!isRegexDirty}
onClick={handleSaveBackupClick}
disabled={!isBackupDirty}
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
${isBackupDirty
? '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>
<span>{t('common.save')}</span>
</button>
</div>
</div>
</div>
{/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('mapping.title')}</h3>
</div>
{/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => (
<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={t('mapping.simpleTitle')}
subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder={t('mapping.localPath')}
rightPlaceholder={t('mapping.cloudPath')}
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={t('server.local')}
subtitle={t('mapping.regexPre')}
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title={t('server.cloud')}
subtitle={t('mapping.regexPre')}
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={t('server.local')}
subtitle={t('mapping.regexPost')}
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title={t('server.cloud')}
subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
</div>
)}
</div>
<div className="flex justify-end items-center gap-2">
<button
onClick={handleResetMapping}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isMappingDirty
? '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>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveMappingClick}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isMappingDirty
? '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>{t('mapping.saveRules')}</span>
</button>
</div>
</div>
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
{ id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
onClick={() => setActiveScheduleTab(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${activeTab === tab.id
${activeScheduleTab === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
@@ -451,8 +744,21 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Tab Content */}
<div className="mb-4 min-h-[50px]">
{activeTab === ScheduleMode.CRON && (
<div className="space-y-2 animate-in fade-in duration-200">
{activeScheduleTab === ScheduleMode.CRON && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableCron')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.CRON ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Content */}
<div className={`space-y-2 transition-opacity duration-200 ${localSchedule.mode !== ScheduleMode.CRON ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span>
<input
@@ -460,27 +766,28 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
value={localSchedule.cronExpression}
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)}
placeholder="0 0 * * *"
disabled={localSchedule.mode !== ScheduleMode.CRON}
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 font-mono focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange placeholder-gray-600"
/>
</div>
<p className="text-[10px] text-gray-500">
Unix-cron format. Leave empty to disable schedule.
Unix-cron format.
</p>
</div>
</div>
)}
{activeTab === ScheduleMode.DAILY && (
{activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableDaily')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.DAILY ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />}
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.DAILY ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div>
{/* Bottom Row: Centered Native Time Input */}
@@ -496,18 +803,17 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div>
)}
{activeTab === ScheduleMode.WEEKLY && (
{activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.WEEKLY ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />}
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.WEEKLY ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div>
{/* Middle Row: Full Width Capsules */}
@@ -546,24 +852,26 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
)}
</div>
{/* Auto Watch Checkbox */}
<div className="flex items-center mb-4 px-1">
{/* Auto Watch Switch */}
<div className="flex items-center justify-between mb-4 mt-2 px-1">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 text-orange-400">
<Eye size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
className="flex items-center space-x-2 group"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.autoWatch ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
{localSchedule.autoWatch ? (
<CheckSquare size={16} className="text-plex-orange" />
) : (
<Square size={16} className="text-gray-600 group-hover:text-gray-400" />
)}
<span className={`text-xs ${localSchedule.autoWatch ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'}`}>
Watch for local playlist changes
</span>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Action Buttons (Mirrored from Regex) */}
{/* Action Buttons */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button
onClick={handleResetSchedule}
@@ -574,7 +882,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveScheduleClick}
@@ -585,7 +893,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
<span>{t('common.save')}</span>
</button>
</div>
</div>
@@ -599,7 +907,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
${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isRegexDirty
: isMappingDirty || isBackupDirty
? '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]'
}`}
@@ -607,18 +915,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span>
<span>{t('strategies.syncing')}</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>Sync Now</span>
<span>{t('strategies.syncNow')}</span>
</>
)}
</button>
{(isRegexDirty) && (
{(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2">
Please save regex changes before syncing.
{t('strategies.saveWarning')}
</p>
)}
</div>
+35
View File
@@ -36,6 +36,41 @@
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Overflow marquee (auto-scroll when truncated) */
.overflow-marquee {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.overflow-marquee__text {
display: inline-block;
will-change: transform;
}
.overflow-marquee__text--animate {
animation: overflow-marquee-scroll var(--marquee-duration, 6s) linear infinite;
}
@keyframes overflow-marquee-scroll {
0%, 12% {
transform: translateX(0);
}
70%, 86% {
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.overflow-marquee__text--animate {
animation: none;
}
}
</style>
<script type="importmap">
{
+3
View File
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
@@ -10,6 +11,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode>
);
+152
View File
@@ -0,0 +1,152 @@
export const en = {
app: {
title: 'PlexSync',
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
common: {
save: 'Save',
cancel: 'Cancel',
revert: 'Revert',
delete: 'Delete',
done: 'Done',
loading: 'Loading...',
refresh: 'Refresh',
close: 'Close',
none: 'None',
disabled: 'Disabled',
add: 'Add',
switchLanguage: 'Switch Language',
},
server: {
local: 'Local Server',
cloud: 'Cloud Server',
playlists: '{count} Playlists',
notConnected: 'Not Connected',
connectionFailed: 'Connection failed',
connecting: 'Connecting...',
waiting: 'Waiting...',
syncing: 'Syncing...',
noPlaylists: 'No playlists found.',
cancelRefresh: 'Cancel Refresh',
refreshPlaylists: 'Refresh Playlists',
},
playlist: {
trackCount: 'Track Count',
lastUpdated: 'Last Updated',
},
dashboard: {
mapping: 'Mapping',
backup: 'Backup',
autoSync: 'Auto-Sync',
watch: 'Watch',
watchModeActive: 'Watch Mode: Active',
watchModeDisabled: 'Watch Mode: Disabled',
notSet: 'Not Set',
retain: 'Retain: {count}',
keep: 'Keep {count}',
connected: 'Connected to Plex',
disconnected: 'Disconnected',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: 'Sync Strategy',
localOverwrite: {
label: 'Local Overwrite',
desc: 'Local playlist completely overwrites Cloud. (No Diff)',
},
cloudOverwrite: {
label: 'Cloud Overwrite',
desc: 'Cloud playlist completely overwrites Local. (No Diff)',
},
mergeLocal: {
label: 'Two-way Merge (Local Priority)',
desc: 'Merge both. Conflicts resolve to Local version.',
},
mergeCloud: {
label: 'Two-way Merge (Cloud Priority)',
desc: 'Merge both. Conflicts resolve to Cloud version.',
},
syncNow: 'Sync Now',
syncing: 'Sync in Progress...',
saveWarning: 'Please save pending changes (Backups/Path Mapping) before syncing.',
},
mapping: {
title: 'Path Mapping',
simple: 'Simple Mapping',
regex: 'Regex Rules',
simpleTitle: 'Path Mapping',
simpleSubtitle: 'Map Local paths to Cloud paths using simple string matching',
regexPre: 'Pre-Processing (Before Sync)',
regexPost: 'Post-Processing (After Sync / Result)',
localPath: 'Local Path',
cloudPath: 'Cloud Path',
pattern: 'Pattern',
replace: 'Replace',
saveRules: 'Save Rules',
noRules: 'No rules defined.',
},
backup: {
title: 'Backup Retention',
enable: 'Enable Backups',
enableDesc: 'Create a copy before changes',
maxVersions: 'Max versions to keep:',
noAutoDelete: 'No auto-delete',
autoDelete: 'Oldest deleted automatically',
},
schedule: {
title: 'Scheduled Tasks',
cron: 'Cron',
daily: 'Daily',
weekly: 'Weekly',
enableCron: 'Enable Cron Schedule',
enableDaily: 'Enable Daily Run',
enableWeekly: 'Enable Weekly Run',
watchLocal: 'Watch Local Changes',
watchDesc: 'Auto-sync when local playlist updates',
schedule: 'Schedule',
notConfigured: 'Not configured',
today: 'Today',
tomorrow: 'Tomorrow',
},
connection: {
titleConnected: 'Server Connected',
titleConnect: 'Connect Plex Server',
serverDetails: 'Server Details',
authentication: 'Authentication',
protocol: 'Protocol',
address: 'IP Address or Domain',
port: 'Port',
token: 'X-Plex-Token (Optional)',
username: 'Username / Email',
password: 'Password',
advanced: 'Advanced Options',
timeout: 'Connection Timeout (Seconds)',
connectBtn: 'Connect Server',
connecting: 'Connecting...',
connectedSuccess: 'Connected Successfully',
selectLibrary: 'Select Library to Sync',
},
toasts: {
localRefreshCancelled: 'Local refresh cancelled.',
cloudRefreshCancelled: 'Cloud refresh cancelled.',
strategySaved: 'Selected strategy "{strategy}" has been saved.',
strategySaveFailed: 'Failed to save sync strategy.',
mappingSaved: 'Path mapping rules have been saved.',
mappingSaveFailed: 'Failed to save path mapping rules.',
backupSaved: 'Backup settings have been saved.',
backupFailed: 'Failed to save backup settings.',
scheduleDisabled: 'Scheduled tasks disabled.',
scheduleEmpty: 'Scheduled tasks disabled (Empty Cron).',
scheduleStarted: 'Scheduled task updated successfully.',
scheduleFailed: 'Failed to update schedule.',
syncFailed: 'Sync failed. Please check connection.',
backgroundSyncSuccess: 'Background sync completed successfully.',
backgroundSyncFailed: 'Background sync failed: {error}',
librarySwitched: 'Library switched to {library}',
connectedTo: 'Successfully connected to {name}',
connectionCancelled: 'Connection cancelled by user.',
librarySaveFailed: 'Failed to save library selection.',
},
};
+152
View File
@@ -0,0 +1,152 @@
export const es = {
app: {
title: 'PlexSync',
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
revert: 'Revertir',
delete: 'Eliminar',
done: 'Hecho',
loading: 'Cargando...',
refresh: 'Actualizar',
close: 'Cerrar',
none: 'Ninguno',
disabled: 'Deshabilitado',
add: 'Añadir',
switchLanguage: 'Cambiar idioma',
},
server: {
local: 'Servidor Local',
cloud: 'Servidor Nube',
playlists: '{count} Listas',
notConnected: 'No Conectado',
connectionFailed: 'Conexión fallida',
connecting: 'Conectando...',
waiting: 'Esperando...',
syncing: 'Sincronizando...',
noPlaylists: 'No se encontraron listas.',
cancelRefresh: 'Cancelar',
refreshPlaylists: 'Actualizar Listas',
},
playlist: {
trackCount: 'Pistas',
lastUpdated: 'Actualizado',
},
dashboard: {
mapping: 'Mapeo',
backup: 'Respaldo',
autoSync: 'Auto-Sync',
watch: 'Vigilar',
watchModeActive: 'Modo Vigía: Activo',
watchModeDisabled: 'Modo Vigía: Desactivado',
notSet: 'No Def.',
retain: 'Retener: {count}',
keep: 'Guardar {count}',
connected: 'Conectado a Plex',
disconnected: 'Desconectado',
synchronizing: 'SINCRONIZANDO',
syncComplete: 'SINCRONIZACIÓN COMPLETA',
},
strategies: {
title: 'Estrategia de Sync',
localOverwrite: {
label: 'Sobreescribir Local',
desc: 'La lista local sobreescribe la nube. (Sin Diff)',
},
cloudOverwrite: {
label: 'Sobreescribir Nube',
desc: 'La lista de la nube sobreescribe la local. (Sin Diff)',
},
mergeLocal: {
label: 'Fusión (Prioridad Local)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Local.',
},
mergeCloud: {
label: 'Fusión (Prioridad Nube)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Nube.',
},
syncNow: 'Sincronizar Ahora',
syncing: 'Sincronizando...',
saveWarning: 'Guarde los cambios pendientes (Respaldos/Mapeo) antes de sincronizar.',
},
mapping: {
title: 'Mapeo de Rutas',
simple: 'Mapeo Simple',
regex: 'Reglas Regex',
simpleTitle: 'Mapeo de Rutas',
simpleSubtitle: 'Mapear rutas locales a la nube usando coincidencia simple',
regexPre: 'Pre-Procesamiento (Antes de Sync)',
regexPost: 'Post-Procesamiento (Después de Sync)',
localPath: 'Ruta Local',
cloudPath: 'Ruta Nube',
pattern: 'Patrón',
replace: 'Reemplazo',
saveRules: 'Guardar Reglas',
noRules: 'No hay reglas definidas.',
},
backup: {
title: 'Retención de Respaldo',
enable: 'Habilitar Respaldos',
enableDesc: 'Crear copia antes de cambios',
maxVersions: 'Máx versiones a guardar:',
noAutoDelete: 'Sin auto-borrado',
autoDelete: 'El más antiguo se borra automáticamente',
},
schedule: {
title: 'Tareas Programadas',
cron: 'Cron',
daily: 'Diario',
weekly: 'Semanal',
enableCron: 'Habilitar Cron',
enableDaily: 'Habilitar Ejecución Diaria',
enableWeekly: 'Habilitar Ejecución Semanal',
watchLocal: 'Vigilar Cambios Locales',
watchDesc: 'Auto-sync cuando la lista local se actualiza',
schedule: 'Horario',
notConfigured: 'No configurado',
today: 'Hoy',
tomorrow: 'Mañana',
},
connection: {
titleConnected: 'Servidor Conectado',
titleConnect: 'Conectar Servidor Plex',
serverDetails: 'Detalles del Servidor',
authentication: 'Autenticación',
protocol: 'Protocolo',
address: 'Dirección IP o Dominio',
port: 'Puerto',
token: 'X-Plex-Token (Opcional)',
username: 'Usuario / Email',
password: 'Password',
advanced: 'Opciones Avanzadas',
timeout: 'Tiempo de espera (Segundos)',
connectBtn: 'Conectar Servidor',
connecting: 'Conectando...',
connectedSuccess: 'Conectado Exitosamente',
selectLibrary: 'Seleccionar Librería',
},
toasts: {
localRefreshCancelled: 'Actualización local cancelada.',
cloudRefreshCancelled: 'Actualización nube cancelada.',
strategySaved: 'Estrategia seleccionada "{strategy}" guardada.',
strategySaveFailed: 'Error al guardar estrategia de sync.',
mappingSaved: 'Reglas de mapeo guardadas.',
mappingSaveFailed: 'Error al guardar reglas de mapeo.',
backupSaved: 'Configuración de respaldo guardada.',
backupFailed: 'Error al guardar configuración de respaldo.',
scheduleDisabled: 'Tareas programadas deshabilitadas.',
scheduleEmpty: 'Tareas programadas deshabilitadas (Cron Vacío).',
scheduleStarted: 'Tarea programada actualizada exitosamente.',
scheduleFailed: 'Error al actualizar horario.',
syncFailed: 'Fallo en sync. Revise conexión.',
backgroundSyncSuccess: 'Sync en segundo plano completado.',
backgroundSyncFailed: 'Sync en segundo plano falló: {error}',
librarySwitched: 'Librería cambiada a {library}',
connectedTo: 'Conectado exitosamente a {name}',
connectionCancelled: 'Conexión cancelada por usuario.',
librarySaveFailed: 'Error al guardar selección de librería.',
},
};
+89 -14
View File
@@ -1,4 +1,4 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy, ScheduleSettings } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings, BackupSettings } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
@@ -38,24 +38,72 @@ const mapPlaylist = (item: any): Playlist => ({
const mapLibrary = (item: any): PlexLibrary => ({
id: item.id ?? item.title,
title: item.title ?? item.id,
type: item.type ?? 'artist',
type: item.type || item.libraryType || item.library_type || item.section?.type || '',
});
const mapRegexRules = (rules: any[]): RegexReplacement[] =>
// Helper function to map raw rules array to ReplacementRule[]
const mapReplacementRules = (rules: any[]): ReplacementRule[] =>
(rules || []).map((rule, index) => ({
id: rule.id || `${rule.pattern || 'rule'}-${index}`,
pattern: rule.pattern || '',
replacement: rule.replacement || '',
id: rule.id || `${rule.search || 'rule'}-${index}-${Date.now()}`,
search: rule.search || rule.pattern || '',
replace: rule.replace || 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(({ id, search, replace }) => ({ id, 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 = {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>> {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>> {
const response = await fetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const regex = mapRegexRules(result.data.path_rules || []);
const pathMapping = mapPathMappingConfig(result.data);
const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '',
@@ -63,9 +111,9 @@ export const apiService = {
token: result.data.token || '',
libraryName: result.data.library_name || '',
};
return { status: 'success', data: { strategy, regex, connection, localPath: result.data.local_path || '' } };
return { status: 'success', data: { strategy, pathMapping, connection, localPath: result.data.local_path || '' } };
}
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>;
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>;
},
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
@@ -78,9 +126,9 @@ export const apiService = {
return handleResponse(response);
},
async saveRegexRules(replacements: RegexReplacement[]): Promise<ApiResponse<{ rules: RegexReplacement[] }>> {
const payload = { rules: replacements.map(({ pattern, replacement }) => ({ pattern, replacement })) };
const response = await fetch(`${API_BASE}/api/settings/regex-rules`, {
async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = pathMappingToApi(config);
const response = await fetch(`${API_BASE}/api/settings/path-mapping`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -170,7 +218,7 @@ export const apiService = {
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
},
async syncPlaylists(strategy: SyncStrategy, _regexRules: RegexReplacement[], localPath?: string): Promise<ApiResponse<null>> {
async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -186,4 +234,31 @@ export const apiService = {
const response = await fetch(`${API_BASE}/api/sync/status`);
return handleResponse(response);
},
async getBackupSettings(): Promise<ApiResponse<BackupSettings>> {
const response = await fetch(`${API_BASE}/api/backup/settings`);
const result = await handleResponse<any>(response);
if (result.status === 'success') {
return {
status: 'success',
data: {
enabled: result.data.enabled ?? false,
retentionCount: result.data.retention_count ?? 5,
},
};
}
return result as ApiResponse<BackupSettings>;
},
async saveBackupSettings(settings: BackupSettings): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/backup/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: settings.enabled,
retention_count: settings.retentionCount,
}),
});
return handleResponse(response);
},
};
+10
View File
@@ -0,0 +1,10 @@
import { en } from './locales/en';
import { es } from './locales/es';
export const translations = {
en,
es,
};
export type Language = keyof typeof translations;
export type TranslationStructure = typeof en;
+29
View File
@@ -34,6 +34,35 @@ export enum SyncState {
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 BackupSettings {
enabled: boolean;
retentionCount: number;
}
export interface RegexReplacement {
id: string;
pattern: string;
-7
View File
@@ -1,7 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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
+133 -54
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from './types';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api';
import {
STRIPE_BASE_SPEED,
@@ -15,7 +16,8 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
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, Type, Code2, Archive, Languages } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
id: number;
@@ -112,6 +114,7 @@ const useStripeAnimation = (syncState: SyncState) => {
};
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -131,6 +134,7 @@ const App: React.FC = () => {
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
@@ -157,6 +161,12 @@ const App: React.FC = () => {
autoWatch: false
});
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -244,7 +254,7 @@ const App: React.FC = () => {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast("Local refresh cancelled.");
addToast(t('toasts.localRefreshCancelled'));
}
};
@@ -278,7 +288,7 @@ const App: React.FC = () => {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast("Cloud refresh cancelled.");
addToast(t('toasts.cloudRefreshCancelled'));
}
};
@@ -296,13 +306,24 @@ const App: React.FC = () => {
// Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy);
addToast(`Selected strategy "${label}" has been saved.`);
addToast(t('toasts.strategySaved', { strategy: label }));
};
// Handle Path Mapping Save
const handleSavePathMapping = (config: PathMappingConfig) => {
setPathMappingConfig(config);
addToast('Path mapping rules have been saved.');
addToast(t('toasts.mappingSaved'));
};
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast(t('toasts.backupSaved'));
} else {
addToast(t('toasts.backupFailed'));
}
};
// Handle Schedule Save
@@ -315,15 +336,15 @@ const App: React.FC = () => {
setScheduleSettings(settings);
if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled.");
addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast("Scheduled tasks disabled (Empty Cron).");
addToast(t('toasts.scheduleEmpty'));
} else {
addToast("Scheduled task started successfully.");
addToast(t('toasts.scheduleStarted'));
}
return true;
} else {
addToast(result.message || "Failed to update schedule.");
addToast(result.message || t('toasts.scheduleFailed'));
return false;
}
};
@@ -358,7 +379,7 @@ const App: React.FC = () => {
} else {
setSyncState(SyncState.ERROR);
addToast("Sync failed. Please check connection.");
addToast(t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
@@ -391,21 +412,21 @@ const App: React.FC = () => {
// Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = {
label: 'Schedule',
value: 'Not configured',
label: t('schedule.schedule'),
value: t('schedule.notConfigured'),
active: false,
autoWatch: settings.autoWatch
};
if (settings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync';
result.value = 'Disabled';
result.label = t('dashboard.autoSync');
result.value = t('common.disabled');
return result;
}
if (settings.mode === ScheduleMode.CRON) {
result.label = 'Cron Schedule';
result.value = settings.cronExpression || 'Pending...';
result.label = t('schedule.cron');
result.value = settings.cronExpression || t('server.waiting');
result.active = true;
return result;
}
@@ -435,8 +456,8 @@ const App: React.FC = () => {
const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) {
result.label = 'Weekly Schedule';
result.value = 'No days selected';
result.label = t('schedule.weekly');
result.value = t('common.none');
return result;
}
@@ -470,12 +491,12 @@ const App: React.FC = () => {
const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate();
let dateStr = '';
if (isToday) dateStr = 'Today';
else if (isTomorrow) dateStr = 'Tomorrow';
if (isToday) dateStr = t('schedule.today');
else if (isTomorrow) dateStr = t('schedule.tomorrow');
else dateStr = days[nextRun.getDay()];
result.label = `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`;
result.value = `${dateStr} at ${timeStr}`;
result.label = settings.mode === ScheduleMode.DAILY ? t('schedule.daily') : t('schedule.weekly');
result.value = `${dateStr} @ ${timeStr}`;
result.active = true;
return result;
}
@@ -492,6 +513,7 @@ const App: React.FC = () => {
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = t('common.none').replace('None', 'Simple'); // Fallback hack if simple not in dict, but it is in mapping
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
@@ -506,15 +528,15 @@ const App: React.FC = () => {
if (count === 0) {
return {
label: 'Path Mapping',
value: 'Not Set',
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon: Icon
};
}
return {
label: 'Path Mapping',
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
@@ -523,6 +545,24 @@ const App: React.FC = () => {
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false
};
}
return {
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true
};
};
const backupInfo = getBackupDisplayInfo(backupSettings);
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">
@@ -594,45 +634,82 @@ const App: React.FC = () => {
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
{/* Normal Toolbar Right */}
<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>
{/* Unified Status Dock */}
<div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
{/* Path Mapping Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{pathMappingInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
<pathMappingInfo.Icon size={12} strokeWidth={2.5} />
<span className="truncate">{pathMappingInfo.value}</span>
</div>
</div>
{/* Schedule Info */}
<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">
{scheduleInfo.label}
</span>
<div className="text-xs font-mono flex items-center gap-1.5">
{/* Schedule Part */}
<div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{scheduleInfo.active && <Clock size={12} />}
<span>{scheduleInfo.value}</span>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{backupInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
<Archive size={12} strokeWidth={2.5} />
<span>{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
</div>
</div>
{/* Watch Part */}
<span className="text-gray-700 mx-0.5">|</span>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 min-w-[140px] group/item">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{t('dashboard.autoSync')}</span>
{/* Watch Indicator Badge */}
<div
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"}
className={`flex items-center gap-1 px-1 rounded-[2px] transition-colors ${scheduleInfo.autoWatch ? 'text-plex-orange bg-plex-orange/10' : 'text-gray-700 bg-gray-800'}`}
title={scheduleInfo.autoWatch ? t('dashboard.watchModeActive') : t('dashboard.watchModeDisabled')}
>
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />}
<span className="text-[10px] font-sans font-bold">WATCH</span>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">{t('dashboard.watch')}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
<Clock size={12} strokeWidth={2.5} />
<span className="truncate max-w-[120px]">{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}</span>
</div>
</div>
</div>
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title="Switch Language"
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */}
@@ -643,7 +720,7 @@ const App: React.FC = () => {
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
@@ -660,7 +737,7 @@ const App: React.FC = () => {
}}
>
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}
{syncState === SyncState.SYNCING ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1>
</div>
</div>
@@ -719,6 +796,8 @@ const App: React.FC = () => {
onSelect={handleStrategyChange}
savedPathMapping={pathMappingConfig}
onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState}
@@ -743,7 +822,7 @@ const App: React.FC = () => {
{/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
<p>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer>
{/* Modals */}
+64
View File
@@ -0,0 +1,64 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { translations, Language, TranslationStructure } from './translations';
interface LanguageContextProps {
language: Language;
setLanguage: (lang: Language) => void;
t: (path: string, params?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextProps | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguageState] = useState<Language>('en');
useEffect(() => {
const savedLang = localStorage.getItem('plexsync-language') as Language;
if (savedLang && translations[savedLang]) {
setLanguageState(savedLang);
}
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('plexsync-language', lang);
};
const t = (path: string, params?: Record<string, string | number>): string => {
const keys = path.split('.');
let current: any = translations[language];
for (const key of keys) {
if (current[key] === undefined) {
console.warn(`Missing translation for key: ${path} in language: ${language}`);
return path;
}
current = current[key];
}
let text = current as string;
if (params) {
Object.entries(params).forEach(([key, value]) => {
text = text.replace(`{${key}}`, String(value));
});
}
return text;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+29 -21
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps {
isOpen: boolean;
@@ -12,6 +13,7 @@ interface ConnectionModalProps {
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
@@ -71,7 +73,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo);
onShowMessage(`Library switched to ${lib.title}`);
onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
}
};
@@ -90,7 +92,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError("Connection cancelled by user.");
setError(t('toasts.connectionCancelled'));
}
return;
}
@@ -119,7 +121,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || [];
setLibraries(libs);
@@ -134,21 +136,27 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(info);
}
} else {
setError(result.message || "Connection failed");
setError(result.message || t('server.connectionFailed'));
}
};
const isConnected = !!connectedServerInfo;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
{isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
@@ -167,7 +175,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
@@ -191,7 +199,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
name="address"
required
disabled={isConnected || isConnecting}
placeholder="IP Address or Domain"
placeholder={t('connection.address')}
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -205,7 +213,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)"
placeholder={t('connection.port')}
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -217,7 +225,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */}
<div className="relative">
@@ -228,7 +236,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)"
placeholder={t('connection.token')}
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -250,7 +258,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder="Username / Email"
placeholder={t('connection.username')}
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -266,7 +274,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder="Password"
placeholder={t('connection.password')}
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -294,7 +302,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>Advanced Options</span>
<span>{t('connection.advanced')}</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
@@ -302,7 +310,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label>
<label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input
type="number"
min="1"
@@ -331,15 +339,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
<span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</>
) : 'Connect Server'}
) : t('connection.connectBtn')}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
Connected Successfully
{t('connection.connectedSuccess')}
</p>
</div>
)}
@@ -348,7 +356,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
@@ -372,7 +380,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
>
Done
{t('common.done')}
</button>
</div>
</div>
+5 -2
View File
@@ -1,12 +1,15 @@
import React from 'react';
import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface PlaylistCardProps {
playlist: Playlist;
}
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
@@ -16,11 +19,11 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count">
<span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title="Last Updated">
<span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
+12 -10
View File
@@ -3,6 +3,7 @@ import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ServerPanelProps {
type: ServerType;
@@ -14,6 +15,7 @@ interface ServerPanelProps {
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud;
@@ -28,17 +30,17 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
let displaySubtitle: React.ReactNode = null;
if (isLocal) {
displayTitle = 'Local Server';
displayTitle = t('server.local');
displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists
{t('server.playlists', { count: playlists.length })}
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server';
displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
@@ -47,20 +49,20 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
</div>
);
} else {
displayTitle = 'Not Connected';
displayTitle = t('server.notConnected');
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed
{t('server.connectionFailed')}
</p>
);
}
} else {
displayTitle = 'Cloud Server';
displayTitle = t('server.cloud');
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'}
{isLoading ? t('server.connecting') : t('server.waiting')}
</p>
);
}
@@ -121,7 +123,7 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"}
title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
@@ -141,11 +143,11 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p>
<p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p>
<p className="text-sm">{t('server.noPlaylists')}</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
+218 -87
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from '../types';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import {
ArrowRightCircle,
ArrowLeftCircle,
@@ -16,16 +17,19 @@ import {
Calendar,
Clock,
Repeat,
CheckSquare,
Square,
Type,
Code2
Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface StrategyOption {
value: SyncStrategy;
label: string;
description: string;
labelKey: string;
descKey: string;
icon: React.ElementType;
color: string;
}
@@ -33,29 +37,29 @@ interface StrategyOption {
const STRATEGIES: StrategyOption[] = [
{
value: SyncStrategy.LOCAL_OVERWRITE,
label: 'Local Overwrite',
description: 'Local playlist completely overwrites Cloud. (No Diff)',
labelKey: 'strategies.localOverwrite.label',
descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle,
color: 'text-blue-400'
},
{
value: SyncStrategy.CLOUD_OVERWRITE,
label: 'Cloud Overwrite',
description: 'Cloud playlist completely overwrites Local. (No Diff)',
labelKey: 'strategies.cloudOverwrite.label',
descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle,
color: 'text-green-400'
},
{
value: SyncStrategy.MERGE_LOCAL,
label: 'Two-way Merge (Local Priority)',
description: 'Merge both. Conflicts resolve to Local version.',
labelKey: 'strategies.mergeLocal.label',
descKey: 'strategies.mergeLocal.desc',
icon: GitMerge,
color: 'text-blue-300'
},
{
value: SyncStrategy.MERGE_CLOUD,
label: 'Two-way Merge (Cloud Priority)',
description: 'Merge both. Conflicts resolve to Cloud version.',
labelKey: 'strategies.mergeCloud.label',
descKey: 'strategies.mergeCloud.desc',
icon: GitMerge,
color: 'text-green-300'
}
@@ -90,18 +94,13 @@ const MAPPING_THEME = {
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule };
if (tab === ScheduleMode.CRON) {
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED;
} else {
// For Daily/Weekly
// If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was CRON or DISABLED), then in the context of this tab, it is effectively Disabled until the user checks the box.
// Unified logic: If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was DISABLED), then in the context of this tab, it remains Disabled until the user toggles the switch.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
}
return derived;
};
@@ -119,6 +118,7 @@ interface MappingGroupEditorProps {
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
t: (key: string) => string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
@@ -129,10 +129,11 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftPlaceholder,
rightPlaceholder,
leftInputClass,
rightInputClass
rightInputClass,
t
}) => {
const handleAdd = () => {
@@ -165,7 +166,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
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"
title={t('common.add')}
>
<Plus size={12} />
</button>
@@ -174,22 +175,22 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
<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.
{t('mapping.noRules')}
</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}
placeholder={leftPlaceholder || t('mapping.pattern')}
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" />
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder}
placeholder={rightPlaceholder || t('mapping.replace')}
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}`}
@@ -213,6 +214,8 @@ interface StrategySelectorProps {
onSelect: (strategy: SyncStrategy, label: string) => void;
savedPathMapping: PathMappingConfig;
onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState;
@@ -224,11 +227,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSelect,
savedPathMapping,
onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule,
onSaveSchedule,
syncState,
onSync
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -236,6 +242,10 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isMappingDirty, setIsMappingDirty] = useState(false);
// Local state for Backup Settings
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
const [isBackupDirty, setIsBackupDirty] = useState(false);
// Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
@@ -254,6 +264,11 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
setIsMappingDirty(false);
}, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
@@ -268,6 +283,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
setIsMappingDirty(isDifferent);
}, [localPathMapping, savedPathMapping]);
// Check dirty state for backup
useEffect(() => {
const isDifferent = JSON.stringify(localBackup) !== JSON.stringify(savedBackup);
setIsBackupDirty(isDifferent);
}, [localBackup, savedBackup]);
// Check dirty state for Schedule (including Active Tab changes)
useEffect(() => {
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
@@ -291,7 +312,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, strategy.label);
onSelect(strategy.value, t(strategy.labelKey));
};
// --- Path Mapping Handlers ---
@@ -351,6 +372,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const regexRules = localPathMapping.regex;
const simpleRules = localPathMapping.simple;
// --- Backup Handlers ---
const handleUpdateBackup = (field: keyof BackupSettings, value: any) => {
if (isLocked) return;
setLocalBackup(prev => ({ ...prev, [field]: value }));
};
const handleResetBackup = () => {
if (isLocked) return;
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
};
const handleSaveBackupClick = () => {
if (isLocked) return;
onSaveBackup(localBackup);
};
// --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return;
@@ -407,7 +444,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`}
title={`Current Strategy: ${t(selectedOption.labelKey)}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
@@ -433,7 +470,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
@@ -448,7 +485,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
{t(strategy.labelKey)}
</span>
</div>
@@ -456,7 +493,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
{t(strategy.descKey)}
</div>
</div>
@@ -469,17 +506,91 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div>
</div>
{/* Section 1.5: Backup Retention */}
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('backup.title')}</h3>
</div>
<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400">
<Archive size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('backup.enable')}</span>
<span className="text-[10px] text-gray-500">{t('backup.enableDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateBackup('enabled', !localBackup.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localBackup.enabled ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localBackup.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Expanded Config */}
<div className={`overflow-hidden transition-all duration-300 ${localBackup.enabled ? 'max-h-20 opacity-100' : 'max-h-0 opacity-50'}`}>
<div className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
<div className="flex items-center space-x-2">
<History size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">{t('backup.maxVersions')}</span>
</div>
<div className="flex items-center space-x-2">
<input
type="number"
min="1"
max="100"
value={localBackup.retentionCount}
onChange={(e) => handleUpdateBackup('retentionCount', parseInt(e.target.value) || 1)}
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
/>
<span className="text-[10px] text-gray-600 italic">{t('backup.autoDelete')}</span>
</div>
</div>
</div>
<div className="flex justify-end items-center gap-2 pt-1">
<button
onClick={handleResetBackup}
disabled={!isBackupDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isBackupDirty
? '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>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveBackupClick}
disabled={!isBackupDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isBackupDirty
? '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>{t('common.save')}</span>
</button>
</div>
</div>
</div>
{/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<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">{t('mapping.title')}</h3>
</div>
{/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type },
{ id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 },
{ id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => (
<button
key={tab.id}
@@ -502,17 +613,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// 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"
title={t('mapping.simpleTitle')}
subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path"
rightPlaceholder="Cloud Path"
leftPlaceholder={t('mapping.localPath')}
rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
t={t}
/>
</div>
) : (
@@ -520,44 +632,48 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<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)"
title={t('server.local')}
subtitle={t('mapping.regexPre')}
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Pre-Processing (Before Sync)"
title={t('server.cloud')}
subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Post-Processing (After Sync / Result)"
title={t('server.local')}
subtitle={t('mapping.regexPost')}
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Post-Processing (After Sync / Result)"
title={t('server.cloud')}
subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
</div>
)}
@@ -573,7 +689,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveMappingClick}
@@ -584,7 +700,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save Rules</span>
<span>{t('mapping.saveRules')}</span>
</button>
</div>
</div>
@@ -592,15 +708,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
{ id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => (
<button
key={tab.id}
@@ -620,7 +736,20 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Tab Content */}
<div className="mb-4 min-h-[50px]">
{activeScheduleTab === ScheduleMode.CRON && (
<div className="space-y-2 animate-in fade-in duration-200">
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableCron')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.CRON ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Content */}
<div className={`space-y-2 transition-opacity duration-200 ${localSchedule.mode !== ScheduleMode.CRON ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span>
<input
@@ -628,27 +757,28 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
value={localSchedule.cronExpression}
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)}
placeholder="0 0 * * *"
disabled={localSchedule.mode !== ScheduleMode.CRON}
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 font-mono focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange placeholder-gray-600"
/>
</div>
<p className="text-[10px] text-gray-500">
Unix-cron format. Leave empty to disable schedule.
Unix-cron format.
</p>
</div>
</div>
)}
{activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableDaily')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.DAILY ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />}
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.DAILY ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div>
{/* Bottom Row: Centered Native Time Input */}
@@ -666,16 +796,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */}
<div className="flex items-center justify-start space-x-2 mb-2">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.WEEKLY ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />}
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.WEEKLY ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div>
{/* Middle Row: Full Width Capsules */}
@@ -714,20 +843,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
)}
</div>
{/* Auto Watch Checkbox */}
<div className="flex items-center mb-4 px-1">
{/* Auto Watch Switch */}
<div className="flex items-center justify-between mb-4 mt-2 px-1">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 text-orange-400">
<Eye size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
className="flex items-center space-x-2 group"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.autoWatch ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
{localSchedule.autoWatch ? (
<CheckSquare size={16} className="text-plex-orange" />
) : (
<Square size={16} className="text-gray-600 group-hover:text-gray-400" />
)}
<span className={`text-xs ${localSchedule.autoWatch ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'}`}>
Watch for local playlist changes
</span>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
@@ -742,7 +873,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveScheduleClick}
@@ -753,7 +884,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
<span>{t('common.save')}</span>
</button>
</div>
</div>
@@ -767,7 +898,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
${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isMappingDirty
: isMappingDirty || isBackupDirty
? '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]'
}`}
@@ -775,18 +906,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span>
<span>{t('strategies.syncing')}</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>Sync Now</span>
<span>{t('strategies.syncNow')}</span>
</>
)}
</button>
{(isMappingDirty) && (
{(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2">
Please save path mapping changes before syncing.
{t('strategies.saveWarning')}
</p>
)}
</div>
+2 -1
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title>
<title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
+4
View File
@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
@@ -10,6 +12,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode>
);
+147
View File
@@ -0,0 +1,147 @@
export const en = {
app: {
// title and manager are no longer used for branding
title: 'PlexSync',
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
common: {
save: 'Save',
cancel: 'Cancel',
revert: 'Revert',
delete: 'Delete',
done: 'Done',
loading: 'Loading...',
refresh: 'Refresh',
close: 'Close',
none: 'None',
disabled: 'Disabled',
add: 'Add',
},
server: {
local: 'Local Server',
cloud: 'Cloud Server',
playlists: '{count} Playlists',
notConnected: 'Not Connected',
connectionFailed: 'Connection failed',
connecting: 'Connecting...',
waiting: 'Waiting...',
syncing: 'Syncing...',
noPlaylists: 'No playlists found.',
cancelRefresh: 'Cancel Refresh',
refreshPlaylists: 'Refresh Playlists',
},
playlist: {
trackCount: 'Track Count',
lastUpdated: 'Last Updated',
},
dashboard: {
mapping: 'Mapping',
backup: 'Backup',
autoSync: 'Auto-Sync',
watch: 'Watch',
watchModeActive: 'Watch Mode: Active',
watchModeDisabled: 'Watch Mode: Disabled',
notSet: 'Not Set',
retain: 'Retain: {count}',
keep: 'Keep {count}',
connected: 'Connected to Plex',
disconnected: 'Disconnected',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: 'Sync Strategy',
localOverwrite: {
label: 'Local Overwrite',
desc: 'Local playlist completely overwrites Cloud. (No Diff)',
},
cloudOverwrite: {
label: 'Cloud Overwrite',
desc: 'Cloud playlist completely overwrites Local. (No Diff)',
},
mergeLocal: {
label: 'Two-way Merge (Local Priority)',
desc: 'Merge both. Conflicts resolve to Local version.',
},
mergeCloud: {
label: 'Two-way Merge (Cloud Priority)',
desc: 'Merge both. Conflicts resolve to Cloud version.',
},
syncNow: 'Sync Now',
syncing: 'Sync in Progress...',
saveWarning: 'Please save pending changes (Backups/Path Mapping) before syncing.',
},
mapping: {
title: 'Path Mapping',
simple: 'Simple Mapping',
regex: 'Regex Rules',
simpleTitle: 'Path Mapping',
simpleSubtitle: 'Map Local paths to Cloud paths using simple string matching',
regexPre: 'Pre-Processing (Before Sync)',
regexPost: 'Post-Processing (After Sync / Result)',
localPath: 'Local Path',
cloudPath: 'Cloud Path',
pattern: 'Pattern',
replace: 'Replace',
saveRules: 'Save Rules',
noRules: 'No rules defined.',
},
backup: {
title: 'Backup Retention',
enable: 'Enable Backups',
enableDesc: 'Create a copy before changes',
maxVersions: 'Max versions to keep:',
autoDelete: 'Oldest deleted automatically',
},
schedule: {
title: 'Scheduled Tasks',
cron: 'Cron',
daily: 'Daily',
weekly: 'Weekly',
enableCron: 'Enable Cron Schedule',
enableDaily: 'Enable Daily Run',
enableWeekly: 'Enable Weekly Run',
watchLocal: 'Watch Local Changes',
watchDesc: 'Auto-sync when local playlist updates',
schedule: 'Schedule',
notConfigured: 'Not configured',
today: 'Today',
tomorrow: 'Tomorrow',
},
connection: {
titleConnected: 'Server Connected',
titleConnect: 'Connect Plex Server',
serverDetails: 'Server Details',
authentication: 'Authentication',
protocol: 'Protocol',
address: 'IP Address or Domain',
port: 'Port',
token: 'X-Plex-Token (Optional)',
username: 'Username / Email',
password: 'Password',
advanced: 'Advanced Options',
timeout: 'Connection Timeout (Seconds)',
connectBtn: 'Connect Server',
connecting: 'Connecting...',
connectedSuccess: 'Connected Successfully',
selectLibrary: 'Select Library to Sync',
},
toasts: {
localRefreshCancelled: 'Local refresh cancelled.',
cloudRefreshCancelled: 'Cloud refresh cancelled.',
strategySaved: 'Selected strategy "{strategy}" has been saved.',
mappingSaved: 'Path mapping rules have been saved.',
backupSaved: 'Backup settings have been saved.',
backupFailed: 'Failed to save backup settings.',
scheduleDisabled: 'Scheduled tasks disabled.',
scheduleEmpty: 'Scheduled tasks disabled (Empty Cron).',
scheduleStarted: 'Scheduled task started successfully.',
scheduleFailed: 'Failed to update schedule.',
syncFailed: 'Sync failed. Please check connection.',
librarySwitched: 'Library switched to {library}',
connectedTo: 'Successfully connected to {name}',
connectionCancelled: 'Connection cancelled by user.',
}
};
+147
View File
@@ -0,0 +1,147 @@
export const es = {
app: {
// title and manager are no longer used for branding
title: 'PlexSync',
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
revert: 'Revertir',
delete: 'Eliminar',
done: 'Hecho',
loading: 'Cargando...',
refresh: 'Actualizar',
close: 'Cerrar',
none: 'Ninguno',
disabled: 'Deshabilitado',
add: 'Añadir',
},
server: {
local: 'Servidor Local',
cloud: 'Servidor Nube',
playlists: '{count} Listas',
notConnected: 'No Conectado',
connectionFailed: 'Conexión fallida',
connecting: 'Conectando...',
waiting: 'Esperando...',
syncing: 'Sincronizando...',
noPlaylists: 'No se encontraron listas.',
cancelRefresh: 'Cancelar',
refreshPlaylists: 'Actualizar Listas',
},
playlist: {
trackCount: 'Pistas',
lastUpdated: 'Actualizado',
},
dashboard: {
mapping: 'Mapeo',
backup: 'Respaldo',
autoSync: 'Auto-Sync',
watch: 'Vigilar',
watchModeActive: 'Modo Vigía: Activo',
watchModeDisabled: 'Modo Vigía: Desactivado',
notSet: 'No Def.',
retain: 'Retener: {count}',
keep: 'Guardar {count}',
connected: 'Conectado a Plex',
disconnected: 'Desconectado',
synchronizing: 'SINCRONIZANDO',
syncComplete: 'SINCRONIZACIÓN COMPLETA',
},
strategies: {
title: 'Estrategia de Sync',
localOverwrite: {
label: 'Sobreescribir Local',
desc: 'La lista local sobreescribe la nube. (Sin Diff)',
},
cloudOverwrite: {
label: 'Sobreescribir Nube',
desc: 'La lista de la nube sobreescribe la local. (Sin Diff)',
},
mergeLocal: {
label: 'Fusión (Prioridad Local)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Local.',
},
mergeCloud: {
label: 'Fusión (Prioridad Nube)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Nube.',
},
syncNow: 'Sincronizar Ahora',
syncing: 'Sincronizando...',
saveWarning: 'Guarde los cambios pendientes (Respaldos/Mapeo) antes de sincronizar.',
},
mapping: {
title: 'Mapeo de Rutas',
simple: 'Mapeo Simple',
regex: 'Reglas Regex',
simpleTitle: 'Mapeo de Rutas',
simpleSubtitle: 'Mapear rutas locales a la nube usando coincidencia simple',
regexPre: 'Pre-Procesamiento (Antes de Sync)',
regexPost: 'Post-Procesamiento (Después de Sync)',
localPath: 'Ruta Local',
cloudPath: 'Ruta Nube',
pattern: 'Patrón',
replace: 'Reemplazo',
saveRules: 'Guardar Reglas',
noRules: 'No hay reglas definidas.',
},
backup: {
title: 'Retención de Respaldo',
enable: 'Habilitar Respaldos',
enableDesc: 'Crear copia antes de cambios',
maxVersions: 'Máx versiones a guardar:',
autoDelete: 'El más antiguo se borra automáticamente',
},
schedule: {
title: 'Tareas Programadas',
cron: 'Cron',
daily: 'Diario',
weekly: 'Semanal',
enableCron: 'Habilitar Cron',
enableDaily: 'Habilitar Ejecución Diaria',
enableWeekly: 'Habilitar Ejecución Semanal',
watchLocal: 'Vigilar Cambios Locales',
watchDesc: 'Auto-sync cuando la lista local se actualiza',
schedule: 'Horario',
notConfigured: 'No configurado',
today: 'Hoy',
tomorrow: 'Mañana',
},
connection: {
titleConnected: 'Servidor Conectado',
titleConnect: 'Conectar Servidor Plex',
serverDetails: 'Detalles del Servidor',
authentication: 'Autenticación',
protocol: 'Protocolo',
address: 'Dirección IP o Dominio',
port: 'Puerto',
token: 'X-Plex-Token (Opcional)',
username: 'Usuario / Email',
password: 'Password',
advanced: 'Opciones Avanzadas',
timeout: 'Tiempo de espera (Segundos)',
connectBtn: 'Conectar Servidor',
connecting: 'Conectando...',
connectedSuccess: 'Conectado Exitosamente',
selectLibrary: 'Seleccionar Librería',
},
toasts: {
localRefreshCancelled: 'Actualización local cancelada.',
cloudRefreshCancelled: 'Actualización nube cancelada.',
strategySaved: 'Estrategia seleccionada "{strategy}" guardada.',
mappingSaved: 'Reglas de mapeo guardadas.',
backupSaved: 'Configuración de respaldo guardada.',
backupFailed: 'Error al guardar configuración de respaldo.',
scheduleDisabled: 'Tareas programadas deshabilitadas.',
scheduleEmpty: 'Tareas programadas deshabilitadas (Cron Vacío).',
scheduleStarted: 'Tarea programada iniciada exitosamente.',
scheduleFailed: 'Error al actualizar horario.',
syncFailed: 'Fallo en sync. Revise conexión.',
librarySwitched: 'Librería cambiada a {library}',
connectedTo: 'Conectado exitosamente a {name}',
connectionCancelled: 'Conexión cancelada por usuario.',
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "PlexSync Manager",
"name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "plexsync-manager",
"name": "pms-playlist-sync",
"private": true,
"version": "0.0.0",
"type": "module",
+10 -1
View File
@@ -2,7 +2,8 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
const SIMULATE_DELAY_MS = 800;
@@ -220,5 +221,13 @@ export const apiService = {
resolve({ data: null, status: 'success', message: 'Schedule updated successfully' });
}, 500);
});
},
saveBackupSettings: async (settings: BackupSettings): Promise<ApiResponse<null>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
}, 500);
});
}
};
+11
View File
@@ -0,0 +1,11 @@
import { en } from './locales/en';
import { es } from './locales/es';
export const translations = {
en,
es
};
export type Language = keyof typeof translations;
export type TranslationStructure = typeof en;
+5
View File
@@ -59,6 +59,11 @@ export interface PathMappingConfig {
regex: PathMappingRules;
}
export interface BackupSettings {
enabled: boolean;
retentionCount: number;
}
export enum ScheduleMode {
DISABLED = 'DISABLED',
CRON = 'CRON',
-6
View File
@@ -1,6 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,4 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,8 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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
@@ -1,7 +0,0 @@
#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
@@ -1,8 +0,0 @@
#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
@@ -1,6 +0,0 @@
#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
@@ -1,128 +0,0 @@
# 🎯 正则路径替换测试 - 快速参考
## ✅ 测试状态
```
✅ 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
@@ -1,283 +0,0 @@
# 正则路径替换功能测试总结
## 📊 测试统计
- **总测试数**: 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
@@ -1,172 +0,0 @@
# 🎭 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
@@ -1,356 +0,0 @@
# 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
@@ -1,175 +0,0 @@
# 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
@@ -1,131 +0,0 @@
"""
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
@@ -1,554 +0,0 @@
"""
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
@@ -1,291 +0,0 @@
"""
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
@@ -1,566 +0,0 @@
"""
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"])