Compare commits
5 Commits
df4f5dde17
...
06f4c0683a
| Author | SHA1 | Date | |
|---|---|---|---|
| 06f4c0683a | |||
| 588c84c2c8 | |||
| b483edae74 | |||
| 7b14445387 | |||
| 1bb07d7f68 |
+24
@@ -144,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()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -39,6 +39,8 @@ class ServerConfig:
|
|||||||
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:
|
||||||
@@ -89,6 +91,8 @@ class ServerConfig:
|
|||||||
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__}")
|
||||||
|
|
||||||
@@ -111,6 +115,8 @@ class ServerConfig:
|
|||||||
"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)
|
||||||
@@ -182,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,
|
||||||
|
|||||||
@@ -66,3 +66,47 @@ 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
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
+29
-2
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types';
|
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, 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,
|
||||||
@@ -161,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>}>({});
|
||||||
@@ -246,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);
|
||||||
@@ -267,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();
|
||||||
@@ -329,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(() => {
|
||||||
@@ -749,6 +774,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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
// Generate a UUID for mapping rules
|
// Generate a UUID for mapping rules
|
||||||
@@ -104,17 +106,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;
|
||||||
};
|
};
|
||||||
@@ -200,7 +197,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}
|
||||||
@@ -227,6 +224,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;
|
||||||
@@ -238,6 +237,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
savedPathMapping,
|
savedPathMapping,
|
||||||
onSavePathMapping,
|
onSavePathMapping,
|
||||||
|
savedBackup,
|
||||||
|
onSaveBackup,
|
||||||
savedSchedule,
|
savedSchedule,
|
||||||
onSaveSchedule,
|
onSaveSchedule,
|
||||||
syncState,
|
syncState,
|
||||||
@@ -250,6 +251,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);
|
||||||
@@ -268,6 +273,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) {
|
||||||
@@ -282,6 +292,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);
|
||||||
@@ -365,6 +381,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;
|
||||||
@@ -483,6 +515,83 @@ 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="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) */}
|
{/* 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">
|
||||||
@@ -634,35 +743,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 */}
|
||||||
@@ -680,16 +803,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 */}
|
||||||
@@ -728,20 +850,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>
|
||||||
|
|
||||||
@@ -781,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'
|
||||||
: 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]'
|
||||||
}`}
|
}`}
|
||||||
@@ -798,9 +922,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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings } from '../types';
|
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, 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 || '';
|
||||||
|
|
||||||
@@ -234,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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export interface PathMappingConfig {
|
|||||||
regex: PathMappingRules;
|
regex: PathMappingRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
retentionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegexReplacement {
|
export interface RegexReplacement {
|
||||||
id: string;
|
id: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user