22 Commits

Author SHA1 Message Date
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 28b68fa9eb PlexPlaylist_UI subtree merge
feat: Add backup settings functionality

Merge commit '0667fac9401254dd9b26043408cb6b204a894184'
2025-12-04 15:37:34 +09: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 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
19 changed files with 1831 additions and 337 deletions
+19 -3
View File
@@ -2,11 +2,27 @@
"theme": "auto", "theme": "auto",
"token": "", "token": "",
"server_url": "", "server_url": "",
"server_port": "32400",
"server_scheme": "https", "server_scheme": "https",
"server_port": "32400",
"timeout": 9, "timeout": 9,
"library_name": "", "library_name": "",
"sync_mode": "merge_local_primary", "sync_mode": "merge_local_primary",
"local_path": "playlist", "local_path": "playlist",
"path_rules": [] "path_rules": [],
} "path_mapping": {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
},
"schedule_mode": "DISABLED",
"schedule_cron": "",
"schedule_daily_time": "02:00",
"schedule_weekly_days": [0],
"schedule_weekly_time": "03:00",
"schedule_auto_watch": false
}
+61
View File
@@ -92,9 +92,29 @@ class RegexRule(BaseModel):
replacement: str = "" 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): class SyncSettingsResponse(BaseModel):
sync_mode: str sync_mode: str
path_rules: list[RegexRule] path_rules: list[RegexRule]
path_mapping: dict | None = None
local_path: str local_path: str
library_name: str | None = None library_name: str | None = None
server_url: str | None = None server_url: str | None = None
@@ -124,6 +144,30 @@ class ScheduleSettings(BaseModel):
autoWatch: bool 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") @app.get("/api/schedule")
async def get_schedule(): async def get_schedule():
next_run = get_next_run_time() next_run = get_next_run_time()
@@ -352,6 +396,7 @@ async def get_settings():
return SyncSettingsResponse( return SyncSettingsResponse(
sync_mode=server_config.sync_mode, sync_mode=server_config.sync_mode,
path_rules=rules, path_rules=rules,
path_mapping=server_config.path_mapping,
local_path=server_config.local_path, local_path=server_config.local_path,
library_name=server_config.library_name, library_name=server_config.library_name,
server_url=server_config.url, server_url=server_config.url,
@@ -380,6 +425,22 @@ async def update_regex_rules(payload: RegexRulePayload):
return {"rules": payload.rules} return {"rules": payload.rules}
@app.put("/api/settings/path-mapping")
async def update_path_mapping(payload: PathMappingPayload):
path_mapping_dict = {
"mode": payload.mode,
"simple": [{"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") @app.put("/api/settings/library")
async def update_library(payload: LibrarySelection): async def update_library(payload: LibrarySelection):
server_config.set_and_save_config(library_name=payload.library_name) server_config.set_and_save_config(library_name=payload.library_name)
+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")
+63 -1
View File
@@ -3,6 +3,16 @@ import os
from app.utils.logger import logger from app.utils.logger import logger
DEFAULT_SYNC_MODE = "merge_local_primary" DEFAULT_SYNC_MODE = "merge_local_primary"
DEFAULT_PATH_MAPPING = {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
}
CONFIG_PATH = os.path.abspath( CONFIG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "config.json") os.path.join(os.path.dirname(__file__), "..", "config.json")
@@ -21,13 +31,16 @@ class ServerConfig:
self.library_name = "" self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE self.sync_mode = DEFAULT_SYNC_MODE
self.local_path = "playlist" self.local_path = "playlist"
self.path_rules: list[dict[str, str]] = [] 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_mode = "DISABLED"
self.schedule_cron = "" self.schedule_cron = ""
self.schedule_daily_time = "02:00" self.schedule_daily_time = "02:00"
self.schedule_weekly_days = [0] self.schedule_weekly_days = [0]
self.schedule_weekly_time = "03:00" self.schedule_weekly_time = "03:00"
self.schedule_auto_watch = False self.schedule_auto_watch = False
self.backup_enabled = False
self.backup_retention_count = 5
self.load() self.load()
def load(self) -> None: def load(self) -> None:
@@ -55,12 +68,31 @@ class ServerConfig:
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE) self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
self.local_path = config.get("local_path", "playlist") self.local_path = config.get("local_path", "playlist")
self.path_rules = config.get("path_rules", []) or [] self.path_rules = config.get("path_rules", []) or []
# Load path_mapping with default fallback
path_mapping_config = config.get("path_mapping")
if path_mapping_config:
self.path_mapping = {
"mode": path_mapping_config.get("mode", "SIMPLE"),
"simple": path_mapping_config.get("simple", []),
"regex": {
"local_pre": path_mapping_config.get("regex", {}).get("local_pre", []),
"local_post": path_mapping_config.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping_config.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping_config.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = config.get("schedule_mode", "DISABLED") self.schedule_mode = config.get("schedule_mode", "DISABLED")
self.schedule_cron = config.get("schedule_cron", "") self.schedule_cron = config.get("schedule_cron", "")
self.schedule_daily_time = config.get("schedule_daily_time", "02:00") self.schedule_daily_time = config.get("schedule_daily_time", "02:00")
self.schedule_weekly_days = config.get("schedule_weekly_days", [0]) self.schedule_weekly_days = config.get("schedule_weekly_days", [0])
self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00") self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00")
self.schedule_auto_watch = config.get("schedule_auto_watch", False) 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.info(f"Server config loaded.")
logger.debug(f"Current server config: {self.__dict__}") logger.debug(f"Current server config: {self.__dict__}")
@@ -76,12 +108,15 @@ class ServerConfig:
"sync_mode": self.sync_mode, "sync_mode": self.sync_mode,
"local_path": self.local_path, "local_path": self.local_path,
"path_rules": self.path_rules, "path_rules": self.path_rules,
"path_mapping": self.path_mapping,
"schedule_mode": self.schedule_mode, "schedule_mode": self.schedule_mode,
"schedule_cron": self.schedule_cron, "schedule_cron": self.schedule_cron,
"schedule_daily_time": self.schedule_daily_time, "schedule_daily_time": self.schedule_daily_time,
"schedule_weekly_days": self.schedule_weekly_days, "schedule_weekly_days": self.schedule_weekly_days,
"schedule_weekly_time": self.schedule_weekly_time, "schedule_weekly_time": self.schedule_weekly_time,
"schedule_auto_watch": self.schedule_auto_watch, "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: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False) json.dump(config, f, indent=4, ensure_ascii=False)
@@ -121,6 +156,21 @@ class ServerConfig:
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None: def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
self.path_rules = path_rules or [] self.path_rules = path_rules or []
def set_path_mapping(self, path_mapping: dict) -> None:
if path_mapping:
self.path_mapping = {
"mode": path_mapping.get("mode", "SIMPLE"),
"simple": path_mapping.get("simple", []),
"regex": {
"local_pre": path_mapping.get("regex", {}).get("local_pre", []),
"local_post": path_mapping.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
def set_schedule( def set_schedule(
self, self,
mode: str, mode: str,
@@ -138,6 +188,15 @@ class ServerConfig:
self.schedule_auto_watch = auto_watch self.schedule_auto_watch = auto_watch
self.save() 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( def set_and_save_config(
self, self,
theme: str = None, theme: str = None,
@@ -150,6 +209,7 @@ class ServerConfig:
sync_mode: str | None = None, sync_mode: str | None = None,
local_path: str | None = None, local_path: str | None = None,
path_rules: list[dict[str, str]] | None = None, path_rules: list[dict[str, str]] | None = None,
path_mapping: dict | None = None,
) -> None: ) -> None:
if theme is not None: if theme is not None:
self.set_theme(theme) self.set_theme(theme)
@@ -171,6 +231,8 @@ class ServerConfig:
self.set_local_path(local_path) self.set_local_path(local_path)
if path_rules is not None: if path_rules is not None:
self.set_path_rules(path_rules) self.set_path_rules(path_rules)
if path_mapping is not None:
self.set_path_mapping(path_mapping)
self.save() self.save()
+45 -1
View File
@@ -65,4 +65,48 @@ def scan_local_playlists(base_path: str) -> list[dict]:
playlists.sort(key=lambda item: item["name"].lower()) playlists.sort(key=lambda item: item["name"].lower())
logger.info(f"Found {len(playlists)} playlists under {absolute_path}.") logger.info(f"Found {len(playlists)} playlists under {absolute_path}.")
return playlists 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
+249 -19
View File
@@ -40,6 +40,15 @@ class PlaylistSyncResult:
output_dir: str output_dir: str
@dataclass
class CompiledRegexRules:
"""Holds compiled regex rules for all four processing stages."""
local_pre: list[tuple[re.Pattern[str], str]]
local_post: list[tuple[re.Pattern[str], str]]
remote_pre: list[tuple[re.Pattern[str], str]]
remote_post: list[tuple[re.Pattern[str], str]]
def load_paths(text: str) -> list[str]: def load_paths(text: str) -> list[str]:
"""Normalize playlist text into a list of absolute paths. """Normalize playlist text into a list of absolute paths.
@@ -72,12 +81,21 @@ def save_paths(paths: Sequence[str]) -> str:
def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]: def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]:
"""Compile regex rules into pattern/replacement pairs.
Supports both legacy format (pattern/replacement) and new format (search/replace).
"""
compiled: list[tuple[re.Pattern[str], str]] = [] compiled: list[tuple[re.Pattern[str], str]] = []
for rule in rules: for rule in rules:
pattern = rule.get("pattern") # Support both legacy (pattern/replacement) and new (search/replace) field names
# Use explicit None checks to allow empty strings as valid values
pattern = rule.get("pattern") if rule.get("pattern") is not None else rule.get("search")
if not pattern: if not pattern:
continue continue
replacement = rule.get("replacement", "") # For replacement, empty string is a valid value (for deletion)
replacement = rule.get("replacement") if rule.get("replacement") is not None else rule.get("replace")
if replacement is None:
replacement = ""
try: try:
compiled.append((re.compile(pattern), replacement)) compiled.append((re.compile(pattern), replacement))
except re.error as exc: except re.error as exc:
@@ -234,9 +252,31 @@ def _merge_chunks(
return chunks return chunks
def _write_results(merged_lines: Sequence[str], folder: str) -> None: def _write_results(
_save_playlist_to_folder("local_result.m3u8", merged_lines, folder) merged_lines: Sequence[str],
_save_playlist_to_folder("remote_result.m3u8", merged_lines, folder) folder: str,
compiled_rules: CompiledRegexRules | None = None
) -> None:
"""Write sync results to the test folder.
If compiled_rules is provided with post-processing rules:
- local_result.m3u8: merged_lines processed with local_post rules
- remote_result.m3u8: merged_lines processed with remote_post rules
- base_next.m3u8: unprocessed merged_lines (normalized sync result)
"""
# Apply post-processing regex rules if provided
if compiled_rules and compiled_rules.local_post:
local_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.local_post)
else:
local_lines = list(merged_lines)
if compiled_rules and compiled_rules.remote_post:
remote_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.remote_post)
else:
remote_lines = list(merged_lines)
_save_playlist_to_folder("local_result.m3u8", local_lines, folder)
_save_playlist_to_folder("remote_result.m3u8", remote_lines, folder)
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder) _save_playlist_to_folder("base_next.m3u8", merged_lines, folder)
@@ -379,12 +419,16 @@ def merge_playlists(
remote_text: str, remote_text: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY, strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
test_folder: str = TEST_PLAYLIST_DIR, test_folder: str = TEST_PLAYLIST_DIR,
compiled_rules: CompiledRegexRules | None = None,
) -> MergeResult: ) -> MergeResult:
"""Merge playlists using diff3 and resolve conflicts per strategy. """Merge playlists using diff3 and resolve conflicts per strategy.
The base, local, and remote normalized playlists are saved into ``test_folder`` The base, local, and remote normalized playlists are saved into ``test_folder``
for inspection. The merged playlist is also stored twice to simulate the for inspection. The merged playlist is also stored twice to simulate the
versions intended for local save and cloud upload. versions intended for local save and cloud upload.
If compiled_rules is provided, post-processing regex rules will be applied
to the results before writing.
""" """
base_paths, local_paths, remote_paths = _normalize_inputs( base_paths, local_paths, remote_paths = _normalize_inputs(
@@ -420,7 +464,7 @@ def merge_playlists(
merged_lines, base_paths, local_paths, remote_paths merged_lines, base_paths, local_paths, remote_paths
) )
_write_results(merged_lines, test_folder) _write_results(merged_lines, test_folder, compiled_rules)
return MergeResult(merged_paths=merged_lines, conflicts=conflicts) return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
@@ -517,6 +561,7 @@ def _sync_single_playlist(
remote_text: str, remote_text: str,
playlist_folder: str, playlist_folder: str,
remote_present: bool, remote_present: bool,
compiled_rules: CompiledRegexRules | None = None,
) -> PlaylistSyncResult: ) -> PlaylistSyncResult:
local_present = local_text is not None local_present = local_text is not None
local_text = local_text or "" local_text = local_text or ""
@@ -535,7 +580,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder base_text, local_text, remote_text, playlist_folder
) )
merged_lines = list(local_paths) merged_lines = list(local_paths)
_write_results(merged_lines, playlist_folder) _write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder) return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode == SyncMode.REMOTE_FORCE: if mode == SyncMode.REMOTE_FORCE:
@@ -547,7 +592,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder base_text, local_text, remote_text, playlist_folder
) )
merged_lines = list(remote_paths) merged_lines = list(remote_paths)
_write_results(merged_lines, playlist_folder) _write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder) return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY): if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY):
@@ -565,6 +610,7 @@ def _sync_single_playlist(
remote_text=remote_text, remote_text=remote_text,
strategy=merge_strategy, strategy=merge_strategy,
test_folder=playlist_folder, test_folder=playlist_folder,
compiled_rules=compiled_rules,
) )
if not merge_result.merged_paths and (not local_present or not remote_present): if not merge_result.merged_paths and (not local_present or not remote_present):
@@ -578,13 +624,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( def sync_all_playlists(
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR
) -> list[PlaylistSyncResult]: ) -> list[PlaylistSyncResult]:
"""Synchronize all playlists that can be matched by name.""" """Synchronize all playlists that can be matched by name.
Path mapping modes:
- SIMPLE: Uses UUID-based mapping_ids to convert between local and cloud paths
- local_pre: local_path -> mapping_id
- remote_pre: cloud_path -> mapping_id
- local_post: mapping_id -> local_path
- remote_post: mapping_id -> cloud_path
- REGEX: Uses custom regex rules for each processing stage
- local_pre, local_post, remote_pre, remote_post rules are applied directly
Processing flow:
1. local_pre rules are applied to local playlists before sync
2. remote_pre rules are applied to remote playlists before sync
3. Sync/merge is performed
4. local_post rules are applied to results before writing to local_result.m3u8
5. remote_post rules are applied to results before writing to remote_result.m3u8
"""
server_config.load() server_config.load()
compiled_rules = _compile_regex_rules(server_config.path_rules)
# Get path_mapping configuration
path_mapping = server_config.path_mapping
mapping_mode = path_mapping.get("mode", "SIMPLE")
# Compile rules based on the mode
compiled_rules: CompiledRegexRules | None = None
legacy_compiled_rules: list[tuple[re.Pattern[str], str]] = []
if mapping_mode == "REGEX":
compiled_rules = _compile_path_mapping_rules(path_mapping)
logger.info("Using REGEX mode for path mapping with 4 rule groups")
elif mapping_mode == "SIMPLE":
simple_mappings = path_mapping.get("simple", [])
if simple_mappings:
compiled_rules = _compile_simple_mapping_rules(simple_mappings)
logger.info(f"Using SIMPLE mode for path mapping with {len(simple_mappings)} mapping pairs")
else:
logger.info("SIMPLE mode with no mappings - no path transformations will be applied")
else:
# Use legacy path_rules for backward compatibility
legacy_compiled_rules = _compile_regex_rules(server_config.path_rules)
logger.info("Using legacy path_rules for preprocessing")
_ensure_test_dir(test_folder) _ensure_test_dir(test_folder)
logger.info(f"Syncing playlists to test folder: {test_folder}") logger.info(f"Syncing playlists to test folder: {test_folder}")
local_playlists = _load_local_playlists(local_dir) local_playlists = _load_local_playlists(local_dir)
@@ -613,16 +823,35 @@ def sync_all_playlists(
remote_text = snapshot_remote_text remote_text = snapshot_remote_text
remote_present = bool(remote_text.strip()) or remote_exists remote_present = bool(remote_text.strip()) or remote_exists
base_text = preprocess_playlist_text( if compiled_rules:
base_text, server_config.path_rules, compiled_rules # Apply pre-processing rules for REGEX or SIMPLE mode
) # base_text doesn't need pre-processing as it's the normalized state
remote_text = preprocess_playlist_text( if local_text is not None and compiled_rules.local_pre:
remote_text, server_config.path_rules, compiled_rules logger.debug(f"Applying local_pre rules to playlist: {playlist}")
) logger.debug(f" Before preprocessing (first 200 chars): {repr(local_text[:200])}")
if local_text is not None: local_text = preprocess_playlist_text(
local_text = preprocess_playlist_text( local_text, [], compiled_rules.local_pre
local_text, server_config.path_rules, compiled_rules )
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, legacy_compiled_rules
) )
remote_text = preprocess_playlist_text(
remote_text, server_config.path_rules, legacy_compiled_rules
)
if local_text is not None:
local_text = preprocess_playlist_text(
local_text, server_config.path_rules, legacy_compiled_rules
)
# Treat missing remote text as absent playlist. # Treat missing remote text as absent playlist.
result = _sync_single_playlist( result = _sync_single_playlist(
@@ -633,6 +862,7 @@ def sync_all_playlists(
remote_text=remote_text, remote_text=remote_text,
playlist_folder=playlist_folder, playlist_folder=playlist_folder,
remote_present=remote_present, remote_present=remote_present,
compiled_rules=compiled_rules,
) )
results.append(result) results.append(result)
+90
View File
@@ -311,4 +311,94 @@ class PlexClient:
) )
return local_2_plex 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() 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) trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time)
if trigger: 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}") logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
else: else:
logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.") logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.")
+55 -1
View File
@@ -1,10 +1,14 @@
import threading import threading
import asyncio import asyncio
import json import json
import os
from datetime import datetime from datetime import datetime
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode from app.utils.playlist_merge import sync_all_playlists, SyncMode
from app.utils.config import server_config 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: class SyncManager:
def __init__(self): def __init__(self):
@@ -110,8 +114,58 @@ class SyncManager:
if sync_kwargs: if sync_kwargs:
kwargs.update(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 # 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): def _complete_sync(self, status, error=None):
with self._lock: with self._lock:
+106 -23
View File
@@ -1,8 +1,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; 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 { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
STRIPE_DECEL_DURATION_MS, STRIPE_DECEL_DURATION_MS,
STRIPE_TILE_SIZE, STRIPE_TILE_SIZE,
@@ -10,15 +9,13 @@ import {
SYNC_SUCCESS_TOTAL_MS, SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS, SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS, TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS, TOAST_EXIT_DURATION_MS
SYNC_BANNER_PADDING_X,
SYNC_BANNER_PADDING_Y,
SYNC_BANNER_MIN_WIDTH,
} from './Config'; } from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
import ServerPanel from './components/ServerPanel'; import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector'; import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal'; import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2 } from 'lucide-react';
interface Toast { interface Toast {
id: number; id: number;
@@ -141,8 +138,17 @@ const App: React.FC = () => {
// Strategy State // Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE); const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State // Path Mapping State (Includes Simple and Regex Rules)
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]); const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State // Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({ const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
@@ -155,6 +161,12 @@ const App: React.FC = () => {
}); });
const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined); const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined);
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System // Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({}); const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -226,7 +238,7 @@ const App: React.FC = () => {
const result = await apiService.getSettings(); const result = await apiService.getSettings();
if (result.status === 'success') { if (result.status === 'success') {
setCurrentStrategy(result.data.strategy); setCurrentStrategy(result.data.strategy);
setRegexReplacements(result.data.regex); setPathMappingConfig(result.data.pathMapping);
setLocalPath(result.data.localPath || 'playlist'); setLocalPath(result.data.localPath || 'playlist');
setConnectionSettings(result.data.connection); setConnectionSettings(result.data.connection);
} }
@@ -240,6 +252,13 @@ const App: React.FC = () => {
} }
}, []); }, []);
const loadBackupSettings = useCallback(async () => {
const result = await apiService.getBackupSettings();
if (result.status === 'success') {
setBackupSettings(result.data);
}
}, []);
// Handle Schedule Save // Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => { const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
const result = await apiService.saveScheduleSettings(settings); const result = await apiService.saveScheduleSettings(settings);
@@ -261,6 +280,17 @@ const App: React.FC = () => {
} }
}; };
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast('Backup settings have been saved.');
} else {
addToast(result.message || 'Failed to save backup settings.');
}
};
// Fetch Local Playlists // Fetch Local Playlists
const refreshLocal = useCallback(async () => { const refreshLocal = useCallback(async () => {
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
@@ -323,7 +353,8 @@ const App: React.FC = () => {
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
loadSchedule(); loadSchedule();
}, [loadSettings, loadSchedule]); loadBackupSettings();
}, [loadSettings, loadSchedule, loadBackupSettings]);
// Initial Load // Initial Load
useEffect(() => { useEffect(() => {
@@ -347,14 +378,14 @@ const App: React.FC = () => {
} }
}; };
// Handle Regex Save // Handle Path Mapping Save
const handleSaveRegex = async (replacements: RegexReplacement[]) => { const handleSavePathMapping = async (config: PathMappingConfig) => {
setRegexReplacements(replacements); setPathMappingConfig(config);
const result = await apiService.saveRegexRules(replacements); const result = await apiService.savePathMapping(config);
if (result.status === 'success') { if (result.status === 'success') {
addToast('Regex preprocessing rules have been saved.'); addToast('Path mapping rules have been saved.');
} else { } else {
addToast(result.message || 'Failed to save regex rules.'); addToast(result.message || 'Failed to save path mapping rules.');
} }
}; };
@@ -365,7 +396,7 @@ const App: React.FC = () => {
setSyncState(SyncState.SYNCING); setSyncState(SyncState.SYNCING);
manualSyncInProgress.current = true; manualSyncInProgress.current = true;
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined); const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig, localPath || undefined);
manualSyncInProgress.current = false; manualSyncInProgress.current = false;
@@ -513,6 +544,44 @@ const App: React.FC = () => {
const scheduleInfo = getScheduleDisplayInfo(); const scheduleInfo = getScheduleDisplayInfo();
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let modeLabel = '';
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
} else {
modeLabel = 'Regex';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: 'Path Mapping',
value: 'Not Set',
active: false,
Icon: Icon
};
}
return {
label: 'Path Mapping',
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
};
};
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
return ( return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black"> <div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -574,7 +643,7 @@ const App: React.FC = () => {
{syncState === SyncState.IDLE ? ( {syncState === SyncState.IDLE ? (
<> <>
{/* Normal Toolbar */} {/* Normal Toolbar Left */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20"> <div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} /> <ArrowLeftRight size={24} strokeWidth={2.5} />
@@ -583,9 +652,20 @@ const App: React.FC = () => {
Plex<span className="text-plex-orange">Sync</span> Plex<span className="text-plex-orange">Sync</span>
</h1> </h1>
</div> </div>
{/* Normal Toolbar Right */} {/* Normal Toolbar Right */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Path Mapping Info */}
<div className="flex flex-col items-end hidden md:flex border-r border-gray-700/50 pr-4 mr-1">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{pathMappingInfo.label}
</span>
<div className={`text-xs font-mono flex items-center gap-1.5 ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{pathMappingInfo.active && <pathMappingInfo.Icon size={12} />}
<span>{pathMappingInfo.value}</span>
</div>
</div>
{/* Schedule Info */} {/* Schedule Info */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex"> <div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider"> <span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
@@ -625,6 +705,7 @@ const App: React.FC = () => {
</div> </div>
</> </>
) : ( ) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div <div
className="bg-black shadow-none rounded-none border-none" className="bg-black shadow-none rounded-none border-none"
@@ -691,8 +772,10 @@ const App: React.FC = () => {
<StrategySelector <StrategySelector
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements} savedPathMapping={pathMappingConfig}
onSaveRegex={handleSaveRegex} onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings} savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule} onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
+21 -9
View File
@@ -35,10 +35,12 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const [selectedLibraryId, setSelectedLibraryId] = useState<string>(''); const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const prevIsOpenRef = useRef(isOpen);
// Reset state when opening // Reset state when opening
useEffect(() => { useEffect(() => {
if (isOpen) { // Only execute reset logic when modal opens (isOpen changes from false to true)
if (isOpen && !prevIsOpenRef.current) {
setError(null); setError(null);
setConnectedServerInfo(null); setConnectedServerInfo(null);
setLibraries([]); setLibraries([]);
@@ -54,12 +56,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) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
} }
}; }
prevIsOpenRef.current = isOpen;
}, [isOpen, initialSettings]); }, [isOpen, initialSettings]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -139,10 +144,11 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`); onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
const libs = info.libraries || []; const libs = info.libraries || [];
setLibraries(libs); const musicLibraries = libs.filter((lib) => lib.type === 'artist').sort((a, b) => a.title.localeCompare(b.title));
if (libs.length > 0) { setLibraries(musicLibraries);
if (musicLibraries.length > 0) {
const preferred = info.libraryName || formData.libraryName; 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); setSelectedLibraryId(defaultLib.id);
setFormData(prev => ({ ...prev, libraryName: defaultLib.title })); setFormData(prev => ({ ...prev, libraryName: defaultLib.title }));
onConnectSuccess({ onConnectSuccess({
@@ -164,8 +170,14 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const isConnected = !!connectedServerInfo; const isConnected = !!connectedServerInfo;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <div
<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]"> 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 */} {/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none"> <div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
+509 -203
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; 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 { import {
ArrowRightCircle, ArrowRightCircle,
ArrowLeftCircle, ArrowLeftCircle,
@@ -16,10 +16,28 @@ import {
Calendar, Calendar,
Clock, Clock,
Repeat, Repeat,
CheckSquare, Type,
Square Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react'; } from 'lucide-react';
// 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 { interface StrategyOption {
value: SyncStrategy; value: SyncStrategy;
label: string; label: string;
@@ -61,30 +79,153 @@ const STRATEGIES: StrategyOption[] = [
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// Color Theme Variables for Mapping Editors
const MAPPING_THEME = {
// Container Themes
local: {
borderColor: "border-blue-500/20",
bgColor: "bg-blue-900/10"
},
remote: {
borderColor: "border-green-500/20",
bgColor: "bg-green-900/10"
},
simple: {
borderColor: "border-gray-700/50",
bgColor: "bg-gray-900/40"
},
// Input Field Themes
inputs: {
default: "bg-gray-800 border-gray-700 text-gray-200 focus:border-plex-orange placeholder-gray-600",
local: "bg-blue-500/10 border-blue-500/30 text-blue-100 focus:border-blue-400 placeholder-blue-300/30",
cloud: "bg-green-500/10 border-green-500/30 text-green-100 focus:border-green-400 placeholder-green-300/30"
}
};
// Helper to determine the actual mode and settings that would be saved based on the current UI state // Helper to determine the actual mode and settings that would be saved based on the current UI state
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => { const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule }; const derived = { ...schedule };
if (tab === ScheduleMode.CRON) { // Unified logic: If the mode matches the tab, we keep it (Enabled).
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED; // 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 { } else {
// For Daily/Weekly derived.mode = ScheduleMode.DISABLED;
// 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.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
} }
return derived; return derived;
}; };
// Sub-component for a single Mapping Group Editor
interface MappingGroupEditorProps {
title: string;
subtitle?: string;
rules: ReplacementRule[];
onChange: (newRules: ReplacementRule[]) => void;
isLocked: boolean;
borderColor?: string;
bgColor?: string;
// Input specific props
leftPlaceholder?: string;
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
title,
subtitle,
rules,
onChange,
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftInputClass,
rightInputClass
}) => {
const handleAdd = () => {
if (isLocked) return;
const newId = 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="Add Rule"
>
<Plus size={12} />
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No rules defined.
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
/>
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
/>
<button
onClick={() => handleDelete(rule.id)}
className="text-gray-600 hover:text-red-400 p-1 rounded hover:bg-red-500/10 transition-colors flex-none"
>
<Trash2 size={12} />
</button>
</div>
))
)}
</div>
</div>
);
};
interface StrategySelectorProps { interface StrategySelectorProps {
currentStrategy: SyncStrategy; currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void; onSelect: (strategy: SyncStrategy, label: string) => void;
savedRegexReplacements: RegexReplacement[]; savedPathMapping: PathMappingConfig;
onSaveRegex: (replacements: RegexReplacement[]) => void; onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings; savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>; onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState; syncState: SyncState;
@@ -94,8 +235,10 @@ interface StrategySelectorProps {
const StrategySelector: React.FC<StrategySelectorProps> = ({ const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy, currentStrategy,
onSelect, onSelect,
savedRegexReplacements, savedPathMapping,
onSaveRegex, onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule, savedSchedule,
onSaveSchedule, onSaveSchedule,
syncState, syncState,
@@ -104,17 +247,20 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for regex editing // Local state for path mapping editing (stores all lists for both modes)
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]); const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isRegexDirty, setIsRegexDirty] = useState(false); 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 // Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule); const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false); const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs // UI State for Schedule Tabs
// We initialize active tab based on the saved mode. If DISABLED, default to CRON. const [activeScheduleTab, setActiveScheduleTab] = useState<ScheduleMode>(
const [activeTab, setActiveTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
); );
@@ -123,32 +269,41 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Initialize local state when prop updates // Initialize local state when prop updates
useEffect(() => { useEffect(() => {
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
setIsRegexDirty(false); setIsMappingDirty(false);
}, [savedRegexReplacements]); }, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => { useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule))); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
// If the saved mode is not disabled, ensure we show that tab.
if (savedSchedule.mode !== ScheduleMode.DISABLED) { if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveTab(savedSchedule.mode); setActiveScheduleTab(savedSchedule.mode);
} }
setIsScheduleDirty(false); setIsScheduleDirty(false);
}, [savedSchedule]); }, [savedSchedule]);
// Check dirty state whenever local changes // Check dirty state whenever local mapping changes
useEffect(() => { useEffect(() => {
const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements); const isDifferent = JSON.stringify(localPathMapping) !== JSON.stringify(savedPathMapping);
setIsRegexDirty(isDifferent); setIsMappingDirty(isDifferent);
}, [localReplacements, savedRegexReplacements]); }, [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) // Check dirty state for Schedule (including Active Tab changes)
useEffect(() => { useEffect(() => {
// We calculate what the "effective" schedule would be if we saved right now. const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule); const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent); setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeTab]); }, [localSchedule, savedSchedule, activeScheduleTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0]; const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
@@ -162,45 +317,84 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Determine if tabs have changed from the saved state const isScheduleActionable = isScheduleDirty || (activeScheduleTab !== (savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode));
const initialTab = savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode;
const hasTabChanged = activeTab !== initialTab;
const isScheduleActionable = isScheduleDirty || hasTabChanged;
const handleSelect = (strategy: StrategyOption) => { const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return; if (isLocked) return;
onSelect(strategy.value, strategy.label); onSelect(strategy.value, strategy.label);
}; };
// --- Regex Handlers --- // --- Path Mapping Handlers ---
const handleAddRegex = () => { const currentMappingMode = localPathMapping.mode;
const updateRegexGroup = (section: keyof PathMappingRules, newRules: ReplacementRule[]) => {
if (isLocked) return; if (isLocked) return;
const newId = Date.now().toString(); setLocalPathMapping(prev => ({
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]); ...prev,
regex: {
...prev.regex,
[section]: newRules
}
}));
}; };
const handleDeleteRegex = (id: string) => { const updateSimpleGroup = (newRules: ReplacementRule[]) => {
if (isLocked) return; 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; if (isLocked) return;
setLocalReplacements(prev => prev.map(r => setLocalPathMapping(prev => ({ ...prev, mode }));
r.id === id ? { ...r, [field]: value } : r
));
}; };
const handleResetRegex = () => { const handleResetMapping = () => {
if (isLocked) return; if (isLocked) return;
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
}; };
const handleSaveRegex = () => { const handleSaveMappingClick = () => {
if (isLocked) return; if (isLocked) return;
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== ''); const clean = (rules: ReplacementRule[]) => rules.filter(r => r.search.trim() !== '');
setLocalReplacements(validReplacements);
onSaveRegex(validReplacements); // 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 --- // --- Schedule Handlers ---
@@ -222,24 +416,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
if (isLocked) return; if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule))); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) { if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveTab(savedSchedule.mode); setActiveScheduleTab(savedSchedule.mode);
} else { } else {
setActiveTab(ScheduleMode.CRON); setActiveScheduleTab(ScheduleMode.CRON);
} }
}; };
const handleSaveScheduleClick = async () => { const handleSaveScheduleClick = async () => {
if (isLocked) return; if (isLocked) return;
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
// Determine the effective settings based on the current view (tab) and inputs
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeTab);
// Call API
const success = await onSaveSchedule(settingsToSave); const success = await onSaveSchedule(settingsToSave);
if (success) { if (success) {
setLocalSchedule(settingsToSave); setLocalSchedule(settingsToSave);
// Dirty state is cleared by the useEffect prop update, or we can clear it optimistically here if needed,
// but useEffect [savedSchedule] handles it correctly.
} }
}; };
@@ -248,7 +436,6 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSync(); onSync();
}; };
// Helper to toggle enable/disable for current active tab (Daily/Weekly)
const toggleScheduleEnable = (targetMode: ScheduleMode) => { const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return; if (isLocked) return;
if (localSchedule.mode === targetMode) { if (localSchedule.mode === targetMode) {
@@ -258,7 +445,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"; const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return ( return (
@@ -279,12 +465,13 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div <div
className={`absolute className={`absolute
top-14 top-14
/* Mobile: Open to left */ /* Mobile: Open to left (max width of screen) */
right-0 origin-top-right right-0 w-[90vw] max-w-[90vw] origin-top-right
/* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
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 transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`} ${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
> >
@@ -328,96 +515,200 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
</div> </div>
{/* Section 2: Regex Preprocessing */} {/* 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">Backup Retention</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">Enable Backups</span>
<span className="text-[10px] text-gray-500">Create a copy before changes</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">Max versions to keep:</span>
</div>
<div className="flex items-center space-x-2">
<input
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 ? 'No auto-delete' : 'Oldest deleted automatically'}</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>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>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="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Path Mapping</h3>
{localReplacements.length === 0 && ( </div>
<button
onClick={handleAddRegex} {/* Tabs for Path Mapping Mode */}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors" <div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
title="Add Rule" {[
> { id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type },
<Plus size={14} /> { id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 },
</button> ].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="Path Mapping"
subtitle="Map Local paths to Cloud paths using simple string matching"
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path"
rightPlaceholder="Cloud Path"
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
/>
</div>
) : (
// Regex Mode: 2x2 Grid
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Pre-Processing (Before Sync)"
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Pre-Processing (Before Sync)"
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Post-Processing (After Sync / Result)"
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Post-Processing (After Sync / Result)"
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
</div>
)} )}
</div> </div>
<div className="space-y-2 mb-4 max-h-40 overflow-y-auto pr-1 custom-scrollbar"> <div className="flex justify-end items-center gap-2">
{localReplacements.length === 0 ? ( <button
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg"> onClick={handleResetMapping}
No regex replacements configured. disabled={!isMappingDirty}
</div> 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
localReplacements.map((regex) => ( ? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200"> : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
<div className="flex-1 min-w-0"> >
<input <RotateCcw size={12} />
type="text" <span>Revert</span>
placeholder="Pattern" </button>
value={regex.pattern} <button
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)} onClick={handleSaveMappingClick}
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 disabled={!isMappingDirty}
${!regex.pattern && isRegexDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`} 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
</div> ? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
<div className="flex-none text-gray-600"> : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
<ArrowRightCircle size={12} /> >
</div> <Save size={12} />
<div className="flex-1 min-w-0"> <span>Save Rules</span>
<input
type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
<div className="flex justify-between items-center gap-2">
<button
onClick={handleAddRegex}
className={`flex items-center space-x-1 px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide transition-colors ${localReplacements.length > 0 ? 'text-plex-orange hover:bg-plex-orange/10' : 'hidden'}`}
>
<Plus size={10} />
<span>Add</span>
</button> </button>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleResetRegex}
disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isRegexDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
</button>
<button
onClick={handleSaveRegex}
disabled={!isRegexDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isRegexDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
</button>
</div>
</div> </div>
</div> </div>
@@ -436,9 +727,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} 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 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' ? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5' : 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`} }`}
@@ -451,36 +742,50 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Tab Content */} {/* Tab Content */}
<div className="mb-4 min-h-[50px]"> <div className="mb-4 min-h-[50px]">
{activeTab === ScheduleMode.CRON && ( {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">
<div className="flex items-center space-x-2"> {/* Top Row: Label + Switch */}
<span className="text-gray-500 font-mono text-xs">Cron:</span> <div className="flex items-center justify-between mb-3 px-1">
<input <span className="text-xs text-gray-400 font-medium">Enable Cron Schedule</span>
type="text" <button
value={localSchedule.cronExpression} onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)} 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'}`}
placeholder="0 0 * * *" >
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" <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
type="text"
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.
</p>
</div> </div>
<p className="text-[10px] text-gray-500">
Unix-cron format. Leave empty to disable schedule.
</p>
</div> </div>
)} )}
{activeTab === ScheduleMode.DAILY && ( {activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-start space-x-2 mb-2"> <div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Daily Run</span>
<button <button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)} onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`} 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'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
> >
{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> </button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div> </div>
{/* Bottom Row: Centered Native Time Input */} {/* Bottom Row: Centered Native Time Input */}
@@ -496,18 +801,17 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
)} )}
{activeTab === ScheduleMode.WEEKLY && ( {activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-start space-x-2 mb-2"> <div className="flex items-center justify-between mb-3 px-1">
<button <span className="text-xs text-gray-400 font-medium">Enable Weekly Run</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)} onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`} 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'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"} >
> <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'}`} />
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />} </button>
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div> </div>
{/* Middle Row: Full Width Capsules */} {/* Middle Row: Full Width Capsules */}
@@ -546,24 +850,26 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
)} )}
</div> </div>
{/* Auto Watch Checkbox */} {/* Auto Watch Switch */}
<div className="flex items-center mb-4 px-1"> <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">Watch Local Changes</span>
<span className="text-[10px] text-gray-500">Auto-sync when local playlist updates</span>
</div>
</div>
<button <button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)} 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 ? ( <span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
<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>
</button> </button>
</div> </div>
{/* Action Buttons (Mirrored from Regex) */} {/* Action Buttons */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5"> <div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button <button
onClick={handleResetSchedule} onClick={handleResetSchedule}
@@ -599,7 +905,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked ${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50' ? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isRegexDirty : isMappingDirty || isBackupDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' ? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]' : 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`} }`}
@@ -616,9 +922,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</> </>
)} )}
</button> </button>
{(isRegexDirty) && ( {(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2"> <p className="text-[10px] text-plex-orange text-center mt-2">
Please save regex changes before syncing. Please save pending changes (Backups/Path Mapping) before syncing.
</p> </p>
)} )}
</div> </div>
+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 || ''; const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
@@ -38,24 +38,72 @@ const mapPlaylist = (item: any): Playlist => ({
const mapLibrary = (item: any): PlexLibrary => ({ const mapLibrary = (item: any): PlexLibrary => ({
id: item.id ?? item.title, id: item.id ?? item.title,
title: item.title ?? item.id, 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) => ({ (rules || []).map((rule, index) => ({
id: rule.id || `${rule.pattern || 'rule'}-${index}`, id: rule.id || `${rule.search || 'rule'}-${index}-${Date.now()}`,
pattern: rule.pattern || '', search: rule.search || rule.pattern || '',
replacement: rule.replacement || '', 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 = { 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 response = await fetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
const mode = result.data.sync_mode as string; const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE; const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const regex = mapRegexRules(result.data.path_rules || []); const pathMapping = mapPathMappingConfig(result.data);
const connection: PlexConnectionSettings = { const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https', protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '', address: result.data.server_url || '',
@@ -63,9 +111,9 @@ export const apiService = {
token: result.data.token || '', token: result.data.token || '',
libraryName: result.data.library_name || '', libraryName: result.data.library_name || '',
}; };
return { status: 'success', data: { strategy, 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 }>> { async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
@@ -78,9 +126,9 @@ export const apiService = {
return handleResponse(response); return handleResponse(response);
}, },
async saveRegexRules(replacements: RegexReplacement[]): Promise<ApiResponse<{ rules: RegexReplacement[] }>> { async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = { rules: replacements.map(({ pattern, replacement }) => ({ pattern, replacement })) }; const payload = pathMappingToApi(config);
const response = await fetch(`${API_BASE}/api/settings/regex-rules`, { const response = await fetch(`${API_BASE}/api/settings/path-mapping`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -170,7 +218,7 @@ export const apiService = {
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>; 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`, { const response = await fetch(`${API_BASE}/api/sync`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -186,4 +234,31 @@ export const apiService = {
const response = await fetch(`${API_BASE}/api/sync/status`); const response = await fetch(`${API_BASE}/api/sync/status`);
return handleResponse(response); 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);
},
}; };
+29
View File
@@ -34,6 +34,35 @@ export enum SyncState {
ERROR = 'ERROR' ERROR = 'ERROR'
} }
export interface ReplacementRule {
id: string;
search: string;
replace: string;
}
export interface PathMappingRules {
localPre: ReplacementRule[];
localPost: ReplacementRule[];
remotePre: ReplacementRule[];
remotePost: ReplacementRule[];
}
export enum PathMappingMode {
SIMPLE = 'SIMPLE',
REGEX = 'REGEX'
}
export interface PathMappingConfig {
mode: PathMappingMode;
simple: ReplacementRule[];
regex: PathMappingRules;
}
export interface BackupSettings {
enabled: boolean;
retentionCount: number;
}
export interface RegexReplacement { export interface RegexReplacement {
id: string; id: string;
pattern: string; pattern: string;
+22 -1
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; 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 { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
@@ -157,6 +159,12 @@ const App: React.FC = () => {
autoWatch: false autoWatch: false
}); });
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System // Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({}); const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -305,6 +313,17 @@ const App: React.FC = () => {
addToast('Path mapping rules have been saved.'); addToast('Path mapping rules have been saved.');
}; };
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast('Backup settings have been saved.');
} else {
addToast('Failed to save backup settings.');
}
};
// Handle Schedule Save // Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => { const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
// Call API (validation happens in Mock) // Call API (validation happens in Mock)
@@ -719,6 +738,8 @@ const App: React.FC = () => {
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedPathMapping={pathMappingConfig} savedPathMapping={pathMappingConfig}
onSavePathMapping={handleSavePathMapping} onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings} savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule} onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
@@ -141,8 +141,14 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const isConnected = !!connectedServerInfo; const isConnected = !!connectedServerInfo;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <div
<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]"> 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 */} {/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none"> <div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
+179 -58
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from '../types'; import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import { import {
ArrowRightCircle, ArrowRightCircle,
ArrowLeftCircle, ArrowLeftCircle,
@@ -16,10 +16,12 @@ import {
Calendar, Calendar,
Clock, Clock,
Repeat, Repeat,
CheckSquare,
Square,
Type, Type,
Code2 Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react'; } from 'lucide-react';
interface StrategyOption { interface StrategyOption {
@@ -90,17 +92,12 @@ const MAPPING_THEME = {
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => { const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule }; const derived = { ...schedule };
if (tab === ScheduleMode.CRON) { // Unified logic: If the mode matches the tab, we keep it (Enabled).
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED; // 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 { } else {
// For Daily/Weekly derived.mode = ScheduleMode.DISABLED;
// 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.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
} }
return derived; return derived;
}; };
@@ -186,7 +183,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)} 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}`} 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 <input
type="text" type="text"
placeholder={rightPlaceholder} placeholder={rightPlaceholder}
@@ -213,6 +210,8 @@ interface StrategySelectorProps {
onSelect: (strategy: SyncStrategy, label: string) => void; onSelect: (strategy: SyncStrategy, label: string) => void;
savedPathMapping: PathMappingConfig; savedPathMapping: PathMappingConfig;
onSavePathMapping: (config: PathMappingConfig) => void; onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings; savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>; onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState; syncState: SyncState;
@@ -224,6 +223,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSelect, onSelect,
savedPathMapping, savedPathMapping,
onSavePathMapping, onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule, savedSchedule,
onSaveSchedule, onSaveSchedule,
syncState, syncState,
@@ -236,6 +237,10 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping); const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isMappingDirty, setIsMappingDirty] = useState(false); 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 // Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule); const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false); const [isScheduleDirty, setIsScheduleDirty] = useState(false);
@@ -254,6 +259,11 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
setIsMappingDirty(false); setIsMappingDirty(false);
}, [savedPathMapping]); }, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => { useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule))); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) { if (savedSchedule.mode !== ScheduleMode.DISABLED) {
@@ -268,6 +278,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
setIsMappingDirty(isDifferent); setIsMappingDirty(isDifferent);
}, [localPathMapping, savedPathMapping]); }, [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) // Check dirty state for Schedule (including Active Tab changes)
useEffect(() => { useEffect(() => {
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab); const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
@@ -351,6 +367,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const regexRules = localPathMapping.regex; const regexRules = localPathMapping.regex;
const simpleRules = localPathMapping.simple; 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 --- // --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => { const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return; if (isLocked) return;
@@ -469,6 +501,80 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</div> </div>
</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">Backup Retention</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">Enable Backups</span>
<span className="text-[10px] text-gray-500">Create a copy before changes</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">Max versions to keep:</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">Oldest deleted automatically</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>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>Save</span>
</button>
</div>
</div>
</div>
{/* Section 2: Path Mapping (Tabs + Grid) */} {/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -620,35 +726,49 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Tab Content */} {/* Tab Content */}
<div className="mb-4 min-h-[50px]"> <div className="mb-4 min-h-[50px]">
{activeScheduleTab === ScheduleMode.CRON && ( {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">
<div className="flex items-center space-x-2"> {/* Top Row: Label + Switch */}
<span className="text-gray-500 font-mono text-xs">Cron:</span> <div className="flex items-center justify-between mb-3 px-1">
<input <span className="text-xs text-gray-400 font-medium">Enable Cron Schedule</span>
type="text" <button
value={localSchedule.cronExpression} onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)} 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'}`}
placeholder="0 0 * * *" >
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" <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
type="text"
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.
</p>
</div> </div>
<p className="text-[10px] text-gray-500">
Unix-cron format. Leave empty to disable schedule.
</p>
</div> </div>
)} )}
{activeScheduleTab === ScheduleMode.DAILY && ( {activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-start space-x-2 mb-2"> <div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Daily Run</span>
<button <button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)} onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`} 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'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
> >
{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> </button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div> </div>
{/* Bottom Row: Centered Native Time Input */} {/* Bottom Row: Centered Native Time Input */}
@@ -666,16 +786,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{activeScheduleTab === ScheduleMode.WEEKLY && ( {activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Checkbox + Label */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-start space-x-2 mb-2"> <div className="flex items-center justify-between mb-3 px-1">
<button <span className="text-xs text-gray-400 font-medium">Enable Weekly Run</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)} onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`} 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'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"} >
> <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'}`} />
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />} </button>
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div> </div>
{/* Middle Row: Full Width Capsules */} {/* Middle Row: Full Width Capsules */}
@@ -714,20 +833,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
)} )}
</div> </div>
{/* Auto Watch Checkbox */} {/* Auto Watch Switch */}
<div className="flex items-center mb-4 px-1"> <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">Watch Local Changes</span>
<span className="text-[10px] text-gray-500">Auto-sync when local playlist updates</span>
</div>
</div>
<button <button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)} 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 ? ( <span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
<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>
</button> </button>
</div> </div>
@@ -767,7 +888,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked ${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50' ? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isMappingDirty : isMappingDirty || isBackupDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' ? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]' : 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`} }`}
@@ -784,9 +905,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
</> </>
)} )}
</button> </button>
{(isMappingDirty) && ( {(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2"> <p className="text-[10px] text-plex-orange text-center mt-2">
Please save path mapping changes before syncing. Please save pending changes (Backups/Path Mapping) before syncing.
</p> </p>
)} )}
</div> </div>
+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'; import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
const SIMULATE_DELAY_MS = 800; const SIMULATE_DELAY_MS = 800;
@@ -220,5 +221,13 @@ export const apiService = {
resolve({ data: null, status: 'success', message: 'Schedule updated successfully' }); resolve({ data: null, status: 'success', message: 'Schedule updated successfully' });
}, 500); }, 500);
}); });
},
saveBackupSettings: async (settings: BackupSettings): Promise<ApiResponse<null>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
}, 500);
});
} }
}; };
+5
View File
@@ -59,6 +59,11 @@ export interface PathMappingConfig {
regex: PathMappingRules; regex: PathMappingRules;
} }
export interface BackupSettings {
enabled: boolean;
retentionCount: number;
}
export enum ScheduleMode { export enum ScheduleMode {
DISABLED = 'DISABLED', DISABLED = 'DISABLED',
CRON = 'CRON', CRON = 'CRON',