Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06f4c0683a | |||
| 588c84c2c8 | |||
| b483edae74 | |||
| 7b14445387 | |||
| 1bb07d7f68 |
+24
@@ -144,6 +144,30 @@ class ScheduleSettings(BaseModel):
|
||||
autoWatch: bool
|
||||
|
||||
|
||||
class BackupSettingsPayload(BaseModel):
|
||||
enabled: bool
|
||||
retention_count: int
|
||||
|
||||
|
||||
@app.get("/api/backup/settings")
|
||||
async def get_backup_settings():
|
||||
server_config.load()
|
||||
return {
|
||||
"enabled": server_config.backup_enabled,
|
||||
"retention_count": server_config.backup_retention_count
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/backup/settings")
|
||||
async def save_backup_settings(settings: BackupSettingsPayload):
|
||||
server_config.set_backup(
|
||||
enabled=settings.enabled,
|
||||
retention_count=settings.retention_count
|
||||
)
|
||||
logger.info(f"Backup settings updated. Enabled: {settings.enabled}, Retention: {settings.retention_count}")
|
||||
return {"status": "success", "message": "Backup settings saved"}
|
||||
|
||||
|
||||
@app.get("/api/schedule")
|
||||
async def get_schedule():
|
||||
next_run = get_next_run_time()
|
||||
|
||||
@@ -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_time = "03:00"
|
||||
self.schedule_auto_watch = False
|
||||
self.backup_enabled = False
|
||||
self.backup_retention_count = 5
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
@@ -89,6 +91,8 @@ class ServerConfig:
|
||||
self.schedule_weekly_days = config.get("schedule_weekly_days", [0])
|
||||
self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00")
|
||||
self.schedule_auto_watch = config.get("schedule_auto_watch", False)
|
||||
self.backup_enabled = config.get("backup_enabled", False)
|
||||
self.backup_retention_count = config.get("backup_retention_count", 5)
|
||||
logger.info(f"Server config loaded.")
|
||||
logger.debug(f"Current server config: {self.__dict__}")
|
||||
|
||||
@@ -111,6 +115,8 @@ class ServerConfig:
|
||||
"schedule_weekly_days": self.schedule_weekly_days,
|
||||
"schedule_weekly_time": self.schedule_weekly_time,
|
||||
"schedule_auto_watch": self.schedule_auto_watch,
|
||||
"backup_enabled": self.backup_enabled,
|
||||
"backup_retention_count": self.backup_retention_count,
|
||||
}
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=4, ensure_ascii=False)
|
||||
@@ -182,6 +188,15 @@ class ServerConfig:
|
||||
self.schedule_auto_watch = auto_watch
|
||||
self.save()
|
||||
|
||||
def set_backup(
|
||||
self,
|
||||
enabled: bool,
|
||||
retention_count: int,
|
||||
) -> None:
|
||||
self.backup_enabled = enabled
|
||||
self.backup_retention_count = retention_count
|
||||
self.save()
|
||||
|
||||
def set_and_save_config(
|
||||
self,
|
||||
theme: str = None,
|
||||
|
||||
@@ -66,3 +66,47 @@ def scan_local_playlists(base_path: str) -> list[dict]:
|
||||
playlists.sort(key=lambda item: item["name"].lower())
|
||||
logger.info(f"Found {len(playlists)} playlists under {absolute_path}.")
|
||||
return playlists
|
||||
def write_local_playlist(playlist_path: str, tracks: List[str]) -> bool:
|
||||
"""
|
||||
Write a list of tracks to a local playlist file.
|
||||
|
||||
Args:
|
||||
playlist_path (str): The path to the playlist file.
|
||||
tracks (list): A list of songs to write to the playlist.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
with open(playlist_path, 'w', encoding="utf-8") as file:
|
||||
file.write("#EXTM3U\n")
|
||||
for track in tracks:
|
||||
file.write(f"{track}\n")
|
||||
logger.info(f"Written {len(tracks)} songs to the playlist: {playlist_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred while writing the playlist {playlist_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_local_playlist(playlist_path: str) -> bool:
|
||||
"""
|
||||
Delete a local playlist file.
|
||||
|
||||
Args:
|
||||
playlist_path (str): The path to the playlist file.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
if os.path.exists(playlist_path):
|
||||
os.remove(playlist_path)
|
||||
logger.info(f"Deleted playlist: {playlist_path}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Playlist not found for deletion: {playlist_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred while deleting the playlist {playlist_path}: {e}")
|
||||
return False
|
||||
|
||||
@@ -311,4 +311,94 @@ class PlexClient:
|
||||
)
|
||||
return local_2_plex
|
||||
|
||||
def get_playlist(self, title: str):
|
||||
"""Get a playlist by title."""
|
||||
self._connect_check()
|
||||
try:
|
||||
# Exact match search for playlist
|
||||
playlists = self.server.playlists(title=title)
|
||||
if playlists:
|
||||
return playlists[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching playlist {title}: {e}")
|
||||
return None
|
||||
|
||||
def create_playlist(self, title: str, items: list):
|
||||
"""Create a new playlist with the given items."""
|
||||
self._connect_check()
|
||||
try:
|
||||
self.server.createPlaylist(title, items=items)
|
||||
logger.info(f"Created playlist {title} with {len(items)} items.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating playlist {title}: {e}")
|
||||
return False
|
||||
|
||||
def delete_playlist(self, title: str):
|
||||
"""Delete a playlist by title."""
|
||||
self._connect_check()
|
||||
try:
|
||||
playlist = self.get_playlist(title)
|
||||
if playlist:
|
||||
playlist.delete()
|
||||
logger.info(f"Deleted playlist {title}.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Playlist {title} not found for deletion.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting playlist {title}: {e}")
|
||||
return False
|
||||
|
||||
def update_playlist(self, title: str, items: list):
|
||||
"""
|
||||
Update a playlist with a new list of items.
|
||||
This implementation replaces the existing items with the new ones.
|
||||
"""
|
||||
self._connect_check()
|
||||
try:
|
||||
playlist = self.get_playlist(title)
|
||||
if not playlist:
|
||||
return self.create_playlist(title, items)
|
||||
|
||||
# Remove all items and add new ones
|
||||
playlist.removeItems(playlist.items())
|
||||
if items:
|
||||
playlist.addItems(items)
|
||||
logger.info(f"Updated playlist {title} with {len(items)} items.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating playlist {title}: {e}")
|
||||
return False
|
||||
|
||||
def get_items_by_paths(self, library_name: str, paths: list[str]) -> list:
|
||||
"""
|
||||
Find Plex items (tracks) by their file paths.
|
||||
"""
|
||||
self._connect_check()
|
||||
if not paths:
|
||||
return []
|
||||
|
||||
try:
|
||||
path_map = self.match_tracks(library_name, paths)
|
||||
except FileNotFoundError:
|
||||
logger.info(f"Cache not found for {library_name}, creating it...")
|
||||
self.cache_lib_tracks(library_name)
|
||||
path_map = self.match_tracks(library_name, paths)
|
||||
|
||||
items = []
|
||||
for path in paths:
|
||||
rating_key = path_map.get(path)
|
||||
if rating_key and rating_key != UNMATCHED_TRACK_RATING_KEY:
|
||||
try:
|
||||
item = self.server.fetchItem(rating_key)
|
||||
items.append(item)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch item for ratingKey {rating_key}: {e}")
|
||||
else:
|
||||
logger.warning(f"Track not found in Plex library (or unmatched): {path}")
|
||||
|
||||
return items
|
||||
|
||||
plex_client = PlexClient()
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import threading
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from app.utils.logger import logger
|
||||
from app.utils.playlist_merge import sync_all_playlists, SyncMode
|
||||
from app.utils.config import server_config
|
||||
from app.utils.backup import perform_backup_before_sync
|
||||
from app.utils.local_playlist import load_local_playlist, write_local_playlist, delete_local_playlist
|
||||
from app.utils.plex_client import plex_client
|
||||
|
||||
class SyncManager:
|
||||
def __init__(self):
|
||||
@@ -110,8 +114,58 @@ class SyncManager:
|
||||
if sync_kwargs:
|
||||
kwargs.update(sync_kwargs)
|
||||
|
||||
# Perform backup before sync if enabled
|
||||
local_dir = kwargs.get("local_dir", server_config.local_path)
|
||||
perform_backup_before_sync(local_dir, server_config.library_name)
|
||||
|
||||
# Execute sync
|
||||
return sync_all_playlists(**kwargs)
|
||||
results = sync_all_playlists(**kwargs)
|
||||
|
||||
# Apply results (write to local and remote)
|
||||
self._apply_sync_results(results)
|
||||
|
||||
return results
|
||||
|
||||
def _apply_sync_results(self, results):
|
||||
logger.info("Applying sync results to local and remote...")
|
||||
for result in results:
|
||||
playlist_name = result.name
|
||||
action = result.action
|
||||
output_dir = result.output_dir
|
||||
|
||||
try:
|
||||
if action == "synced":
|
||||
# 1. Write Local
|
||||
local_result_path = os.path.join(output_dir, "local_result.m3u8")
|
||||
if os.path.exists(local_result_path):
|
||||
tracks = load_local_playlist(local_result_path)
|
||||
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
write_local_playlist(dest_path, tracks)
|
||||
|
||||
# 2. Write Remote (Plex)
|
||||
remote_result_path = os.path.join(output_dir, "remote_result.m3u8")
|
||||
if os.path.exists(remote_result_path):
|
||||
tracks = load_local_playlist(remote_result_path)
|
||||
if server_config.library_name:
|
||||
items = plex_client.get_items_by_paths(server_config.library_name, tracks)
|
||||
plex_client.update_playlist(playlist_name, items)
|
||||
else:
|
||||
logger.warning("Library name not configured, skipping Plex update.")
|
||||
|
||||
elif action == "deleted":
|
||||
# Delete Local
|
||||
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
|
||||
delete_local_playlist(dest_path)
|
||||
# Also check for .m3u
|
||||
dest_path_m3u = os.path.join(server_config.local_path, f"{playlist_name}.m3u")
|
||||
delete_local_playlist(dest_path_m3u)
|
||||
|
||||
# Delete Remote
|
||||
plex_client.delete_playlist(playlist_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying sync result for playlist {playlist_name}: {e}")
|
||||
|
||||
def _complete_sync(self, status, error=None):
|
||||
with self._lock:
|
||||
|
||||
+29
-2
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
STRIPE_BASE_SPEED,
|
||||
@@ -161,6 +161,12 @@ const App: React.FC = () => {
|
||||
});
|
||||
const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined);
|
||||
|
||||
// Backup State
|
||||
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
|
||||
enabled: false,
|
||||
retentionCount: 5
|
||||
});
|
||||
|
||||
// Toast Notification System
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
|
||||
@@ -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
|
||||
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
|
||||
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
|
||||
const refreshLocal = useCallback(async () => {
|
||||
if (localAbortRef.current) localAbortRef.current.abort();
|
||||
@@ -329,7 +353,8 @@ const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadSchedule();
|
||||
}, [loadSettings, loadSchedule]);
|
||||
loadBackupSettings();
|
||||
}, [loadSettings, loadSchedule, loadBackupSettings]);
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
@@ -749,6 +774,8 @@ const App: React.FC = () => {
|
||||
onSelect={handleStrategyChange}
|
||||
savedPathMapping={pathMappingConfig}
|
||||
onSavePathMapping={handleSavePathMapping}
|
||||
savedBackup={backupSettings}
|
||||
onSaveBackup={handleSaveBackupSettings}
|
||||
savedSchedule={scheduleSettings}
|
||||
onSaveSchedule={handleSaveSchedule}
|
||||
syncState={syncState}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from '../types';
|
||||
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
|
||||
import {
|
||||
ArrowRightCircle,
|
||||
ArrowLeftCircle,
|
||||
@@ -16,10 +16,12 @@ import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Repeat,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Type,
|
||||
Code2
|
||||
Code2,
|
||||
Link,
|
||||
Archive,
|
||||
History,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
|
||||
// Generate a UUID for mapping rules
|
||||
@@ -104,18 +106,13 @@ const MAPPING_THEME = {
|
||||
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
|
||||
const derived = { ...schedule };
|
||||
|
||||
if (tab === ScheduleMode.CRON) {
|
||||
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED;
|
||||
} else {
|
||||
// For Daily/Weekly
|
||||
// If the mode matches the tab, we keep it (Enabled).
|
||||
// If the mode doesn't match (e.g. it was CRON or DISABLED), then in the context of this tab, it is effectively Disabled until the user checks the box.
|
||||
// Unified logic: If the mode matches the tab, we keep it (Enabled).
|
||||
// If the mode doesn't match (e.g. it was DISABLED), then in the context of this tab, it remains Disabled until the user toggles the switch.
|
||||
if (derived.mode === tab) {
|
||||
derived.mode = tab;
|
||||
} else {
|
||||
derived.mode = ScheduleMode.DISABLED;
|
||||
}
|
||||
}
|
||||
return derived;
|
||||
};
|
||||
|
||||
@@ -200,7 +197,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
|
||||
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
|
||||
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
|
||||
/>
|
||||
<ArrowRightCircle size={10} className="text-gray-600 flex-none opacity-50" />
|
||||
<Link size={12} className="text-gray-600 flex-none opacity-50" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={rightPlaceholder}
|
||||
@@ -227,6 +224,8 @@ interface StrategySelectorProps {
|
||||
onSelect: (strategy: SyncStrategy, label: string) => void;
|
||||
savedPathMapping: PathMappingConfig;
|
||||
onSavePathMapping: (config: PathMappingConfig) => void;
|
||||
savedBackup: BackupSettings;
|
||||
onSaveBackup: (settings: BackupSettings) => void;
|
||||
savedSchedule: ScheduleSettings;
|
||||
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
|
||||
syncState: SyncState;
|
||||
@@ -238,6 +237,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
onSelect,
|
||||
savedPathMapping,
|
||||
onSavePathMapping,
|
||||
savedBackup,
|
||||
onSaveBackup,
|
||||
savedSchedule,
|
||||
onSaveSchedule,
|
||||
syncState,
|
||||
@@ -250,6 +251,10 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
|
||||
const [isMappingDirty, setIsMappingDirty] = useState(false);
|
||||
|
||||
// Local state for Backup Settings
|
||||
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
|
||||
const [isBackupDirty, setIsBackupDirty] = useState(false);
|
||||
|
||||
// Local state for Schedule editing
|
||||
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
|
||||
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
|
||||
@@ -268,6 +273,11 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
setIsMappingDirty(false);
|
||||
}, [savedPathMapping]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
|
||||
setIsBackupDirty(false);
|
||||
}, [savedBackup]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
|
||||
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
|
||||
@@ -282,6 +292,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
setIsMappingDirty(isDifferent);
|
||||
}, [localPathMapping, savedPathMapping]);
|
||||
|
||||
// Check dirty state for backup
|
||||
useEffect(() => {
|
||||
const isDifferent = JSON.stringify(localBackup) !== JSON.stringify(savedBackup);
|
||||
setIsBackupDirty(isDifferent);
|
||||
}, [localBackup, savedBackup]);
|
||||
|
||||
// Check dirty state for Schedule (including Active Tab changes)
|
||||
useEffect(() => {
|
||||
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
|
||||
@@ -365,6 +381,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
const regexRules = localPathMapping.regex;
|
||||
const simpleRules = localPathMapping.simple;
|
||||
|
||||
// --- Backup Handlers ---
|
||||
const handleUpdateBackup = (field: keyof BackupSettings, value: any) => {
|
||||
if (isLocked) return;
|
||||
setLocalBackup(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleResetBackup = () => {
|
||||
if (isLocked) return;
|
||||
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
|
||||
};
|
||||
|
||||
const handleSaveBackupClick = () => {
|
||||
if (isLocked) return;
|
||||
onSaveBackup(localBackup);
|
||||
};
|
||||
|
||||
// --- Schedule Handlers ---
|
||||
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
|
||||
if (isLocked) return;
|
||||
@@ -483,6 +515,83 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1.5: Backup Retention */}
|
||||
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">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="flex items-center justify-between mb-3">
|
||||
@@ -634,7 +743,20 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
{/* Tab Content */}
|
||||
<div className="mb-4 min-h-[50px]">
|
||||
{activeScheduleTab === ScheduleMode.CRON && (
|
||||
<div className="space-y-2 animate-in fade-in duration-200">
|
||||
<div className="flex flex-col animate-in fade-in duration-200">
|
||||
{/* Top Row: Label + Switch */}
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<span className="text-xs text-gray-400 font-medium">Enable Cron Schedule</span>
|
||||
<button
|
||||
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.CRON ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`space-y-2 transition-opacity duration-200 ${localSchedule.mode !== ScheduleMode.CRON ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-500 font-mono text-xs">Cron:</span>
|
||||
<input
|
||||
@@ -642,27 +764,28 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
value={localSchedule.cronExpression}
|
||||
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)}
|
||||
placeholder="0 0 * * *"
|
||||
disabled={localSchedule.mode !== ScheduleMode.CRON}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 font-mono focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange placeholder-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
Unix-cron format. Leave empty to disable schedule.
|
||||
Unix-cron format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeScheduleTab === ScheduleMode.DAILY && (
|
||||
<div className="flex flex-col animate-in fade-in duration-200">
|
||||
{/* Top Row: Checkbox + Label */}
|
||||
<div className="flex items-center justify-start space-x-2 mb-2">
|
||||
{/* Top Row: Label + Switch */}
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<span className="text-xs text-gray-400 font-medium">Enable Daily Run</span>
|
||||
<button
|
||||
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
|
||||
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.DAILY ? 'bg-plex-orange' : 'bg-gray-700'}`}
|
||||
>
|
||||
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.DAILY ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Centered Native Time Input */}
|
||||
@@ -680,16 +803,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
|
||||
{activeScheduleTab === ScheduleMode.WEEKLY && (
|
||||
<div className="flex flex-col animate-in fade-in duration-200">
|
||||
{/* Top Row: Checkbox + Label */}
|
||||
<div className="flex items-center justify-start space-x-2 mb-2">
|
||||
{/* Top Row: Label + Switch */}
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<span className="text-xs text-gray-400 font-medium">Enable Weekly Run</span>
|
||||
<button
|
||||
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
|
||||
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
|
||||
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.WEEKLY ? 'bg-plex-orange' : 'bg-gray-700'}`}
|
||||
>
|
||||
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.WEEKLY ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
|
||||
</div>
|
||||
|
||||
{/* Middle Row: Full Width Capsules */}
|
||||
@@ -728,20 +850,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto Watch Checkbox */}
|
||||
<div className="flex items-center mb-4 px-1">
|
||||
{/* Auto Watch Switch */}
|
||||
<div className="flex items-center justify-between mb-4 mt-2 px-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 text-orange-400">
|
||||
<Eye size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-200">Watch Local Changes</span>
|
||||
<span className="text-[10px] text-gray-500">Auto-sync when local playlist updates</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
|
||||
className="flex items-center space-x-2 group"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.autoWatch ? 'bg-plex-orange' : 'bg-gray-700'}`}
|
||||
>
|
||||
{localSchedule.autoWatch ? (
|
||||
<CheckSquare size={16} className="text-plex-orange" />
|
||||
) : (
|
||||
<Square size={16} className="text-gray-600 group-hover:text-gray-400" />
|
||||
)}
|
||||
<span className={`text-xs ${localSchedule.autoWatch ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'}`}>
|
||||
Watch for local playlist changes
|
||||
</span>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
${isLocked
|
||||
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
|
||||
: isMappingDirty
|
||||
: isMappingDirty || isBackupDirty
|
||||
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
|
||||
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
|
||||
}`}
|
||||
@@ -798,9 +922,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{(isMappingDirty) && (
|
||||
{(isMappingDirty || isBackupDirty) && (
|
||||
<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>
|
||||
)}
|
||||
</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 || '';
|
||||
|
||||
@@ -234,4 +234,31 @@ export const apiService = {
|
||||
const response = await fetch(`${API_BASE}/api/sync/status`);
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async getBackupSettings(): Promise<ApiResponse<BackupSettings>> {
|
||||
const response = await fetch(`${API_BASE}/api/backup/settings`);
|
||||
const result = await handleResponse<any>(response);
|
||||
if (result.status === 'success') {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
enabled: result.data.enabled ?? false,
|
||||
retentionCount: result.data.retention_count ?? 5,
|
||||
},
|
||||
};
|
||||
}
|
||||
return result as ApiResponse<BackupSettings>;
|
||||
},
|
||||
|
||||
async saveBackupSettings(settings: BackupSettings): Promise<ApiResponse<null>> {
|
||||
const response = await fetch(`${API_BASE}/api/backup/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: settings.enabled,
|
||||
retention_count: settings.retentionCount,
|
||||
}),
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface PathMappingConfig {
|
||||
regex: PathMappingRules;
|
||||
}
|
||||
|
||||
export interface BackupSettings {
|
||||
enabled: boolean;
|
||||
retentionCount: number;
|
||||
}
|
||||
|
||||
export interface RegexReplacement {
|
||||
id: string;
|
||||
pattern: string;
|
||||
|
||||
Reference in New Issue
Block a user