50 Commits

Author SHA1 Message Date
Koha9 96c853125c Fix watcher sync loop when auto-watch is enabled 2026-01-15 16:26:52 +09:00
Koha9 c00d6100c2 Merge branch 'feat/Identification_features' 2026-01-15 15:10:25 +09:00
Koha9 86f18cc410 feat: add safe filename handling for backup and sync processes to prevent invalid paths 2025-12-20 04:46:09 +09:00
Koha9 254c391c89 feat: enhance logging by redacting sensitive information and update .gitignore for runtime logs 2025-12-19 03:23:38 +09:00
Koha9 a0631c6280 feat: update .gitignore and add docker-compose sample for service configuration 2025-12-19 03:02:30 +09:00
Koha9 1806e0823f feat: add CORS configuration options to enhance API security 2025-12-19 02:33:00 +09:00
Koha9 ea5a0004da feat: add identification page 2025-12-18 06:46:27 +09:00
Koha9 e3d3df9ecb PlexPlaylist_UI subtree merge
feat: Implement user authentication and login screen

Merge commit 'a14210c458d5f6c6a4875ca8228db63c0b73cf75'
2025-12-17 20:25:06 +09:00
Koha9 a14210c458 Squashed 'sample-front-end/' changes from 32f6ed7..e855771
e855771 feat: Implement user authentication and login screen

git-subtree-dir: sample-front-end
git-subtree-split: e8557717c9397bb10286a61f1d29a9976c10ecba
2025-12-17 20:25:06 +09:00
Koha9 f0b129a27e feat: docker-compose update for enhance configuration flexibility 2025-12-15 10:47:51 +09:00
Koha9 9ddc0d9eb2 feat: Refactor backup and sync paths, enhance configuration flexibility 2025-12-15 10:45:28 +09:00
Koha9 834e21b331 Merge branch 'UI-internationalization' 2025-12-15 08:13:52 +09:00
Koha9 e1208420a0 feat: Add cht language support. 2025-12-14 03:52:35 +09:00
Koha9 575d1a7008 feat: Update week day representation and add localization support for weekdays 2025-12-14 03:14:58 +09:00
Koha9 4d3bb6cfd8 feat: Add chs language support. 2025-12-14 03:09:29 +09:00
Koha9 0629ffc3bc Merge branch 'main' into UI-internationalization 2025-12-14 01:06:56 +09:00
Koha9 a6f0d1c73c fix: CRLF error.
add CRLF to LF in Dockerfile.
2025-12-14 01:06:17 +09:00
Koha9 a7c3b544fa feat: Added the OverflowMarquee, implemented auto-scrolling text. 2025-12-14 01:04:26 +09:00
Koha9 5c6b0b0444 Merge branch 'main' into UI-internationalization 2025-12-13 20:40:39 +09:00
Koha9 b1c9fa5f8e feat: Add timezone setting 2025-12-13 17:51:20 +09:00
Koha9 cae08acab3 feat: Implement i18n infrastructure 2025-12-13 17:29:27 +09:00
Koha9 a745adc1ab Squashed 'sample-front-end/' changes from 9a32272..32f6ed7
32f6ed7 feat: Implement internationalization and rename project

git-subtree-dir: sample-front-end
git-subtree-split: 32f6ed743b43e001e6d1170cc370521f6b4173a2
2025-12-09 05:19:21 +09:00
Koha9 2fc8a32b5f PlexPlaylist_UI subtree merge
feat: Implement internationalization and rename project

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
2025-11-30 22:11:29 +00:00
copilot-swe-agent[bot] c18ff5b2ef Initial plan 2025-11-30 22:00:19 +00:00
56 changed files with 4936 additions and 801 deletions
+31
View File
@@ -0,0 +1,31 @@
# Timezone
TZ=Asia/Tokyo
# Enable authentication (required)
# 1 = enabled, 0 = disabled
PLEXPLAYLISTSYNC_AUTH_ENABLED=1
# Login username/password (required if auth enabled)
PLEXPLAYLISTSYNC_AUTH_USERNAME=USERNAME
PLEXPLAYLISTSYNC_AUTH_PASSWORD=CHANGE_PASSWORD
# Strongly recommended: stable token signing secret (or tokens will become invalid after container restart)
# Use a sufficiently long random string
PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET=REPLACE_WITH_A_RANDOM_STRING
# Token TTL seconds (optional)
PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS=86400
# CORS allowlist (optional)
# Default is empty (CORS disabled; same-origin only).
# Accepts comma-separated list or a JSON array.
# Example:
# PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# or
# PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=["https://your.domain"]
PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=
# Allow cookies/credentials for allowlisted origins (optional)
# 1 = enabled, 0 = disabled
# Note: if origins contains '*', credentials will be forced off.
PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS=0
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings to avoid CRLF issues in Linux containers
* text=auto
# Shell scripts must be LF for correct shebang parsing
*.sh text eol=lf
# PowerShell scripts are typically CRLF on Windows
*.ps1 text eol=crlf
+11
View File
@@ -62,6 +62,9 @@ local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal
# App runtime logs
app/logs/
# Flask stuff: # Flask stuff:
instance/ instance/
.webassets-cache .webassets-cache
@@ -176,3 +179,11 @@ cython_debug/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# data
data/*
playlists/*
docker-compose.yml
# Local dev config may contain Plex token
app/config.json
+7 -1
View File
@@ -6,9 +6,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \ && apt-get install -y --no-install-recommends build-essential tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@@ -17,4 +21,6 @@ COPY frontend ./frontend
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+16
View File
@@ -26,6 +26,22 @@ PlexPlaylistSync 是一个用于同步 Plex 播放列表和本地 `.m3u`/`.m3u8`
docker compose up --build docker compose up --build
``` ```
### 配置容器时区
本项目支持通过 `docker-compose.yml` 的环境变量 `TZ` 配置容器运行时区(需要使用有效的 IANA 时区名,例如 `Asia/Shanghai``UTC``America/New_York`)。
- 临时指定(当前终端会话生效):
```bash
TZ=UTC docker compose up --build
```
- 或在项目根目录创建 `.env`
```env
TZ=Asia/Shanghai
```
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080` - 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。 - 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。 - 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
-12
View File
@@ -1,12 +0,0 @@
{
"theme": "auto",
"token": "",
"server_url": "",
"server_port": "32400",
"server_scheme": "https",
"timeout": 9,
"library_name": "",
"sync_mode": "merge_local_primary",
"local_path": "playlist",
"path_rules": []
}
+208 -70
View File
@@ -1,11 +1,12 @@
import os import os
import json
from datetime import datetime from datetime import datetime
from typing import Sequence from typing import Sequence
from fastapi import FastAPI, Form, HTTPException, Query, Request from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import asyncio import asyncio
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -13,13 +14,137 @@ from pydantic import BaseModel, Field
from app.utils.config import server_config from app.utils.config import server_config
from app.utils.local_playlist import load_local_playlist, scan_local_playlists from app.utils.local_playlist import load_local_playlist, scan_local_playlists
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.playlist_merge import SyncMode, TEST_PLAYLIST_DIR, sync_all_playlists from app.utils.playlist_merge import SyncMode, SYNC_ARTIFACTS_DIR, sync_all_playlists
from app.utils.plex_client import plex_client from app.utils.plex_client import plex_client
from app.utils.scheduler import start_scheduler, update_scheduler_job, get_next_run_time, validate_cron_expression from app.utils.scheduler import start_scheduler, update_scheduler_job, get_next_run_time, validate_cron_expression
from app.utils.sync_manager import sync_manager from app.utils.sync_manager import sync_manager
from app.utils.auth import load_auth_config, issue_token, verify_token
app = FastAPI() app = FastAPI()
def _parse_cors_allowed_origins(raw: str | None) -> list[str]:
if not raw:
return []
value = raw.strip()
if not value:
return []
if value == "*":
return ["*"]
try:
if value.startswith("["):
parsed = json.loads(value)
if isinstance(parsed, list):
origins = [str(item).strip() for item in parsed]
else:
origins = [str(parsed).strip()]
else:
origins = [part.strip() for part in value.split(",")]
except Exception:
logger.warning(
"Invalid PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS; CORS will be disabled."
)
return []
return [origin for origin in origins if origin]
def _env_truthy(name: str, default: str = "0") -> bool:
value = os.getenv(name, default)
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
_CORS_ALLOWED_ORIGINS = _parse_cors_allowed_origins(
os.getenv("PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS", "")
)
_CORS_ALLOW_CREDENTIALS = (
False
if "*" in _CORS_ALLOWED_ORIGINS
else _env_truthy("PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS", "0")
)
# --- Optional API Authentication (username/password) ---
AUTH_CONFIG = load_auth_config()
class LoginPayload(BaseModel):
username: str
password: str
@app.get("/api/auth/config")
async def get_auth_config():
return {"enabled": AUTH_CONFIG.enabled}
@app.post("/api/auth/login")
async def api_login(payload: LoginPayload):
if not AUTH_CONFIG.enabled:
raise HTTPException(status_code=400, detail="Authentication is disabled")
if payload.username != AUTH_CONFIG.username or payload.password != AUTH_CONFIG.password:
raise HTTPException(status_code=401, detail="Invalid credentials")
token = issue_token(AUTH_CONFIG, payload.username)
return {
"token": token,
"username": payload.username,
"expires_in": AUTH_CONFIG.token_ttl_seconds,
}
@app.get("/api/auth/me")
async def api_me(request: Request):
if not AUTH_CONFIG.enabled:
return {"username": ""}
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
else:
token = ""
payload = verify_token(AUTH_CONFIG, token) if token else None
if not payload:
raise HTTPException(status_code=401, detail="Unauthorized")
return {"username": payload.get("u", "")}
@app.post("/api/auth/logout")
async def api_logout():
# Stateless token auth; client clears token.
return {"status": "success"}
_AUTH_API_WHITELIST = {
"/api/auth/config",
"/api/auth/login",
}
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if AUTH_CONFIG.enabled and request.url.path.startswith("/api"):
if request.method.upper() == "OPTIONS":
return await call_next(request)
if request.url.path not in _AUTH_API_WHITELIST:
auth_header = request.headers.get("authorization", "")
token = ""
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
# For endpoints like EventSource(SSE) where custom headers are not available.
if not token:
token = request.query_params.get("access_token", "").strip()
if not token or not verify_token(AUTH_CONFIG, token):
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
return await call_next(request)
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
sync_manager.set_event_loop(asyncio.get_running_loop()) sync_manager.set_event_loop(asyncio.get_running_loop())
@@ -27,8 +152,8 @@ async def startup_event():
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=_CORS_ALLOWED_ORIGINS,
allow_credentials=True, allow_credentials=_CORS_ALLOW_CREDENTIALS,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -92,9 +217,29 @@ class RegexRule(BaseModel):
replacement: str = "" replacement: str = ""
class ReplacementRule(BaseModel):
id: str = ""
search: str
replace: str = ""
class RegexRulesGroup(BaseModel):
local_pre: list[ReplacementRule] = []
local_post: list[ReplacementRule] = []
remote_pre: list[ReplacementRule] = []
remote_post: list[ReplacementRule] = []
class PathMappingPayload(BaseModel):
mode: str = "SIMPLE"
simple: list[ReplacementRule] = []
regex: RegexRulesGroup = RegexRulesGroup()
class SyncSettingsResponse(BaseModel): class SyncSettingsResponse(BaseModel):
sync_mode: str sync_mode: str
path_rules: list[RegexRule] path_rules: list[RegexRule]
path_mapping: dict | None = None
local_path: str local_path: str
library_name: str | None = None library_name: str | None = None
server_url: str | None = None server_url: str | None = None
@@ -124,6 +269,30 @@ class ScheduleSettings(BaseModel):
autoWatch: bool autoWatch: bool
class BackupSettingsPayload(BaseModel):
enabled: bool
retention_count: int
@app.get("/api/backup/settings")
async def get_backup_settings():
server_config.load()
return {
"enabled": server_config.backup_enabled,
"retention_count": server_config.backup_retention_count
}
@app.put("/api/backup/settings")
async def save_backup_settings(settings: BackupSettingsPayload):
server_config.set_backup(
enabled=settings.enabled,
retention_count=settings.retention_count
)
logger.info(f"Backup settings updated. Enabled: {settings.enabled}, Retention: {settings.retention_count}")
return {"status": "success", "message": "Backup settings saved"}
@app.get("/api/schedule") @app.get("/api/schedule")
async def get_schedule(): async def get_schedule():
next_run = get_next_run_time() next_run = get_next_run_time()
@@ -352,6 +521,7 @@ async def get_settings():
return SyncSettingsResponse( return SyncSettingsResponse(
sync_mode=server_config.sync_mode, sync_mode=server_config.sync_mode,
path_rules=rules, path_rules=rules,
path_mapping=server_config.path_mapping,
local_path=server_config.local_path, local_path=server_config.local_path,
library_name=server_config.library_name, library_name=server_config.library_name,
server_url=server_config.url, server_url=server_config.url,
@@ -380,6 +550,22 @@ async def update_regex_rules(payload: RegexRulePayload):
return {"rules": payload.rules} return {"rules": payload.rules}
@app.put("/api/settings/path-mapping")
async def update_path_mapping(payload: PathMappingPayload):
path_mapping_dict = {
"mode": payload.mode,
"simple": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.simple],
"regex": {
"local_pre": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.local_pre],
"local_post": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.local_post],
"remote_pre": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_pre],
"remote_post": [{"id": rule.id, "search": rule.search, "replace": rule.replace} for rule in payload.regex.remote_post],
}
}
server_config.set_and_save_config(path_mapping=path_mapping_dict)
return {"path_mapping": server_config.path_mapping}
@app.put("/api/settings/library") @app.put("/api/settings/library")
async def update_library(payload: LibrarySelection): async def update_library(payload: LibrarySelection):
server_config.set_and_save_config(library_name=payload.library_name) server_config.set_and_save_config(library_name=payload.library_name)
@@ -438,8 +624,8 @@ async def api_connect(payload: ConnectRequest):
async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"), local_path: str | None = None): async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"), local_path: str | None = None):
server_type = server.lower() server_type = server.lower()
if server_type == "local": if server_type == "local":
resolved_path = local_path or server_config.local_path # local_path is intentionally fixed; ignore query overrides.
server_config.set_and_save_config(local_path=resolved_path) resolved_path = server_config.local_path
playlists = _scan_local_playlists_with_meta(resolved_path) playlists = _scan_local_playlists_with_meta(resolved_path)
return {"playlists": [item.model_dump() for item in playlists]} return {"playlists": [item.model_dump() for item in playlists]}
@@ -481,57 +667,8 @@ async def api_sync(payload: SyncRequest):
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
# Update config temporarily for this sync if needed, but sync_manager reads from config. # local_path is intentionally fixed; ignore request overrides.
# If payload overrides config, we might need to handle that. local_dir = server_config.local_path
# However, sync_manager._perform_sync reads from server_config.
# If we want to support one-off sync with custom params via sync_manager, we need to update sync_manager.
# For now, let's assume payload params should be saved or used.
# But sync_manager is designed to run background tasks too.
# If we want to keep the existing behavior of api_sync (blocking and returning stats),
# we can use sync_manager.run_sync(wait=True).
# But we need to make sure sync_manager uses the params from payload if provided.
# Since sync_manager reads from server_config, let's update server_config if payload has values.
# Or better, pass params to sync_manager.run_sync?
# sync_manager._perform_sync currently hardcodes reading from server_config.
# Let's stick to the requirement: "watchdog当发现更改时,执行同步,同步时UI页面也会显示正在同步状态。"
# This implies we need a shared state.
# If I change api_sync to use sync_manager, I need to ensure it supports the custom params.
# But payload.local_path and payload.mode are optional.
# Let's modify sync_manager to accept overrides.
# But wait, sync_manager is a singleton.
# For this task, I will just wrap the existing logic in sync_manager.run_sync(wait=True)
# AND I will modify sync_manager to allow passing explicit args to _perform_sync.
# But first, let's update api_sync to use sync_manager.run_sync(wait=True)
# AND we need to handle the parameter passing.
# Actually, looking at sync_manager implementation I just wrote:
# def _perform_sync(self):
# server_config.load()
# return sync_all_playlists(local_dir=server_config.local_path, mode=SyncMode(server_config.sync_mode))
# It ignores arguments. This is a limitation.
# I should update SyncManager to accept kwargs for sync_all_playlists.
# Let's update SyncManager first.
pass
@app.post("/api/sync")
async def api_sync(payload: SyncRequest):
server_config.load()
try:
sync_mode = payload.mode or SyncMode(server_config.sync_mode)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
local_dir = payload.local_path or server_config.local_path
# Use sync_manager to execute sync, ensuring state is updated # Use sync_manager to execute sync, ensuring state is updated
try: try:
@@ -556,7 +693,7 @@ async def api_sync(payload: SyncRequest):
"conflict_count": conflict_count, "conflict_count": conflict_count,
"delete_count": deleted_count, "delete_count": deleted_count,
"playlist_count": len(results), "playlist_count": len(results),
"output_dir": TEST_PLAYLIST_DIR, "output_dir": SYNC_ARTIFACTS_DIR,
} }
@@ -600,23 +737,24 @@ def _build_home_context(
# 显示主页 # 显示主页
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def home(request: Request, local_path: str = "playlist"): async def home(request: Request, local_path: str = "playlists"):
index_path = os.path.join(FRONTEND_DIST_PATH, "index.html") index_path = os.path.join(FRONTEND_DIST_PATH, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
return FileResponse(index_path) return FileResponse(index_path)
context = _build_home_context(request, local_path or server_config.local_path) # local_path is intentionally fixed; ignore query overrides.
context = _build_home_context(request, server_config.local_path)
return templates.TemplateResponse("home.html", context) return templates.TemplateResponse("home.html", context)
@app.post("/sync", response_class=HTMLResponse) @app.post("/sync", response_class=HTMLResponse)
async def trigger_sync(request: Request, mode: str = Form(...), local_path: str = Form("playlist")): async def trigger_sync(request: Request, mode: str = Form(...), local_path: str = Form("playlists")):
try: try:
sync_mode = SyncMode(mode) sync_mode = SyncMode(mode)
except ValueError: except ValueError:
context = _build_home_context( context = _build_home_context(
request, request,
local_path, server_config.local_path,
message=f"未知的同步策略:{mode}", message=f"未知的同步策略:{mode}",
message_type="danger", message_type="danger",
selected_mode=mode, selected_mode=mode,
@@ -625,17 +763,17 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
try: try:
results = sync_all_playlists( results = sync_all_playlists(
local_dir=local_path, local_dir=server_config.local_path,
mode=sync_mode, mode=sync_mode,
test_folder=TEST_PLAYLIST_DIR, test_folder=SYNC_ARTIFACTS_DIR,
) )
merged_count = sum(len(item.merged_paths) for item in results) merged_count = sum(len(item.merged_paths) for item in results)
conflict_count = sum(len(item.conflicts) for item in results) conflict_count = sum(len(item.conflicts) for item in results)
deleted_count = sum(1 for item in results if item.action == "deleted") deleted_count = sum(1 for item in results if item.action == "deleted")
context = _build_home_context( context = _build_home_context(
request, request,
local_path, server_config.local_path,
message="同步完成,输出已写入测试目录用于验证", message="同步完成,输出已写入同步工作目录(Artifacts",
message_type="success", message_type="success",
sync_result={ sync_result={
"mode": sync_mode.value, "mode": sync_mode.value,
@@ -647,7 +785,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
"conflict_count": conflict_count, "conflict_count": conflict_count,
"delete_count": deleted_count, "delete_count": deleted_count,
"playlist_count": len(results), "playlist_count": len(results),
"output_dir": TEST_PLAYLIST_DIR, "output_dir": SYNC_ARTIFACTS_DIR,
}, },
selected_mode=sync_mode.value, selected_mode=sync_mode.value,
) )
@@ -656,7 +794,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
logger.warning(f"Sync failed: {exc}") logger.warning(f"Sync failed: {exc}")
context = _build_home_context( context = _build_home_context(
request, request,
local_path, server_config.local_path,
message=f"同步失败:{exc}", message=f"同步失败:{exc}",
message_type="danger", message_type="danger",
selected_mode=sync_mode.value, selected_mode=sync_mode.value,
@@ -667,7 +805,7 @@ async def trigger_sync(request: Request, mode: str = Form(...), local_path: str
@app.post("/path-rules", response_class=HTMLResponse) @app.post("/path-rules", response_class=HTMLResponse)
async def save_path_rules( async def save_path_rules(
request: Request, request: Request,
local_path: str = Form("playlist"), local_path: str = Form("playlists"),
pattern: list[str] | None = Form(None), pattern: list[str] | None = Form(None),
replacement: list[str] | None = Form(None), replacement: list[str] | None = Form(None),
): ):
@@ -685,7 +823,7 @@ async def save_path_rules(
context = _build_home_context( context = _build_home_context(
request, request,
local_path, server_config.local_path,
message="正则规则已保存并会在同步前应用。", message="正则规则已保存并会在同步前应用。",
message_type="success", message_type="success",
) )
+104
View File
@@ -0,0 +1,104 @@
import base64
import hashlib
import hmac
import json
import os
import time
from dataclasses import dataclass
from app.utils.logger import logger
def _parse_bool(value: str | None, default: bool = False) -> bool:
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
def _b64url_decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode((data + padding).encode("utf-8"))
@dataclass(frozen=True)
class AuthConfig:
enabled: bool
username: str
password: str
token_secret: bytes
token_ttl_seconds: int
def load_auth_config() -> AuthConfig:
enabled = _parse_bool(os.environ.get("PLEXPLAYLISTSYNC_AUTH_ENABLED"), default=False)
username = os.environ.get("PLEXPLAYLISTSYNC_AUTH_USERNAME", "").strip()
password = os.environ.get("PLEXPLAYLISTSYNC_AUTH_PASSWORD", "")
ttl = int(os.environ.get("PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS", "86400"))
ttl = max(60, ttl)
secret_env = os.environ.get("PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET", "").strip()
if secret_env:
token_secret = secret_env.encode("utf-8")
elif enabled:
# If auth is enabled but no explicit secret is set, fall back to an ephemeral secret.
# This means tokens become invalid after restart, which is acceptable for this project.
token_secret = os.urandom(32)
logger.warning(
"PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET not set; using ephemeral secret (tokens reset on restart)."
)
else:
token_secret = b""
if enabled:
if not username or not password:
raise RuntimeError(
"Auth enabled but missing credentials: please set PLEXPLAYLISTSYNC_AUTH_USERNAME and PLEXPLAYLISTSYNC_AUTH_PASSWORD."
)
return AuthConfig(
enabled=enabled,
username=username,
password=password,
token_secret=token_secret,
token_ttl_seconds=ttl,
)
def issue_token(config: AuthConfig, username: str) -> str:
now = int(time.time())
payload = {"u": username, "exp": now + config.token_ttl_seconds}
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(config.token_secret, payload_b64.encode("utf-8"), hashlib.sha256).digest()
sig_b64 = _b64url_encode(sig)
return f"{payload_b64}.{sig_b64}"
def verify_token(config: AuthConfig, token: str) -> dict | None:
try:
payload_b64, sig_b64 = token.split(".", 1)
except ValueError:
return None
try:
expected_sig = hmac.new(
config.token_secret, payload_b64.encode("utf-8"), hashlib.sha256
).digest()
actual_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(expected_sig, actual_sig):
return None
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
exp = int(payload.get("exp", 0))
if exp <= int(time.time()):
return None
if not payload.get("u"):
return None
return payload
except Exception:
return None
+300
View File
@@ -0,0 +1,300 @@
import os
import zipfile
import hashlib
import re
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 (repo root /backups)
DEFAULT_BACKUP_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "backups")
)
# Allow Docker / users to relocate backups for centralized host backup.
# Example: /app/data/backup
BACKUP_DIR = os.path.abspath(
os.environ.get("PLEXPLAYLISTSYNC_BACKUP_DIR", DEFAULT_BACKUP_DIR)
)
def _safe_zip_entry_name(name: str, extension: str = ".m3u8") -> str:
"""Return a safe zip entry filename.
Prevents zip-slip style paths and avoids problematic characters.
"""
original = (name or "").strip()
base = os.path.basename(original)
base = re.sub(r"[\x00-\x1f\x7f]", "_", base)
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in base).strip().strip(". ")
if not cleaned:
cleaned = "playlist"
cleaned = cleaned[:160].rstrip().strip(". ")
if cleaned != original:
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
cleaned = f"{cleaned}__{digest}"
return f"{cleaned}{extension}"
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 = _safe_zip_entry_name(playlist_name)
# 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 = _safe_zip_entry_name(getattr(playlist, "title", "playlist"))
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")
+110 -17
View File
@@ -2,12 +2,54 @@ import json
import os import os
from app.utils.logger import logger from app.utils.logger import logger
DEFAULT_SYNC_MODE = "merge_local_primary"
CONFIG_PATH = os.path.abspath( def _redact_for_log(value: object) -> object:
if not isinstance(value, dict):
return value
redacted = dict(value)
for key in ("token", "password"):
if key in redacted and redacted.get(key):
redacted[key] = "***"
return redacted
def _redact_server_config_dict(state: dict) -> dict:
if not isinstance(state, dict):
return {}
redacted = dict(state)
if redacted.get("token"):
redacted["token"] = "***"
return redacted
DEFAULT_SYNC_MODE = "merge_local_primary"
LOCAL_PLAYLISTS_FOLDER = "playlists"
DEFAULT_PATH_MAPPING = {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
}
DEFAULT_CONFIG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "config.json") os.path.join(os.path.dirname(__file__), "..", "config.json")
) )
# Allow Docker / users to relocate config for backup convenience.
# Example: /app/data/config/config.json
CONFIG_PATH = os.path.abspath(
os.environ.get("PLEXPLAYLISTSYNC_CONFIG_PATH", DEFAULT_CONFIG_PATH)
)
def _ensure_parent_dir(file_path: str) -> None:
parent = os.path.dirname(os.path.abspath(file_path))
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
class ServerConfig: class ServerConfig:
@@ -20,21 +62,26 @@ class ServerConfig:
self.timeout = 9 self.timeout = 9
self.library_name = "" self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE self.sync_mode = DEFAULT_SYNC_MODE
self.local_path = "playlist" # Local playlists folder is intentionally fixed and not part of config.
self.path_rules: list[dict[str, str]] = [] # Docker volume should mount host ./playlists -> container /app/playlists.
self.local_path = LOCAL_PLAYLISTS_FOLDER
self.path_rules: list[dict[str, str]] = [] # Legacy field for backward compatibility
self.path_mapping: dict = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = "DISABLED" self.schedule_mode = "DISABLED"
self.schedule_cron = "" self.schedule_cron = ""
self.schedule_daily_time = "02:00" self.schedule_daily_time = "00:00"
self.schedule_weekly_days = [0] self.schedule_weekly_days = [0]
self.schedule_weekly_time = "03:00" self.schedule_weekly_time = "00:00"
self.schedule_auto_watch = False self.schedule_auto_watch = False
self.backup_enabled = True
self.backup_retention_count = 5
self.load() self.load()
def load(self) -> None: def load(self) -> None:
try: try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f: with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
logger.debug(f"Loaded server config: {config}") logger.debug(f"Loaded server config: {_redact_for_log(config)}")
except FileNotFoundError: except FileNotFoundError:
# 如果配置文件不存在,使用默认值 # 如果配置文件不存在,使用默认值
self.save() self.save()
@@ -53,17 +100,39 @@ class ServerConfig:
self.timeout = config.get("timeout", 9) self.timeout = config.get("timeout", 9)
self.library_name = config.get("library_name", "") self.library_name = config.get("library_name", "")
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE) self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
self.local_path = config.get("local_path", "playlist") # local_path is fixed by design and not configurable.
self.local_path = LOCAL_PLAYLISTS_FOLDER
self.path_rules = config.get("path_rules", []) or [] self.path_rules = config.get("path_rules", []) or []
# Load path_mapping with default fallback
path_mapping_config = config.get("path_mapping")
if path_mapping_config:
self.path_mapping = {
"mode": path_mapping_config.get("mode", "SIMPLE"),
"simple": path_mapping_config.get("simple", []),
"regex": {
"local_pre": path_mapping_config.get("regex", {}).get("local_pre", []),
"local_post": path_mapping_config.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping_config.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping_config.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
self.schedule_mode = config.get("schedule_mode", "DISABLED") self.schedule_mode = config.get("schedule_mode", "DISABLED")
self.schedule_cron = config.get("schedule_cron", "") self.schedule_cron = config.get("schedule_cron", "")
self.schedule_daily_time = config.get("schedule_daily_time", "02:00") self.schedule_daily_time = config.get("schedule_daily_time", "02:00")
self.schedule_weekly_days = config.get("schedule_weekly_days", [0]) self.schedule_weekly_days = config.get("schedule_weekly_days", [0])
self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00") self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00")
self.schedule_auto_watch = config.get("schedule_auto_watch", False) self.schedule_auto_watch = config.get("schedule_auto_watch", False)
logger.info(f"Server config loaded: {self.__dict__}") 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: {_redact_server_config_dict(self.__dict__)}")
def save(self): def save(self):
_ensure_parent_dir(CONFIG_PATH)
config = { config = {
"theme": self.theme, "theme": self.theme,
"token": self.token, "token": self.token,
@@ -73,18 +142,21 @@ class ServerConfig:
"timeout": self.timeout, "timeout": self.timeout,
"library_name": self.library_name, "library_name": self.library_name,
"sync_mode": self.sync_mode, "sync_mode": self.sync_mode,
"local_path": self.local_path,
"path_rules": self.path_rules, "path_rules": self.path_rules,
"path_mapping": self.path_mapping,
"schedule_mode": self.schedule_mode, "schedule_mode": self.schedule_mode,
"schedule_cron": self.schedule_cron, "schedule_cron": self.schedule_cron,
"schedule_daily_time": self.schedule_daily_time, "schedule_daily_time": self.schedule_daily_time,
"schedule_weekly_days": self.schedule_weekly_days, "schedule_weekly_days": self.schedule_weekly_days,
"schedule_weekly_time": self.schedule_weekly_time, "schedule_weekly_time": self.schedule_weekly_time,
"schedule_auto_watch": self.schedule_auto_watch, "schedule_auto_watch": self.schedule_auto_watch,
"backup_enabled": self.backup_enabled,
"backup_retention_count": self.backup_retention_count,
} }
with open(CONFIG_PATH, "w", encoding="utf-8") as f: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False) json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved: {config}") logger.info(f"Server config saved.")
logger.debug(f"Saved server config: {_redact_for_log(config)}")
def set_url(self, url: str) -> None: def set_url(self, url: str) -> None:
self.url = url self.url = url
@@ -107,9 +179,6 @@ class ServerConfig:
def set_sync_mode(self, sync_mode: str) -> None: def set_sync_mode(self, sync_mode: str) -> None:
self.sync_mode = sync_mode self.sync_mode = sync_mode
def set_local_path(self, local_path: str) -> None:
self.local_path = local_path or "playlist"
def set_theme(self, theme: str) -> None: def set_theme(self, theme: str) -> None:
# check theme is valid # check theme is valid
if theme not in ["auto", "dark", "light"]: if theme not in ["auto", "dark", "light"]:
@@ -120,6 +189,21 @@ class ServerConfig:
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None: def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
self.path_rules = path_rules or [] self.path_rules = path_rules or []
def set_path_mapping(self, path_mapping: dict) -> None:
if path_mapping:
self.path_mapping = {
"mode": path_mapping.get("mode", "SIMPLE"),
"simple": path_mapping.get("simple", []),
"regex": {
"local_pre": path_mapping.get("regex", {}).get("local_pre", []),
"local_post": path_mapping.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
def set_schedule( def set_schedule(
self, self,
mode: str, mode: str,
@@ -137,6 +221,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,
@@ -147,8 +240,8 @@ class ServerConfig:
timeout: int | None = None, timeout: int | None = None,
library_name: str | None = None, library_name: str | None = None,
sync_mode: str | None = None, sync_mode: str | None = None,
local_path: str | None = None,
path_rules: list[dict[str, str]] | None = None, path_rules: list[dict[str, str]] | None = None,
path_mapping: dict | None = None,
) -> None: ) -> None:
if theme is not None: if theme is not None:
self.set_theme(theme) self.set_theme(theme)
@@ -166,10 +259,10 @@ class ServerConfig:
self.set_library(library_name) self.set_library(library_name)
if sync_mode is not None: if sync_mode is not None:
self.set_sync_mode(sync_mode) self.set_sync_mode(sync_mode)
if local_path is not None:
self.set_local_path(local_path)
if path_rules is not None: if path_rules is not None:
self.set_path_rules(path_rules) self.set_path_rules(path_rules)
if path_mapping is not None:
self.set_path_mapping(path_mapping)
self.save() self.save()
+62
View File
@@ -66,3 +66,65 @@ 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:
desired_lines = ["#EXTM3U\n"] + [f"{track}\n" for track in tracks]
desired_content = "".join(desired_lines)
# Avoid rewriting identical content to prevent watcher feedback loops.
if os.path.exists(playlist_path):
try:
with open(playlist_path, 'r', encoding="utf-8") as existing_file:
existing_content = existing_file.read()
if existing_content == desired_content:
logger.debug(f"Playlist unchanged; skipping write: {playlist_path}")
return True
except Exception as e:
# If read fails, fall back to rewriting.
logger.debug(f"Failed to read existing playlist for comparison ({playlist_path}): {e}")
# Write via temp file then replace for safer updates.
os.makedirs(os.path.dirname(os.path.abspath(playlist_path)), exist_ok=True)
tmp_path = f"{playlist_path}.tmp"
with open(tmp_path, 'w', encoding="utf-8") as file:
file.write(desired_content)
os.replace(tmp_path, playlist_path)
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
+23 -1
View File
@@ -4,7 +4,29 @@ import os
LOG_PATH = os.path.abspath( LOG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "logs", "app.log")) os.path.join(os.path.dirname(__file__), "..", "logs", "app.log"))
LOG_LEVEL = logging.DEBUG def _get_log_level():
"""Get log level from environment variable."""
level_str = os.getenv("LOG_LEVEL", "INFO").upper()
# Try to convert to integer
if level_str.isdigit():
return int(level_str)
# Map string to logging level
levels = {
"CRITICAL": logging.CRITICAL,
"FATAL": logging.FATAL,
"ERROR": logging.ERROR,
"WARN": logging.WARNING,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
"NOTSET": logging.NOTSET,
}
return levels.get(level_str, logging.INFO)
LOG_LEVEL = _get_log_level()
def logger_initialize() -> logging.Logger: def logger_initialize() -> logging.Logger:
"""Initialize the logger for the application. Return a logger that logs to console and a app.log.""" """Initialize the logger for the application. Return a logger that logs to console and a app.log."""
+337 -55
View File
@@ -12,10 +12,13 @@ from app.utils.plex_client import plex_client
from merge3 import Merge3 from merge3 import Merge3
TEST_PLAYLIST_DIR = os.path.abspath( SYNC_ARTIFACTS_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "test_playlists") os.path.join(os.path.dirname(__file__), "..", "..", "data", "sync_artifacts")
) )
# Backward-compat alias (older API / logs used this name).
TEST_PLAYLIST_DIR = SYNC_ARTIFACTS_DIR
class ConflictResolutionStrategy(str, Enum): class ConflictResolutionStrategy(str, Enum):
LOCAL_PRIORITY = "local_priority" LOCAL_PRIORITY = "local_priority"
@@ -40,6 +43,15 @@ class PlaylistSyncResult:
output_dir: str output_dir: str
@dataclass
class CompiledRegexRules:
"""Holds compiled regex rules for all four processing stages."""
local_pre: list[tuple[re.Pattern[str], str]]
local_post: list[tuple[re.Pattern[str], str]]
remote_pre: list[tuple[re.Pattern[str], str]]
remote_post: list[tuple[re.Pattern[str], str]]
def load_paths(text: str) -> list[str]: def load_paths(text: str) -> list[str]:
"""Normalize playlist text into a list of absolute paths. """Normalize playlist text into a list of absolute paths.
@@ -72,12 +84,21 @@ def save_paths(paths: Sequence[str]) -> str:
def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]: def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]:
"""Compile regex rules into pattern/replacement pairs.
Supports both legacy format (pattern/replacement) and new format (search/replace).
"""
compiled: list[tuple[re.Pattern[str], str]] = [] compiled: list[tuple[re.Pattern[str], str]] = []
for rule in rules: for rule in rules:
pattern = rule.get("pattern") # Support both legacy (pattern/replacement) and new (search/replace) field names
# Use explicit None checks to allow empty strings as valid values
pattern = rule.get("pattern") if rule.get("pattern") is not None else rule.get("search")
if not pattern: if not pattern:
continue continue
replacement = rule.get("replacement", "") # For replacement, empty string is a valid value (for deletion)
replacement = rule.get("replacement") if rule.get("replacement") is not None else rule.get("replace")
if replacement is None:
replacement = ""
try: try:
compiled.append((re.compile(pattern), replacement)) compiled.append((re.compile(pattern), replacement))
except re.error as exc: except re.error as exc:
@@ -141,9 +162,37 @@ class MergeResult:
conflicts: list[dict] conflicts: list[dict]
def _ensure_test_dir(folder: str = TEST_PLAYLIST_DIR) -> str: def _ensure_dir(path: str) -> str:
os.makedirs(folder, exist_ok=True) os.makedirs(path, exist_ok=True)
return folder return path
def _safe_folder_name(name: str, max_len: int = 120) -> str:
"""Make a filesystem-safe folder name (especially for Windows hosts).
This is used for artifacts folders persisted to the host.
"""
if not name:
return "(unnamed)"
# Windows-disallowed characters plus path separators.
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip()
if not cleaned:
cleaned = "(unnamed)"
if len(cleaned) > max_len:
cleaned = cleaned[:max_len].rstrip()
return cleaned
def _playlist_artifact_dir(artifacts_root: str, playlist_name: str) -> str:
return os.path.join(artifacts_root, _safe_folder_name(playlist_name))
def _artifact_file(playlist_folder: str, category: str, filename: str) -> str:
category_dir = _ensure_dir(os.path.join(playlist_folder, category))
return os.path.join(category_dir, filename)
def _read_text_if_exists(path: str) -> tuple[str, bool]: def _read_text_if_exists(path: str) -> tuple[str, bool]:
@@ -156,25 +205,27 @@ def _read_text_if_exists(path: str) -> tuple[str, bool]:
return "", False return "", False
def _save_playlist_to_folder(filename: str, paths: Sequence[str], folder: str) -> str: def _save_playlist_text(path: str, text: str) -> str:
_ensure_test_dir(folder) """Write text if changed (avoid triggering unnecessary file events)."""
file_path = os.path.join(folder, filename)
new_content = save_paths(paths) _ensure_dir(os.path.dirname(path))
# Check if content has changed before writing to avoid triggering unnecessary file events if os.path.exists(path):
if os.path.exists(file_path):
try: try:
with open(file_path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as file:
current_content = f.read() if file.read() == text:
if current_content == new_content: return path
return file_path
except OSError: except OSError:
pass pass
with open(file_path, "w", encoding="utf-8") as file: with open(path, "w", encoding="utf-8") as file:
file.write(new_content) file.write(text)
return file_path return path
def _save_playlist_paths(path: str, paths: Sequence[str]) -> str:
logger.info(f"Saving playlist to: {path}")
return _save_playlist_text(path, save_paths(paths))
def _normalize_inputs( def _normalize_inputs(
@@ -186,9 +237,9 @@ def _normalize_inputs(
local_paths = load_paths(local_text) local_paths = load_paths(local_text)
remote_paths = load_paths(remote_text) remote_paths = load_paths(remote_text)
_save_playlist_to_folder("base_playlist.m3u8", base_paths, folder) _save_playlist_paths(_artifact_file(folder, "base", "base_prev.m3u8"), base_paths)
_save_playlist_to_folder("local_input.m3u8", local_paths, folder) _save_playlist_paths(_artifact_file(folder, "inputs", "local_input.m3u8"), local_paths)
_save_playlist_to_folder("remote_input.m3u8", remote_paths, folder) _save_playlist_paths(_artifact_file(folder, "inputs", "remote_input.m3u8"), remote_paths)
return base_paths, local_paths, remote_paths return base_paths, local_paths, remote_paths
@@ -233,15 +284,36 @@ def _merge_chunks(
return chunks return chunks
def _write_results(merged_lines: Sequence[str], folder: str) -> None: def _write_results(
_save_playlist_to_folder("local_result.m3u8", merged_lines, folder) merged_lines: Sequence[str],
_save_playlist_to_folder("remote_result.m3u8", merged_lines, folder) folder: str,
_save_playlist_to_folder("base_next.m3u8", merged_lines, folder) compiled_rules: CompiledRegexRules | None = None
) -> None:
"""Write sync results to the test folder.
If compiled_rules is provided with post-processing rules:
- local_result.m3u8: merged_lines processed with local_post rules
- remote_result.m3u8: merged_lines processed with remote_post rules
- base_next.m3u8: unprocessed merged_lines (normalized sync result)
"""
# Apply post-processing regex rules if provided
if compiled_rules and compiled_rules.local_post:
local_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.local_post)
else:
local_lines = list(merged_lines)
if compiled_rules and compiled_rules.remote_post:
remote_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.remote_post)
else:
remote_lines = list(merged_lines)
_save_playlist_paths(_artifact_file(folder, "outputs", "local_result.m3u8"), local_lines)
_save_playlist_paths(_artifact_file(folder, "outputs", "remote_result.m3u8"), remote_lines)
_save_playlist_paths(_artifact_file(folder, "base", "base_next.m3u8"), merged_lines)
def _write_delete_marker(playlist: str, folder: str) -> str: def _write_delete_marker(playlist: str, folder: str) -> str:
_ensure_test_dir(folder) marker_path = _artifact_file(folder, "meta", "delete.txt")
marker_path = os.path.join(folder, "delete.txt")
with open(marker_path, "w", encoding="utf-8") as file: with open(marker_path, "w", encoding="utf-8") as file:
file.write(f"delete playlist {playlist}") file.write(f"delete playlist {playlist}")
return marker_path return marker_path
@@ -377,13 +449,17 @@ def merge_playlists(
local_text: str, local_text: str,
remote_text: str, remote_text: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY, strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
test_folder: str = TEST_PLAYLIST_DIR, test_folder: str = SYNC_ARTIFACTS_DIR,
compiled_rules: CompiledRegexRules | None = None,
) -> MergeResult: ) -> MergeResult:
"""Merge playlists using diff3 and resolve conflicts per strategy. """Merge playlists using diff3 and resolve conflicts per strategy.
The base, local, and remote normalized playlists are saved into ``test_folder`` The base, local, and remote normalized playlists are saved into ``test_folder``
for inspection. The merged playlist is also stored twice to simulate the for inspection. The merged playlist is also stored twice to simulate the
versions intended for local save and cloud upload. versions intended for local save and cloud upload.
If compiled_rules is provided, post-processing regex rules will be applied
to the results before writing.
""" """
base_paths, local_paths, remote_paths = _normalize_inputs( base_paths, local_paths, remote_paths = _normalize_inputs(
@@ -419,7 +495,7 @@ def merge_playlists(
merged_lines, base_paths, local_paths, remote_paths merged_lines, base_paths, local_paths, remote_paths
) )
_write_results(merged_lines, test_folder) _write_results(merged_lines, test_folder, compiled_rules)
return MergeResult(merged_paths=merged_lines, conflicts=conflicts) return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
@@ -449,19 +525,42 @@ def _load_local_playlists(local_dir: str) -> dict[str, str]:
def _load_playlist_snapshots(playlist: str, folder: str) -> tuple[str, str, str, bool, bool]: def _load_playlist_snapshots(playlist: str, folder: str) -> tuple[str, str, str, bool, bool]:
"""Load base/local/remote texts for a playlist from its test folder.""" """Load base/local/remote texts for a playlist from its test folder."""
playlist_folder = os.path.join(folder, playlist) playlist_folder = _playlist_artifact_dir(folder, playlist)
# Prefer new organized layout.
base_text, base_exists = _read_text_if_exists( base_text, base_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_next.m3u8") os.path.join(playlist_folder, "base", "base_next.m3u8")
) )
if not base_text: if not base_text:
alt_text, _ = _read_text_if_exists( alt_text, alt_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_playlist.m3u8") os.path.join(playlist_folder, "base", "base_prev.m3u8")
) )
base_text = base_text or alt_text base_text = base_text or alt_text
base_exists = base_exists or alt_exists
# Backward-compat: legacy flat file layout.
if not base_text:
legacy_text, legacy_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_next.m3u8")
)
base_text = base_text or legacy_text
base_exists = base_exists or legacy_exists
if not base_text:
legacy_text, legacy_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base_playlist.m3u8")
)
base_text = base_text or legacy_text
base_exists = base_exists or legacy_exists
remote_text, remote_exists = _read_text_if_exists( remote_text, remote_exists = _read_text_if_exists(
os.path.join(playlist_folder, "remote_input.m3u8") os.path.join(playlist_folder, "inputs", "remote_input.m3u8")
) )
if not remote_text:
legacy_remote, legacy_exists = _read_text_if_exists(
os.path.join(playlist_folder, "remote_input.m3u8")
)
remote_text = remote_text or legacy_remote
remote_exists = remote_exists or legacy_exists
return base_text, remote_text, playlist_folder, remote_exists, base_exists return base_text, remote_text, playlist_folder, remote_exists, base_exists
@@ -516,6 +615,7 @@ def _sync_single_playlist(
remote_text: str, remote_text: str,
playlist_folder: str, playlist_folder: str,
remote_present: bool, remote_present: bool,
compiled_rules: CompiledRegexRules | None = None,
) -> PlaylistSyncResult: ) -> PlaylistSyncResult:
local_present = local_text is not None local_present = local_text is not None
local_text = local_text or "" local_text = local_text or ""
@@ -534,7 +634,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder base_text, local_text, remote_text, playlist_folder
) )
merged_lines = list(local_paths) merged_lines = list(local_paths)
_write_results(merged_lines, playlist_folder) _write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder) return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode == SyncMode.REMOTE_FORCE: if mode == SyncMode.REMOTE_FORCE:
@@ -546,7 +646,7 @@ def _sync_single_playlist(
base_text, local_text, remote_text, playlist_folder base_text, local_text, remote_text, playlist_folder
) )
merged_lines = list(remote_paths) merged_lines = list(remote_paths)
_write_results(merged_lines, playlist_folder) _write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder) return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY): if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY):
@@ -564,6 +664,7 @@ def _sync_single_playlist(
remote_text=remote_text, remote_text=remote_text,
strategy=merge_strategy, strategy=merge_strategy,
test_folder=playlist_folder, test_folder=playlist_folder,
compiled_rules=compiled_rules,
) )
if not merge_result.merged_paths and (not local_present or not remote_present): if not merge_result.merged_paths and (not local_present or not remote_present):
@@ -577,24 +678,185 @@ def _sync_single_playlist(
) )
def _compile_path_mapping_rules(path_mapping: dict) -> CompiledRegexRules:
"""Compile regex rules from path_mapping config for all four processing stages."""
regex_config = path_mapping.get("regex", {})
return CompiledRegexRules(
local_pre=_compile_regex_rules(regex_config.get("local_pre", [])),
local_post=_compile_regex_rules(regex_config.get("local_post", [])),
remote_pre=_compile_regex_rules(regex_config.get("remote_pre", [])),
remote_post=_compile_regex_rules(regex_config.get("remote_post", [])),
)
def _compile_simple_mapping_rules(simple_mappings: list[dict]) -> CompiledRegexRules:
"""Compile simple mapping pairs into four rule groups using UUID-based mapping_ids.
Each simple mapping has:
- id: UUID used as the mapping_id (unique identifier to prevent conflicts)
- search: Local path prefix
- replace: Cloud path prefix
This generates four rule sets:
- local_pre: Replace local path (search) with mapping_id
- remote_pre: Replace cloud path (replace) with mapping_id
- local_post: Replace mapping_id with local path (search)
- remote_post: Replace mapping_id with cloud path (replace)
The mapping_id is wrapped with special markers to prevent conflicts with actual paths.
"""
local_pre_rules: list[dict[str, str]] = []
local_post_rules: list[dict[str, str]] = []
remote_pre_rules: list[dict[str, str]] = []
remote_post_rules: list[dict[str, str]] = []
# UUID pattern for validation (accepts standard UUID format with or without hyphens)
uuid_pattern = re.compile(r'^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$')
for mapping in simple_mappings:
# Get the mapping values
mapping_id = mapping.get("id")
local_path = mapping.get("search", "") # Local path is stored in 'search' field
cloud_path = mapping.get("replace", "") # Cloud path is stored in 'replace' field
# Validate mapping_id is a proper UUID to prevent injection attacks
if not mapping_id or not isinstance(mapping_id, str):
logger.warning(f"Skipping mapping with missing or invalid id: {mapping}")
continue
if not uuid_pattern.match(mapping_id):
logger.warning(f"Skipping mapping with non-UUID id format: {mapping_id}")
continue
# Paths must be non-empty strings
if not local_path or not isinstance(local_path, str):
logger.warning(f"Skipping mapping with missing local path: {mapping}")
continue
if not cloud_path or not isinstance(cloud_path, str):
logger.warning(f"Skipping mapping with missing cloud path: {mapping}")
continue
# Normalize Windows paths: Replace double backslashes with single backslashes
# This handles cases where users enter escaped paths like \\Koha9-Main\\Music
# when the actual playlist content uses \Koha9-Main\Music
original_local = local_path
original_cloud = cloud_path
local_path = local_path.replace("\\\\", "\\")
cloud_path = cloud_path.replace("\\\\", "\\")
if local_path != original_local or cloud_path != original_cloud:
logger.info(f"Normalized Windows paths:")
logger.info(f" Local: {repr(original_local)} -> {repr(local_path)}")
logger.info(f" Cloud: {repr(original_cloud)} -> {repr(cloud_path)}")
# Create a unique placeholder using the validated UUID
# Using special markers to prevent conflicts with actual paths
placeholder = f"__MAPPING__{mapping_id}__"
# Debug logging for path mapping
logger.debug(f"Simple mapping pair:")
logger.debug(f" Local path (search): {repr(local_path)}")
logger.debug(f" Cloud path (replace): {repr(cloud_path)}")
logger.debug(f" Placeholder: {placeholder}")
# Pre-processing rules (use re.escape to treat paths as literal strings)
# local_pre: Replace local path with placeholder
local_pattern = re.escape(local_path)
logger.debug(f" Local pre pattern: {repr(local_pattern)}")
local_pre_rules.append({
"pattern": local_pattern,
"replacement": placeholder
})
# remote_pre: Replace cloud path with placeholder
remote_pattern = re.escape(cloud_path)
logger.debug(f" Remote pre pattern: {repr(remote_pattern)}")
remote_pre_rules.append({
"pattern": remote_pattern,
"replacement": placeholder
})
# Post-processing rules
# local_post: Replace placeholder with local path
# Note: In regex replacement, backslashes need to be escaped
local_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": local_path.replace("\\", "\\\\")
})
# remote_post: Replace placeholder with cloud path
remote_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": cloud_path.replace("\\", "\\\\")
})
logger.info(f"Compiled {len(local_pre_rules)} simple mapping pairs into rules")
return CompiledRegexRules(
local_pre=_compile_regex_rules(local_pre_rules),
local_post=_compile_regex_rules(local_post_rules),
remote_pre=_compile_regex_rules(remote_pre_rules),
remote_post=_compile_regex_rules(remote_post_rules),
)
def sync_all_playlists( def sync_all_playlists(
local_dir: str, mode: SyncMode, test_folder: str = TEST_PLAYLIST_DIR local_dir: str, mode: SyncMode, test_folder: str = SYNC_ARTIFACTS_DIR
) -> list[PlaylistSyncResult]: ) -> list[PlaylistSyncResult]:
"""Synchronize all playlists that can be matched by name.""" """Synchronize all playlists that can be matched by name.
Path mapping modes:
- SIMPLE: Uses UUID-based mapping_ids to convert between local and cloud paths
- local_pre: local_path -> mapping_id
- remote_pre: cloud_path -> mapping_id
- local_post: mapping_id -> local_path
- remote_post: mapping_id -> cloud_path
- REGEX: Uses custom regex rules for each processing stage
- local_pre, local_post, remote_pre, remote_post rules are applied directly
Processing flow:
1. local_pre rules are applied to local playlists before sync
2. remote_pre rules are applied to remote playlists before sync
3. Sync/merge is performed
4. local_post rules are applied to results before writing to local_result.m3u8
5. remote_post rules are applied to results before writing to remote_result.m3u8
"""
server_config.load() server_config.load()
compiled_rules = _compile_regex_rules(server_config.path_rules)
_ensure_test_dir(test_folder) # Get path_mapping configuration
path_mapping = server_config.path_mapping
mapping_mode = path_mapping.get("mode", "SIMPLE")
# Compile rules based on the mode
compiled_rules: CompiledRegexRules | None = None
legacy_compiled_rules: list[tuple[re.Pattern[str], str]] = []
if mapping_mode == "REGEX":
compiled_rules = _compile_path_mapping_rules(path_mapping)
logger.info("Using REGEX mode for path mapping with 4 rule groups")
elif mapping_mode == "SIMPLE":
simple_mappings = path_mapping.get("simple", [])
if simple_mappings:
compiled_rules = _compile_simple_mapping_rules(simple_mappings)
logger.info(f"Using SIMPLE mode for path mapping with {len(simple_mappings)} mapping pairs")
else:
logger.info("SIMPLE mode with no mappings - no path transformations will be applied")
else:
# Use legacy path_rules for backward compatibility
legacy_compiled_rules = _compile_regex_rules(server_config.path_rules)
logger.info("Using legacy path_rules for preprocessing")
_ensure_dir(test_folder)
logger.info(f"Sync artifacts folder: {test_folder}")
local_playlists = _load_local_playlists(local_dir) local_playlists = _load_local_playlists(local_dir)
remote_playlists = _fetch_remote_playlists() remote_playlists = _fetch_remote_playlists()
playlist_names: set[str] = set(local_playlists.keys()) playlist_names: set[str] = set(local_playlists.keys())
playlist_names.update(remote_playlists.keys()) playlist_names.update(remote_playlists.keys())
for entry in os.scandir(test_folder):
if entry.is_dir():
playlist_names.add(entry.name)
results: list[PlaylistSyncResult] = [] results: list[PlaylistSyncResult] = []
for playlist in sorted(playlist_names): for playlist in sorted(playlist_names):
@@ -611,16 +873,35 @@ def sync_all_playlists(
remote_text = snapshot_remote_text remote_text = snapshot_remote_text
remote_present = bool(remote_text.strip()) or remote_exists remote_present = bool(remote_text.strip()) or remote_exists
base_text = preprocess_playlist_text( if compiled_rules:
base_text, server_config.path_rules, compiled_rules # Apply pre-processing rules for REGEX or SIMPLE mode
) # base_text doesn't need pre-processing as it's the normalized state
remote_text = preprocess_playlist_text( if local_text is not None and compiled_rules.local_pre:
remote_text, server_config.path_rules, compiled_rules logger.debug(f"Applying local_pre rules to playlist: {playlist}")
) logger.debug(f" Before preprocessing (first 200 chars): {repr(local_text[:200])}")
if local_text is not None: local_text = preprocess_playlist_text(
local_text = preprocess_playlist_text( local_text, [], compiled_rules.local_pre
local_text, server_config.path_rules, compiled_rules )
logger.debug(f" After preprocessing (first 200 chars): {repr(local_text[:200])}")
if remote_text and compiled_rules.remote_pre:
logger.debug(f"Applying remote_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(remote_text[:200])}")
remote_text = preprocess_playlist_text(
remote_text, [], compiled_rules.remote_pre
)
logger.debug(f" After preprocessing (first 200 chars): {repr(remote_text[:200])}")
elif legacy_compiled_rules:
# Use legacy preprocessing for all texts
base_text = preprocess_playlist_text(
base_text, server_config.path_rules, legacy_compiled_rules
) )
remote_text = preprocess_playlist_text(
remote_text, server_config.path_rules, legacy_compiled_rules
)
if local_text is not None:
local_text = preprocess_playlist_text(
local_text, server_config.path_rules, legacy_compiled_rules
)
# Treat missing remote text as absent playlist. # Treat missing remote text as absent playlist.
result = _sync_single_playlist( result = _sync_single_playlist(
@@ -631,6 +912,7 @@ def sync_all_playlists(
remote_text=remote_text, remote_text=remote_text,
playlist_folder=playlist_folder, playlist_folder=playlist_folder,
remote_present=remote_present, remote_present=remote_present,
compiled_rules=compiled_rules,
) )
results.append(result) results.append(result)
+93 -7
View File
@@ -79,9 +79,7 @@ class PlexClient:
# Update the base URL and connection status # Update the base URL and connection status
self.base_url = build_plex_url(scheme, url, port) self.base_url = build_plex_url(scheme, url, port)
self.connected = True self.connected = True
logger.info( logger.info(f"Connected to Plex server at {self.base_url}.")
f"Connected to Plex server at {self.base_url} with token: {self.token}"
)
return self.server, self.token return self.server, self.token
except Exception as e: except Exception as e:
logger.warning(f"Failed to connect to Plex server: {str(e)}") logger.warning(f"Failed to connect to Plex server: {str(e)}")
@@ -106,9 +104,7 @@ class PlexClient:
self.token = account.authenticationToken self.token = account.authenticationToken
self.server = PlexServer(self.base_url, self.token, timeout=timeout) self.server = PlexServer(self.base_url, self.token, timeout=timeout)
logger.debug( logger.debug(f"Connected to Plex server with username: {username}.")
f"Connected to Plex server with username: {username}, token: {self.token}"
)
return self.server, self.token return self.server, self.token
def _connect_with_token( def _connect_with_token(
@@ -124,7 +120,7 @@ class PlexClient:
self.base_url = build_plex_url(scheme, url, port) self.base_url = build_plex_url(scheme, url, port)
self.server = PlexServer(self.base_url, token, timeout=timeout) self.server = PlexServer(self.base_url, token, timeout=timeout)
logger.debug(f"Connected to Plex server with token: {token}") logger.debug("Connected to Plex server with token.")
return self.server, token return self.server, token
def _connect_check(self): def _connect_check(self):
@@ -311,4 +307,94 @@ class PlexClient:
) )
return local_2_plex return local_2_plex
def get_playlist(self, title: str):
"""Get a playlist by title."""
self._connect_check()
try:
# Exact match search for playlist
playlists = self.server.playlists(title=title)
if playlists:
return playlists[0]
return None
except Exception as e:
logger.error(f"Error fetching playlist {title}: {e}")
return None
def create_playlist(self, title: str, items: list):
"""Create a new playlist with the given items."""
self._connect_check()
try:
self.server.createPlaylist(title, items=items)
logger.info(f"Created playlist {title} with {len(items)} items.")
return True
except Exception as e:
logger.error(f"Error creating playlist {title}: {e}")
return False
def delete_playlist(self, title: str):
"""Delete a playlist by title."""
self._connect_check()
try:
playlist = self.get_playlist(title)
if playlist:
playlist.delete()
logger.info(f"Deleted playlist {title}.")
return True
else:
logger.warning(f"Playlist {title} not found for deletion.")
return False
except Exception as e:
logger.error(f"Error deleting playlist {title}: {e}")
return False
def update_playlist(self, title: str, items: list):
"""
Update a playlist with a new list of items.
This implementation replaces the existing items with the new ones.
"""
self._connect_check()
try:
playlist = self.get_playlist(title)
if not playlist:
return self.create_playlist(title, items)
# Remove all items and add new ones
playlist.removeItems(playlist.items())
if items:
playlist.addItems(items)
logger.info(f"Updated playlist {title} with {len(items)} items.")
return True
except Exception as e:
logger.error(f"Error updating playlist {title}: {e}")
return False
def get_items_by_paths(self, library_name: str, paths: list[str]) -> list:
"""
Find Plex items (tracks) by their file paths.
"""
self._connect_check()
if not paths:
return []
try:
path_map = self.match_tracks(library_name, paths)
except FileNotFoundError:
logger.info(f"Cache not found for {library_name}, creating it...")
self.cache_lib_tracks(library_name)
path_map = self.match_tracks(library_name, paths)
items = []
for path in paths:
rating_key = path_map.get(path)
if rating_key and rating_key != UNMATCHED_TRACK_RATING_KEY:
try:
item = self.server.fetchItem(rating_key)
items.append(item)
except Exception as e:
logger.warning(f"Failed to fetch item for ratingKey {rating_key}: {e}")
else:
logger.warning(f"Track not found in Plex library (or unmatched): {path}")
return items
plex_client = PlexClient() plex_client = PlexClient()
+1 -1
View File
@@ -143,7 +143,7 @@ def update_scheduler_job():
trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time) trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time)
if trigger: if trigger:
scheduler.add_job(job_function, trigger) scheduler.add_job(job_function, trigger, misfire_grace_time=60, coalesce=True)
logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}") logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
else: else:
logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.") logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.")
+138 -1
View File
@@ -1,10 +1,17 @@
import threading import threading
import asyncio import asyncio
import json import json
import os
import hashlib
import re
import time
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):
@@ -15,6 +22,23 @@ class SyncManager:
self._last_error = None self._last_error = None
self._listeners = [] # List of asyncio.Queue self._listeners = [] # List of asyncio.Queue
self._loop = None self._loop = None
# Suppress watcher events briefly after we write/delete local playlists.
# This prevents feedback loops where a sync triggers another sync.
self._watcher_suppress_until = 0.0
# Tunable defaults (seconds). Keep short to avoid missing real user edits.
self._watcher_suppress_after_write_seconds = 2.5
self._watcher_suppress_after_sync_seconds = 2.5
def suppress_watcher_events(self, seconds: float):
now = time.monotonic()
with self._lock:
self._watcher_suppress_until = max(self._watcher_suppress_until, now + float(seconds))
@property
def is_watcher_suppressed(self) -> bool:
with self._lock:
return time.monotonic() < self._watcher_suppress_until
def set_event_loop(self, loop): def set_event_loop(self, loop):
self._loop = loop self._loop = loop
@@ -72,6 +96,11 @@ class SyncManager:
self._is_syncing = True self._is_syncing = True
self._last_status = "syncing" self._last_status = "syncing"
self._last_error = None self._last_error = None
# Preemptively suppress watcher in case the poller notices changes right after.
self._watcher_suppress_until = max(
self._watcher_suppress_until,
time.monotonic() + self._watcher_suppress_after_sync_seconds,
)
self._notify_listeners() self._notify_listeners()
logger.info(f"Starting sync (Source: {trigger_source})...") logger.info(f"Starting sync (Source: {trigger_source})...")
@@ -110,11 +139,119 @@ 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, "outputs", "local_result.m3u8")
if os.path.exists(local_result_path):
tracks = load_local_playlist(local_result_path)
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
# Ensure directory exists
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
write_local_playlist(dest_path, tracks)
# 2. Write Remote (Plex)
remote_result_path = os.path.join(output_dir, "outputs", "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 = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
delete_local_playlist(dest_path)
# Also check for .m3u
dest_path_m3u = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u")
self.suppress_watcher_events(self._watcher_suppress_after_write_seconds)
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}")
@staticmethod
def _safe_local_playlist_path(local_dir: str, playlist_name: str, extension: str) -> str:
base_dir = os.path.abspath(local_dir or "")
if not base_dir:
raise ValueError("Local playlist directory is not configured")
original = (playlist_name or "").strip()
# Drop any path components.
name = os.path.basename(original)
# Remove control chars.
name = re.sub(r"[\x00-\x1f\x7f]", "_", name)
# Replace path separators and Windows-invalid characters.
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip().strip(". ")
windows_reserved = {
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}
needs_hash = False
if not cleaned:
cleaned = "playlist"
needs_hash = True
if cleaned.upper() in windows_reserved:
needs_hash = True
if cleaned != original:
needs_hash = True
cleaned = cleaned[:160].rstrip().strip(". ")
if not cleaned:
cleaned = "playlist"
needs_hash = True
if needs_hash:
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
cleaned = f"{cleaned}__{digest}"
filename = f"{cleaned}{extension}"
candidate = os.path.abspath(os.path.join(base_dir, filename))
# Ensure the final path stays within base_dir.
if os.path.commonpath([base_dir, candidate]) != base_dir:
raise ValueError("Refusing to write outside local playlist directory")
return candidate
def _complete_sync(self, status, error=None): def _complete_sync(self, status, error=None):
now = time.monotonic()
with self._lock: with self._lock:
# Keep watcher suppression a bit after sync finishes, since PollingObserver
# may detect file changes on the next polling tick.
self._watcher_suppress_until = max(
self._watcher_suppress_until,
now + self._watcher_suppress_after_sync_seconds,
)
self._last_status = status self._last_status = status
self._last_error = error self._last_error = error
self._last_sync_time = datetime.now() self._last_sync_time = datetime.now()
+17 -3
View File
@@ -22,22 +22,36 @@ class PlaylistEventHandler(FileSystemEventHandler):
if event.is_directory: if event.is_directory:
return return
# For moved events, the interesting path is the destination.
event_path = getattr(event, "dest_path", None) if event.event_type == "moved" else event.src_path
if not event_path:
return
# Filter out noisy events. Only listen to actual changes. # Filter out noisy events. Only listen to actual changes.
# 'opened' and 'closed' (without write) are read events and should be ignored. # 'opened' and 'closed' (without write) are read events and should be ignored.
if event.event_type not in ['created', 'modified', 'deleted', 'moved']: if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return return
# Ignore temporary files or hidden files # Ignore temporary files or hidden files
filename = os.path.basename(event.src_path) filename = os.path.basename(event_path)
if filename.startswith('.'): if filename.startswith('.'):
return return
# Only watch playlist files to avoid noisy triggers.
if not filename.lower().endswith((".m3u", ".m3u8")):
return
# Prevent feedback loops: if sync is in progress, ignore events # Prevent feedback loops: if sync is in progress, ignore events
if sync_manager.is_syncing: if sync_manager.is_syncing:
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event.src_path} because sync is in progress.") logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event_path} because sync is in progress.")
return return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event.src_path}") # Prevent feedback loops: ignore events right after sync writes/deletes.
if getattr(sync_manager, "is_watcher_suppressed", False):
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event_path} because watcher is suppressed.")
return
logger.info(f"[Watcher] Accepted file change: {event.event_type} {event_path}")
self.trigger_sync() self.trigger_sync()
def trigger_sync(self): def trigger_sync(self):
-12
View File
@@ -1,12 +0,0 @@
services:
plex-playlist-sync:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
ports:
- "8888:8080"
volumes:
- path_to_your_playlist:/app/playlist
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
restart: unless-stopped
+36
View File
@@ -0,0 +1,36 @@
services:
plex-playlist-sync:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --no-server-header
ports:
- "8888:8080"
volumes:
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
- PATH_TO_DATA/backup:/app/data/backup
- PATH_TO_DATA/config:/app/data/config
- PATH_TO_DATA/sync_artifacts:/app/data/sync_artifacts
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
- PLEXPLAYLISTSYNC_CONFIG_PATH=/app/data/config/config.json
- PLEXPLAYLISTSYNC_BACKUP_DIR=/app/data/backup
- TZ=${TZ:-UTC}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# CORS (default: disabled / same-origin only)
# Comma-separated list or JSON array. Example:
# - PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# - PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=["https://your.domain"]
# Use '*' only if you understand the risk (credentials will be forced off).
- PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS=${PLEXPLAYLISTSYNC_CORS_ALLOWED_ORIGINS:-}
# Optional: allow cookies/credentials for allowed origins (ignored when origins contains '*')
- PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS=${PLEXPLAYLISTSYNC_CORS_ALLOW_CREDENTIALS:-0}
# Optional API auth (protects /api/*).
# Set PLEXPLAYLISTSYNC_AUTH_ENABLED=1 to enable.
- PLEXPLAYLISTSYNC_AUTH_ENABLED=${PLEXPLAYLISTSYNC_AUTH_ENABLED:-0}
- PLEXPLAYLISTSYNC_AUTH_USERNAME=${PLEXPLAYLISTSYNC_AUTH_USERNAME:-}
- PLEXPLAYLISTSYNC_AUTH_PASSWORD=${PLEXPLAYLISTSYNC_AUTH_PASSWORD:-}
# Optional: stable signing secret for tokens (recommended if auth enabled).
- PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET=${PLEXPLAYLISTSYNC_AUTH_TOKEN_SECRET:-}
# Optional: token TTL seconds
- PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS=${PLEXPLAYLISTSYNC_AUTH_TOKEN_TTL_SECONDS:-86400}
restart: unless-stopped
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
# Configure timezone inside the container if TZ is provided.
# This avoids relying on host mounts like /etc/localtime, which are awkward on Windows.
if [ "${TZ:-}" != "" ]; then
ZONEINFO="/usr/share/zoneinfo/${TZ}"
if [ -e "$ZONEINFO" ]; then
ln -snf "$ZONEINFO" /etc/localtime
echo "$TZ" > /etc/timezone
else
echo "[entrypoint] Warning: TZ='$TZ' not found at $ZONEINFO; keeping existing timezone." >&2
fi
fi
exec "$@"
+364 -84
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api'; import { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
@@ -10,15 +9,16 @@ import {
SYNC_SUCCESS_TOTAL_MS, SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS, SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS, TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS, TOAST_EXIT_DURATION_MS
SYNC_BANNER_PADDING_X,
SYNC_BANNER_PADDING_Y,
SYNC_BANNER_MIN_WIDTH,
} from './Config'; } from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
import ServerPanel from './components/ServerPanel'; import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector'; import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal'; import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff } from 'lucide-react'; import LoginScreen from './components/LoginScreen';
import OverflowMarquee from './components/OverflowMarquee';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast { interface Toast {
id: number; id: number;
@@ -115,6 +115,13 @@ const useStripeAnimation = (syncState: SyncState) => {
}; };
const App: React.FC = () => { const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
// Auth (optional; controlled by backend /api/auth/config)
const [authReady, setAuthReady] = useState(false);
const [authEnabled, setAuthEnabled] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState('');
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]); const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]); const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined); const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -137,12 +144,22 @@ const App: React.FC = () => {
// Connection Modal State // Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State // Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE); const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State // Path Mapping State (Includes Simple and Regex Rules)
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]); const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State // Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({ const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
@@ -155,6 +172,81 @@ 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
});
useEffect(() => {
let cancelled = false;
const initAuth = async () => {
try {
const cfg = await apiService.getAuthConfig();
const enabled = cfg.status === 'success' ? !!cfg.data.enabled : false;
if (cancelled) return;
setAuthEnabled(enabled);
if (!enabled) {
setIsAuthenticated(true);
setAuthReady(true);
return;
}
const savedToken = localStorage.getItem('plexsync-token');
const savedUser = localStorage.getItem('plexsync-username');
if (!savedToken || !savedUser) {
setIsAuthenticated(false);
setCurrentUser('');
setAuthReady(true);
return;
}
const me = await apiService.me();
if (me.status === 'success') {
setIsAuthenticated(true);
setCurrentUser(me.data.username || savedUser);
} else {
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
}
} catch {
// If auth discovery fails, fall back to no-auth to keep local dev workable.
setAuthEnabled(false);
setIsAuthenticated(true);
} finally {
if (!cancelled) setAuthReady(true);
}
};
initAuth();
return () => {
cancelled = true;
};
}, []);
const handleLoginSuccess = (token: string, username: string) => {
localStorage.setItem('plexsync-token', token);
localStorage.setItem('plexsync-username', username);
setCurrentUser(username);
setIsAuthenticated(true);
};
const handleLogout = async () => {
try {
await apiService.logout();
} catch {
// ignore
}
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
};
// Toast Notification System // Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({}); const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -226,7 +318,7 @@ const App: React.FC = () => {
const result = await apiService.getSettings(); const result = await apiService.getSettings();
if (result.status === 'success') { if (result.status === 'success') {
setCurrentStrategy(result.data.strategy); setCurrentStrategy(result.data.strategy);
setRegexReplacements(result.data.regex); setPathMappingConfig(result.data.pathMapping);
setLocalPath(result.data.localPath || 'playlist'); setLocalPath(result.data.localPath || 'playlist');
setConnectionSettings(result.data.connection); setConnectionSettings(result.data.connection);
} }
@@ -240,6 +332,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);
@@ -250,19 +349,33 @@ const App: React.FC = () => {
loadSchedule(); loadSchedule();
if (settings.mode === ScheduleMode.DISABLED) { if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled."); addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast(t('toasts.scheduleEmpty'));
} else { } else {
addToast("Scheduled task updated successfully."); addToast(t('toasts.scheduleStarted'));
} }
return true; return true;
} else { } else {
addToast(result.message || "Failed to update schedule."); addToast(result.message || t('toasts.scheduleFailed'));
return false; return false;
} }
}; };
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast(t('toasts.backupSaved'));
} else {
addToast(result.message || t('toasts.backupFailed'));
}
};
// Fetch Local Playlists // Fetch Local Playlists
const refreshLocal = useCallback(async () => { const refreshLocal = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController(); const abortController = new AbortController();
localAbortRef.current = abortController; localAbortRef.current = abortController;
@@ -274,19 +387,20 @@ const App: React.FC = () => {
} }
setLoadingLocal(false); setLoadingLocal(false);
localAbortRef.current = null; localAbortRef.current = null;
}, [localPath]); }, [authReady, isAuthenticated, localPath]);
const cancelLocalRefresh = () => { const cancelLocalRefresh = () => {
if (localAbortRef.current) { if (localAbortRef.current) {
localAbortRef.current.abort(); localAbortRef.current.abort();
localAbortRef.current = null; localAbortRef.current = null;
setLoadingLocal(false); setLoadingLocal(false);
addToast("Local refresh cancelled."); addToast(t('toasts.localRefreshCancelled'));
} }
}; };
// Fetch Cloud Playlists and Info // Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => { const refreshCloud = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController(); const abortController = new AbortController();
cloudAbortRef.current = abortController; cloudAbortRef.current = abortController;
@@ -308,25 +422,28 @@ const App: React.FC = () => {
setLoadingCloud(false); setLoadingCloud(false);
cloudAbortRef.current = null; cloudAbortRef.current = null;
} }
}, []); }, [authReady, isAuthenticated]);
const cancelCloudRefresh = () => { const cancelCloudRefresh = () => {
if (cloudAbortRef.current) { if (cloudAbortRef.current) {
cloudAbortRef.current.abort(); cloudAbortRef.current.abort();
cloudAbortRef.current = null; cloudAbortRef.current = null;
setLoadingCloud(false); setLoadingCloud(false);
addToast("Cloud refresh cancelled."); addToast(t('toasts.cloudRefreshCancelled'));
} }
}; };
// Load persisted configuration // Load persisted configuration
useEffect(() => { useEffect(() => {
if (!authReady || !isAuthenticated) return;
loadSettings(); loadSettings();
loadSchedule(); loadSchedule();
}, [loadSettings, loadSchedule]); loadBackupSettings();
}, [authReady, isAuthenticated, loadSettings, loadSchedule, loadBackupSettings]);
// Initial Load // Initial Load
useEffect(() => { useEffect(() => {
if (!authReady || !isAuthenticated) return;
refreshLocal(); refreshLocal();
refreshCloud(); refreshCloud();
return () => { return () => {
@@ -334,38 +451,39 @@ const App: React.FC = () => {
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort();
} }
}, [refreshLocal, refreshCloud]); }, [authReady, isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change // Handle Strategy Change
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => { const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy); setCurrentStrategy(strategy);
const result = await apiService.updateSyncStrategy(strategy); const result = await apiService.updateSyncStrategy(strategy);
if (result.status === 'success') { if (result.status === 'success') {
addToast(`Selected strategy "${label}" has been saved.`); addToast(t('toasts.strategySaved', { strategy: label }));
} else { } else {
addToast(result.message || 'Failed to save sync strategy.'); addToast(result.message || t('toasts.strategySaveFailed'));
} }
}; };
// Handle Regex Save // Handle Path Mapping Save
const handleSaveRegex = async (replacements: RegexReplacement[]) => { const handleSavePathMapping = async (config: PathMappingConfig) => {
setRegexReplacements(replacements); setPathMappingConfig(config);
const result = await apiService.saveRegexRules(replacements); const result = await apiService.savePathMapping(config);
if (result.status === 'success') { if (result.status === 'success') {
addToast('Regex preprocessing rules have been saved.'); addToast(t('toasts.mappingSaved'));
} else { } else {
addToast(result.message || 'Failed to save regex rules.'); addToast(result.message || t('toasts.mappingSaveFailed'));
} }
}; };
// Handle Sync Trigger // Handle Sync Trigger
const handleSyncTrigger = async () => { const handleSyncTrigger = async () => {
if (!authReady || !isAuthenticated) return;
if (syncState !== SyncState.IDLE) return; if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING); setSyncState(SyncState.SYNCING);
manualSyncInProgress.current = true; manualSyncInProgress.current = true;
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements, localPath || undefined); const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig, localPath || undefined);
manualSyncInProgress.current = false; manualSyncInProgress.current = false;
@@ -379,14 +497,23 @@ const App: React.FC = () => {
}, SYNC_SUCCESS_TOTAL_MS); }, SYNC_SUCCESS_TOTAL_MS);
} else { } else {
setSyncState(SyncState.ERROR); setSyncState(SyncState.ERROR);
addToast(result.message || 'Sync failed. Please check connection.'); addToast(result.message || t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
} }
}; };
// SSE for sync status // SSE for sync status
useEffect(() => { useEffect(() => {
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`); if (!authReady || !isAuthenticated) return;
const base = `${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`;
const url = new URL(base, window.location.origin);
if (authEnabled) {
const token = localStorage.getItem('plexsync-token');
if (!token) return;
url.searchParams.set('access_token', token);
}
const eventSource = new EventSource(url.toString());
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
try { try {
@@ -426,11 +553,11 @@ const App: React.FC = () => {
setSyncState(SyncState.SUCCESS); setSyncState(SyncState.SUCCESS);
refreshLocal(); refreshLocal();
refreshCloud(); refreshCloud();
addToast("Background sync completed successfully."); addToast(t('toasts.backgroundSyncSuccess'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
} else if (status === 'error') { } else if (status === 'error') {
setSyncState(SyncState.ERROR); setSyncState(SyncState.ERROR);
addToast(`Background sync failed: ${error}`); addToast(t('toasts.backgroundSyncFailed', { error: error || '' }));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
} }
} else { } else {
@@ -453,7 +580,24 @@ const App: React.FC = () => {
return () => { return () => {
eventSource.close(); eventSource.close();
}; };
}, [syncState, refreshLocal, refreshCloud, addToast]); }, [authReady, isAuthenticated, authEnabled, syncState, refreshLocal, refreshCloud, addToast]);
if (!authReady) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 text-gray-200">
{t('common.loading')}
</div>
);
}
if (authEnabled && !isAuthenticated) {
return (
<LoginScreen
onLoginSuccess={handleLoginSuccess}
onLoginError={(msg) => addToast(msg)}
/>
);
}
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => { const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo); setCloudServerInfo(serverInfo);
@@ -488,31 +632,85 @@ const App: React.FC = () => {
const getScheduleDisplayInfo = () => { const getScheduleDisplayInfo = () => {
const result = { const result = {
label: 'Schedule', label: t('dashboard.autoSync'),
value: 'Not configured', value: t('schedule.notConfigured'),
active: false, active: false,
autoWatch: scheduleSettings.autoWatch autoWatch: scheduleSettings.autoWatch,
}; };
if (scheduleSettings.mode === ScheduleMode.DISABLED) { if (scheduleSettings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync'; result.value = t('common.disabled');
result.value = 'Disabled';
return result; return result;
} }
let label = 'Schedule'; if (scheduleSettings.mode === ScheduleMode.CRON && scheduleSettings.cronExpression.trim() === '') {
if (scheduleSettings.mode === ScheduleMode.CRON) label = 'Cron Schedule'; result.value = t('dashboard.notSet');
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule'; } else {
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule'; result.value = nextRunTime ? `${nextRunTime}` : t('common.loading');
}
result.label = label;
result.value = nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...';
result.active = true; result.active = true;
return result; return result;
}; };
const scheduleInfo = getScheduleDisplayInfo(); const scheduleInfo = getScheduleDisplayInfo();
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
count = config.simple.length;
Icon = Type;
} else {
count =
config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon,
};
}
const modeLabel = config.mode === PathMappingMode.SIMPLE ? t('mapping.simple') : t('mapping.regex');
return {
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon,
};
};
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false,
};
}
return {
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true,
};
};
const backupInfo = getBackupDisplayInfo(backupSettings);
return ( return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black"> <div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -574,57 +772,137 @@ const App: React.FC = () => {
{syncState === SyncState.IDLE ? ( {syncState === SyncState.IDLE ? (
<> <>
{/* Normal Toolbar */} {/* Normal Toolbar Left */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20"> <div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} /> <ArrowLeftRight size={24} strokeWidth={2.5} />
</div> </div>
<h1 className="text-xl font-bold tracking-tight text-white"> <h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span> <span className="text-plex-orange">PMS</span> Playlist Sync
</h1> </h1>
</div> </div>
{/* Normal Toolbar Right */} {/* Normal Toolbar Right */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Schedule Info */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{scheduleInfo.label}
</span>
<div className="text-xs font-mono flex items-center gap-1.5">
{/* Schedule Part */}
<div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{scheduleInfo.active && <Clock size={12} />}
<span>{scheduleInfo.value}</span>
</div>
{/* Watch Part */} {/* Unified Status Dock */}
<span className="text-gray-700 mx-0.5">|</span> <div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
<div
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`} {/* Path Mapping Section */}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"} <div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[120px] group/item">
> <span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{pathMappingInfo.label}</span>
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />} <div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
<span className="text-[10px] font-sans font-bold">WATCH</span> <pathMappingInfo.Icon size={12} strokeWidth={2.5} className="flex-shrink-0" />
</div> <OverflowMarquee>
</div> {pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
</OverflowMarquee>
</div>
</div>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[100px] group/item">
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${backupInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{backupInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
<Archive size={12} strokeWidth={2.5} className="flex-shrink-0" />
<OverflowMarquee>
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
</OverflowMarquee>
</div>
</div>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 w-[180px] group/item">
<div className="flex items-center justify-between">
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{scheduleInfo.label}</span>
{/* Watch Indicator Badge */}
<div
className={`flex items-center gap-1 px-1 rounded-[2px] transition-colors ${scheduleInfo.autoWatch ? 'text-plex-orange bg-plex-orange/10' : 'text-gray-700 bg-gray-800'}`}
title={scheduleInfo.autoWatch ? t('dashboard.watchModeActive') : t('dashboard.watchModeDisabled')}
>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">{t('dashboard.watch')}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
<Clock size={12} strokeWidth={2.5} className="flex-shrink-0" />
<OverflowMarquee>
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
</OverflowMarquee>
</div>
</div>
</div> </div>
{/* Connection Status Button */} <div className="flex items-center gap-4">
<button {/* Language Switcher */}
onClick={() => setIsConnectionModalOpen(true)} <div className="relative">
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md ${ <button
isConnected onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20" className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20" title={t('common.switchLanguage')}
}`} >
title={isConnected ? "Connected to Plex" : "Disconnected"} <Languages size={18} />
> </button>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />} {isLangMenuOpen && (
</button> <>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
{/* Logout (rightmost) */}
{authEnabled && isAuthenticated && (
<button
onClick={handleLogout}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('auth.logout')}
>
<LogOut size={18} />
</button>
)}
</div>
</div> </div>
</> </>
) : ( ) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div <div
className="bg-black shadow-none rounded-none border-none" className="bg-black shadow-none rounded-none border-none"
@@ -634,7 +912,7 @@ const App: React.FC = () => {
}} }}
> >
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}> <h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'} {syncState === SyncState.SYNCING ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1> </h1>
</div> </div>
</div> </div>
@@ -691,8 +969,10 @@ const App: React.FC = () => {
<StrategySelector <StrategySelector
currentStrategy={currentStrategy} currentStrategy={currentStrategy}
onSelect={handleStrategyChange} onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements} savedPathMapping={pathMappingConfig}
onSaveRegex={handleSaveRegex} onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings} savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule} onSaveSchedule={handleSaveSchedule}
syncState={syncState} syncState={syncState}
@@ -717,7 +997,7 @@ const App: React.FC = () => {
{/* Footer */} {/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur"> <footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p> <p>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer> </footer>
{/* Modals */} {/* Modals */}
+63
View File
@@ -0,0 +1,63 @@
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { translations, Language } from './translations';
interface LanguageContextProps {
language: Language;
setLanguage: (lang: Language) => void;
t: (path: string, params?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextProps | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguageState] = useState<Language>('en');
useEffect(() => {
const savedLang = localStorage.getItem('plexsync-language') as Language;
if (savedLang && translations[savedLang]) {
setLanguageState(savedLang);
}
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('plexsync-language', lang);
};
const t = (path: string, params?: Record<string, string | number>): string => {
const keys = path.split('.');
let current: any = translations[language];
for (const key of keys) {
if (current[key] === undefined) {
console.warn(`Missing translation for key: ${path} in language: ${language}`);
return path;
}
current = current[key];
}
let text = current as string;
if (params) {
Object.entries(params).forEach(([key, value]) => {
text = text.replace(`{${key}}`, String(value));
});
}
return text;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+44 -30
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types'; import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react'; import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps { interface ConnectionModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,6 +14,7 @@ interface ConnectionModalProps {
} }
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => { const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({ const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http', protocol: 'http',
address: '', address: '',
@@ -35,10 +37,12 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const [selectedLibraryId, setSelectedLibraryId] = useState<string>(''); const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const prevIsOpenRef = useRef(isOpen);
// Reset state when opening // Reset state when opening
useEffect(() => { useEffect(() => {
if (isOpen) { // Only execute reset logic when modal opens (isOpen changes from false to true)
if (isOpen && !prevIsOpenRef.current) {
setError(null); setError(null);
setConnectedServerInfo(null); setConnectedServerInfo(null);
setLibraries([]); setLibraries([]);
@@ -54,12 +58,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
})); }));
} }
} }
return () => {
// Cleanup any pending request if modal closes // Cleanup when closing
if (!isOpen && prevIsOpenRef.current) {
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
} }
}; }
prevIsOpenRef.current = isOpen;
}, [isOpen, initialSettings]); }, [isOpen, initialSettings]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -85,9 +92,9 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(updatedInfo); onConnectSuccess(updatedInfo);
const saveResult = await apiService.updateLibrary(lib.title); const saveResult = await apiService.updateLibrary(lib.title);
if (saveResult.status !== 'success') { if (saveResult.status !== 'success') {
onShowMessage(saveResult.message || 'Failed to save library selection'); onShowMessage(saveResult.message || t('toasts.librarySaveFailed'));
} else { } else {
onShowMessage(`Library switched to ${lib.title}`); onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
} }
} }
}; };
@@ -107,7 +114,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
abortControllerRef.current.abort(); abortControllerRef.current.abort();
abortControllerRef.current = null; abortControllerRef.current = null;
setIsConnecting(false); setIsConnecting(false);
setError("Connection cancelled by user."); setError(t('toasts.connectionCancelled'));
} }
return; return;
} }
@@ -136,13 +143,14 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const info = result.data.serverInfo; const info = result.data.serverInfo;
setConnectedServerInfo(info); setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`); onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || []; const libs = info.libraries || [];
setLibraries(libs); const musicLibraries = libs.filter((lib) => lib.type === 'artist').sort((a, b) => a.title.localeCompare(b.title));
if (libs.length > 0) { setLibraries(musicLibraries);
if (musicLibraries.length > 0) {
const preferred = info.libraryName || formData.libraryName; const preferred = info.libraryName || formData.libraryName;
const defaultLib = libs.find(lib => lib.title === preferred) || libs[0]; const defaultLib = musicLibraries.find(lib => lib.title === preferred) || musicLibraries[0];
setSelectedLibraryId(defaultLib.id); setSelectedLibraryId(defaultLib.id);
setFormData(prev => ({ ...prev, libraryName: defaultLib.title })); setFormData(prev => ({ ...prev, libraryName: defaultLib.title }));
onConnectSuccess({ onConnectSuccess({
@@ -151,27 +159,33 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
}); });
const saveResult = await apiService.updateLibrary(defaultLib.title); const saveResult = await apiService.updateLibrary(defaultLib.title);
if (saveResult.status !== 'success') { if (saveResult.status !== 'success') {
setError(saveResult.message || 'Failed to save library selection'); setError(saveResult.message || t('toasts.librarySaveFailed'));
} }
} else { } else {
onConnectSuccess(info); onConnectSuccess(info);
} }
} else { } else {
setError(result.message || "Connection failed"); setError(result.message || t('server.connectionFailed'));
} }
}; };
const isConnected = !!connectedServerInfo; const isConnected = !!connectedServerInfo;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <div
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"> className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none"> <div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} /> <Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'} {isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3> </h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors"> <button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} /> <X size={20} />
@@ -190,7 +204,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Server Connection */} {/* Server Connection */}
<div className="space-y-3"> <div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label> <label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<div className="col-span-1"> <div className="col-span-1">
<select <select
@@ -214,7 +228,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
name="address" name="address"
required required
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="IP Address or Domain" placeholder={t('connection.address')}
value={formData.address} value={formData.address}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`} className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -228,7 +242,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="port" name="port"
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)" placeholder={t('connection.port')}
value={formData.port} value={formData.port}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`} className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -240,7 +254,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Authentication */} {/* Authentication */}
<div className="space-y-3"> <div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label> <label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */} {/* Token */}
<div className="relative"> <div className="relative">
@@ -251,7 +265,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="token" name="token"
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)" placeholder={t('connection.token')}
value={formData.token} value={formData.token}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`} className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -273,7 +287,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="username" name="username"
disabled={isTokenProvided || isConnecting} disabled={isTokenProvided || isConnecting}
placeholder="Username / Email" placeholder={t('connection.username')}
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`} className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -289,7 +303,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
name="password" name="password"
disabled={isTokenProvided || isConnecting} disabled={isTokenProvided || isConnecting}
placeholder="Password" placeholder={t('connection.password')}
value={formData.password} value={formData.password}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`} className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -317,7 +331,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings size={14} /> <Settings size={14} />
<span>Advanced Options</span> <span>{t('connection.advanced')}</span>
</div> </div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button> </button>
@@ -325,7 +339,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{showAdvanced && ( {showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2"> <div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div> <div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label> <label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input <input
type="number" type="number"
min="1" min="1"
@@ -354,15 +368,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{isConnecting ? ( {isConnecting ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span> <span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</> </>
) : 'Connect Server'} ) : t('connection.connectBtn')}
</button> </button>
) : ( ) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center"> <div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2"> <p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} /> <CheckCircle size={16} />
Connected Successfully {t('connection.connectedSuccess')}
</p> </p>
</div> </div>
)} )}
@@ -371,7 +385,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Library Selection - Appears after connection */} {/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && ( {isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in"> <div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label> <label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" /> <Library size={14} className="text-plex-orange" />
@@ -395,7 +409,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onClick={onClose} onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500" className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
> >
Done {t('common.done')}
</button> </button>
</div> </div>
</div> </div>
+182
View File
@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { useLanguage } from '../LanguageContext';
import { apiService } from '../services/api';
import { Lock, User, Loader2, Languages, ArrowRight, ArrowLeftRight } from 'lucide-react';
import type { LoginCredentials } from '../types';
interface LoginScreenProps {
onLoginSuccess: (token: string, username: string) => void;
onLoginError: (msg: string) => void;
}
const LoginScreen: React.FC<LoginScreenProps> = ({ onLoginSuccess, onLoginError }) => {
const { t, language, setLanguage } = useLanguage();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLocalError(null);
try {
const creds: LoginCredentials = { username, password };
const response = await apiService.login(creds);
if (response.status === 'success') {
onLoginSuccess(response.data.token, response.data.username);
} else {
const errorMsg = response.message || t('auth.invalidCredentials');
setLocalError(errorMsg);
onLoginError(errorMsg);
}
} catch {
setLocalError(t('auth.invalidCredentials'));
} finally {
setIsLoading(false);
}
};
const currentLangLabel =
language === 'en' ? 'English'
: language === 'es' ? 'Español'
: language === 'chs' ? '简体中文'
: '繁體中文';
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black p-4">
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-plex-orange/10 rounded-full blur-[100px] opacity-20"></div>
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[100px] opacity-20"></div>
</div>
{/* Language Switcher (Top Right) */}
<div className="absolute top-6 right-6 z-20">
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center space-x-2 px-3 py-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 border border-gray-700 hover:border-gray-600 text-gray-300 transition-all backdrop-blur-sm"
>
<Languages size={16} />
<span className="text-sm font-medium">{currentLangLabel}</span>
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</div>
</>
)}
</div>
</div>
{/* Main Card */}
<div className="w-full max-w-md bg-gray-900/60 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-2xl p-8 z-10">
<div className="text-center mb-8 flex flex-col items-center">
<div className="inline-flex items-center justify-center p-3 rounded-xl bg-gradient-to-br from-plex-orange to-yellow-600 shadow-lg shadow-plex-orange/20 mb-4">
<ArrowLeftRight size={32} strokeWidth={2.5} className="text-gray-900" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
<form onSubmit={handleLogin} className="space-y-4">
{localError && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center justify-center">
{localError}
</div>
)}
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.username')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="username"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.password')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="password"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || !username || !password}
className={`w-full h-12 mt-6 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLoading
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-plex-orange text-gray-900 hover:bg-yellow-500 active:scale-[0.98]'
}`}
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
<span>{t('auth.loggingIn')}</span>
</>
) : (
<>
<span>{t('auth.loginBtn')}</span>
<ArrowRight size={18} />
</>
)}
</button>
</form>
<div className="mt-8 pt-6 border-t border-gray-700/50 text-center">
<p className="text-[10px] text-gray-600">© PMS Playlist Sync</p>
</div>
</div>
</div>
);
};
export default LoginScreen;
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
type OverflowMarqueeProps = {
children: React.ReactNode;
className?: string;
textClassName?: string;
title?: string;
speedPxPerSec?: number;
minDurationSec?: number;
};
type MarqueeMetrics = {
isOverflowing: boolean;
overflowPx: number;
durationSec: number;
};
const DEFAULT_SPEED_PX_PER_SEC = 24;
const DEFAULT_MIN_DURATION_SEC = 4;
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
children,
className,
textClassName,
title,
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
minDurationSec = DEFAULT_MIN_DURATION_SEC,
}) => {
const containerRef = useRef<HTMLSpanElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [metrics, setMetrics] = useState<MarqueeMetrics>({
isOverflowing: false,
overflowPx: 0,
durationSec: minDurationSec,
});
const fallbackTitle = useMemo(() => {
if (title) return title;
return typeof children === 'string' ? children : undefined;
}, [children, title]);
const recompute = () => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Ensure we measure with current layout.
const available = container.clientWidth;
const content = text.scrollWidth;
const overflowPx = Math.ceil(content - available);
if (overflowPx > 1) {
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
setMetrics({ isOverflowing: true, overflowPx, durationSec });
} else {
// Avoid re-render loops if already not overflowing.
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
}
};
useLayoutEffect(() => {
recompute();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children]);
useEffect(() => {
recompute();
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
const ro = new ResizeObserver(() => recompute());
ro.observe(container);
ro.observe(text);
const onResize = () => recompute();
window.addEventListener('resize', onResize);
return () => {
ro.disconnect();
window.removeEventListener('resize', onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
? ({
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
['--marquee-duration' as any]: `${metrics.durationSec}s`,
} satisfies React.CSSProperties)
: undefined;
return (
<span
ref={containerRef}
className={['overflow-marquee', className].filter(Boolean).join(' ')}
title={fallbackTitle}
>
<span
ref={textRef}
className={[
'overflow-marquee__text',
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
textClassName,
]
.filter(Boolean)
.join(' ')}
style={textStyle}
>
{children}
</span>
</span>
);
};
export default OverflowMarquee;
+9 -4
View File
@@ -1,26 +1,31 @@
import React from 'react'; import React from 'react';
import { Playlist } from '../types'; import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react'; import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface PlaylistCardProps { interface PlaylistCardProps {
playlist: Playlist; playlist: Playlist;
} }
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => { const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return ( return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm"> <div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors"> <h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
{playlist.title} <OverflowMarquee>
{playlist.title}
</OverflowMarquee>
</h4> </h4>
</div> </div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400"> <div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count"> <span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" /> <Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount} {playlist.trackCount}
</span> </span>
<span className="flex items-center" title="Last Updated"> <span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" /> <Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()} {new Date(playlist.lastUpdated).toLocaleDateString()}
</span> </span>
+19 -11
View File
@@ -3,6 +3,8 @@ import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types'; import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard'; import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react'; import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface ServerPanelProps { interface ServerPanelProps {
type: ServerType; type: ServerType;
@@ -14,6 +16,7 @@ interface ServerPanelProps {
} }
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => { const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL; const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud; let Icon = isLocal ? Server : Cloud;
@@ -28,39 +31,44 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
let displaySubtitle: React.ReactNode = null; let displaySubtitle: React.ReactNode = null;
if (isLocal) { if (isLocal) {
displayTitle = 'Local Server'; displayTitle = t('server.local');
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0"> <p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists {t('server.playlists', { count: playlists.length })}
</p> </p>
); );
} else { } else {
// Cloud Logic // Cloud Logic
if (serverInfo) { if (serverInfo) {
if (serverInfo.isConnected) { if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server'; displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = ( displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0"> <div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span> <span className="text-plex-orange font-semibold min-w-0 max-w-full">
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
<OverflowMarquee className="hidden md:inline-block">
{serverInfo.libraryName}
</OverflowMarquee>
</span>
<span className="text-gray-600 hidden md:inline"></span> <span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span> <span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div> </div>
); );
} else { } else {
displayTitle = 'Not Connected'; displayTitle = t('server.notConnected');
Icon = WifiOff; Icon = WifiOff;
headerColor = 'text-red-400'; headerColor = 'text-red-400';
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5"> <p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed {t('server.connectionFailed')}
</p> </p>
); );
} }
} else { } else {
displayTitle = 'Cloud Server'; displayTitle = t('server.cloud');
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5"> <p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'} {isLoading ? t('server.connecting') : t('server.waiting')}
</p> </p>
); );
} }
@@ -121,7 +129,7 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
: 'text-gray-400 hover:text-white hover:bg-white/10' : 'text-gray-400 hover:text-white hover:bg-white/10'
} }
`} `}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"} title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
> >
{isLoading ? ( {isLoading ? (
<div className="relative flex items-center justify-center"> <div className="relative flex items-center justify-center">
@@ -141,11 +149,11 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
{isLoading && playlists.length === 0 ? ( {isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3"> <div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" /> <RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p> <p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div> </div>
) : playlists.length === 0 ? ( ) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500"> <div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p> <p className="text-sm">{t('server.noPlaylists')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2.5 md:space-y-3"> <div className="space-y-2.5 md:space-y-3">
File diff suppressed because it is too large Load Diff
+36 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title> <title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
@@ -36,6 +36,41 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #6b7280; background: #6b7280;
} }
/* Overflow marquee (auto-scroll when truncated) */
.overflow-marquee {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.overflow-marquee__text {
display: inline-block;
will-change: transform;
}
.overflow-marquee__text--animate {
animation: overflow-marquee-scroll var(--marquee-duration, 6s) linear infinite;
}
@keyframes overflow-marquee-scroll {
0%, 12% {
transform: translateX(0);
}
70%, 86% {
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.overflow-marquee__text--animate {
animation: none;
}
}
</style> </style>
<script type="importmap"> <script type="importmap">
{ {
+4 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
@@ -10,6 +11,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode> </React.StrictMode>
); );
+172
View File
@@ -0,0 +1,172 @@
export const cht = {
app: {
title: 'PlexSync',
manager: '管理',
footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。',
},
auth: {
title: '登入',
subtitle: '登入後管理播放清單同步',
username: '使用者名稱',
password: '密碼',
loginBtn: '登入',
logout: '登出',
loggingIn: '驗證中…',
invalidCredentials: '使用者名稱或密碼錯誤',
welcome: '歡迎,{user}',
},
common: {
save: '儲存',
cancel: '取消',
revert: '還原',
delete: '刪除',
done: '完成',
loading: '載入中…',
refresh: '重新整理',
close: '關閉',
none: '無',
disabled: '已停用',
add: '新增',
switchLanguage: '切換語言',
},
server: {
local: '本機伺服器',
cloud: '雲端伺服器',
playlists: '{count} 個播放清單',
notConnected: '未連線',
connectionFailed: '連線失敗',
connecting: '連線中…',
waiting: '等待中…',
syncing: '同步中…',
noPlaylists: '找不到播放清單。',
cancelRefresh: '取消重新整理',
refreshPlaylists: '重新整理播放清單',
},
playlist: {
trackCount: '曲目數',
lastUpdated: '上次更新',
},
dashboard: {
mapping: '路徑對應',
backup: '備份',
autoSync: '自動同步',
watch: '監看',
watchModeActive: '監看模式:啟用',
watchModeDisabled: '監看模式:停用',
notSet: '未設定',
retain: '保留:{count}',
keep: '保留 {count}',
connected: '已連線至 Plex',
disconnected: '未連線',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: '同步策略',
localOverwrite: {
label: '本機覆寫',
desc: '本機播放清單完全覆寫雲端。(無 Diff)',
},
cloudOverwrite: {
label: '雲端覆寫',
desc: '雲端播放清單完全覆寫本機。(無 Diff)',
},
mergeLocal: {
label: '雙向合併(本機優先)',
desc: '合併兩端。衝突以本機版本為準。',
},
mergeCloud: {
label: '雙向合併(雲端優先)',
desc: '合併兩端。衝突以雲端版本為準。',
},
syncNow: '立即同步',
syncing: '同步進行中…',
saveWarning: '同步前請先儲存待處理的變更(備份/路徑對應)。',
},
mapping: {
title: '路徑對應',
simple: '簡易對應',
regex: 'Regex 規則',
simpleTitle: '路徑對應',
simpleSubtitle: '使用簡單字串比對將本機路徑對應到雲端路徑',
regexPre: '前處理(同步前)',
regexPost: '後處理(同步後 / 結果)',
localPath: '本機路徑',
cloudPath: '雲端路徑',
pattern: '模式',
replace: '取代',
saveRules: '儲存規則',
noRules: '尚未定義規則。',
},
backup: {
title: '備份保留',
enable: '啟用備份',
enableDesc: '變更前建立副本',
maxVersions: '保留的最大版本數:',
noAutoDelete: '不自動刪除',
autoDelete: '自動刪除最舊版本',
},
schedule: {
title: '排程任務',
cron: 'Cron',
daily: '每日',
weekly: '每週',
weekdaysNarrow: {
0: '日',
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
},
enableCron: '啟用 Cron 排程',
enableDaily: '啟用每日執行',
enableWeekly: '啟用每週執行',
watchLocal: '監看本機變更',
watchDesc: '本機播放清單更新時自動同步',
schedule: '排程',
notConfigured: '尚未設定',
today: '今天',
tomorrow: '明天',
},
connection: {
titleConnected: '伺服器已連線',
titleConnect: '連線 Plex 伺服器',
serverDetails: '伺服器詳細資訊',
authentication: '驗證',
protocol: '通訊協定',
address: 'IP 位址或網域',
port: '連接埠',
token: 'X-Plex-Token(選填)',
username: '使用者名稱 / 電子郵件',
password: '密碼',
advanced: '進階選項',
timeout: '連線逾時(秒)',
connectBtn: '連線伺服器',
connecting: '連線中…',
connectedSuccess: '連線成功',
selectLibrary: '選擇要同步的媒體庫',
},
toasts: {
localRefreshCancelled: '已取消本機重新整理。',
cloudRefreshCancelled: '已取消雲端重新整理。',
strategySaved: '已儲存選擇的策略「{strategy}」。',
strategySaveFailed: '儲存同步策略失敗。',
mappingSaved: '已儲存路徑對應規則。',
mappingSaveFailed: '儲存路徑對應規則失敗。',
backupSaved: '已儲存備份設定。',
backupFailed: '儲存備份設定失敗。',
scheduleDisabled: '已停用排程任務。',
scheduleEmpty: '已停用排程任務(Cron 為空)。',
scheduleStarted: '排程任務更新成功。',
scheduleFailed: '更新排程失敗。',
syncFailed: '同步失敗。請檢查連線。',
backgroundSyncSuccess: '背景同步已成功完成。',
backgroundSyncFailed: '背景同步失敗:{error}',
librarySwitched: '媒體庫已切換為 {library}',
connectedTo: '已成功連線到 {name}',
connectionCancelled: '使用者已取消連線。',
librarySaveFailed: '儲存媒體庫選擇失敗。',
},
};
+172
View File
@@ -0,0 +1,172 @@
export const en = {
app: {
title: 'PlexSync',
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
auth: {
title: 'Login',
subtitle: 'Sign in to manage your playlist syncs',
username: 'Username',
password: 'Password',
loginBtn: 'Sign In',
logout: 'Logout',
loggingIn: 'Verifying...',
invalidCredentials: 'Invalid username or password',
welcome: 'Welcome, {user}',
},
common: {
save: 'Save',
cancel: 'Cancel',
revert: 'Revert',
delete: 'Delete',
done: 'Done',
loading: 'Loading...',
refresh: 'Refresh',
close: 'Close',
none: 'None',
disabled: 'Disabled',
add: 'Add',
switchLanguage: 'Switch Language',
},
server: {
local: 'Local Server',
cloud: 'Cloud Server',
playlists: '{count} Playlists',
notConnected: 'Not Connected',
connectionFailed: 'Connection failed',
connecting: 'Connecting...',
waiting: 'Waiting...',
syncing: 'Syncing...',
noPlaylists: 'No playlists found.',
cancelRefresh: 'Cancel Refresh',
refreshPlaylists: 'Refresh Playlists',
},
playlist: {
trackCount: 'Track Count',
lastUpdated: 'Last Updated',
},
dashboard: {
mapping: 'Mapping',
backup: 'Backup',
autoSync: 'Auto-Sync',
watch: 'Watch',
watchModeActive: 'Watch Mode: Active',
watchModeDisabled: 'Watch Mode: Disabled',
notSet: 'Not Set',
retain: 'Retain: {count}',
keep: 'Keep {count}',
connected: 'Connected to Plex',
disconnected: 'Disconnected',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: 'Sync Strategy',
localOverwrite: {
label: 'Local Overwrite',
desc: 'Local playlist completely overwrites Cloud. (No Diff)',
},
cloudOverwrite: {
label: 'Cloud Overwrite',
desc: 'Cloud playlist completely overwrites Local. (No Diff)',
},
mergeLocal: {
label: 'Two-way Merge (Local Priority)',
desc: 'Merge both. Conflicts resolve to Local version.',
},
mergeCloud: {
label: 'Two-way Merge (Cloud Priority)',
desc: 'Merge both. Conflicts resolve to Cloud version.',
},
syncNow: 'Sync Now',
syncing: 'Sync in Progress...',
saveWarning: 'Please save pending changes (Backups/Path Mapping) before syncing.',
},
mapping: {
title: 'Path Mapping',
simple: 'Simple Mapping',
regex: 'Regex Rules',
simpleTitle: 'Path Mapping',
simpleSubtitle: 'Map Local paths to Cloud paths using simple string matching',
regexPre: 'Pre-Processing (Before Sync)',
regexPost: 'Post-Processing (After Sync / Result)',
localPath: 'Local Path',
cloudPath: 'Cloud Path',
pattern: 'Pattern',
replace: 'Replace',
saveRules: 'Save Rules',
noRules: 'No rules defined.',
},
backup: {
title: 'Backup Retention',
enable: 'Enable Backups',
enableDesc: 'Create a copy before changes',
maxVersions: 'Max versions to keep:',
noAutoDelete: 'No auto-delete',
autoDelete: 'Oldest deleted automatically',
},
schedule: {
title: 'Scheduled Tasks',
cron: 'Cron',
daily: 'Daily',
weekly: 'Weekly',
weekdaysNarrow: {
0: 'S',
1: 'M',
2: 'T',
3: 'W',
4: 'T',
5: 'F',
6: 'S',
},
enableCron: 'Enable Cron Schedule',
enableDaily: 'Enable Daily Run',
enableWeekly: 'Enable Weekly Run',
watchLocal: 'Watch Local Changes',
watchDesc: 'Auto-sync when local playlist updates',
schedule: 'Schedule',
notConfigured: 'Not configured',
today: 'Today',
tomorrow: 'Tomorrow',
},
connection: {
titleConnected: 'Server Connected',
titleConnect: 'Connect Plex Server',
serverDetails: 'Server Details',
authentication: 'Authentication',
protocol: 'Protocol',
address: 'IP Address or Domain',
port: 'Port',
token: 'X-Plex-Token (Optional)',
username: 'Username / Email',
password: 'Password',
advanced: 'Advanced Options',
timeout: 'Connection Timeout (Seconds)',
connectBtn: 'Connect Server',
connecting: 'Connecting...',
connectedSuccess: 'Connected Successfully',
selectLibrary: 'Select Library to Sync',
},
toasts: {
localRefreshCancelled: 'Local refresh cancelled.',
cloudRefreshCancelled: 'Cloud refresh cancelled.',
strategySaved: 'Selected strategy "{strategy}" has been saved.',
strategySaveFailed: 'Failed to save sync strategy.',
mappingSaved: 'Path mapping rules have been saved.',
mappingSaveFailed: 'Failed to save path mapping rules.',
backupSaved: 'Backup settings have been saved.',
backupFailed: 'Failed to save backup settings.',
scheduleDisabled: 'Scheduled tasks disabled.',
scheduleEmpty: 'Scheduled tasks disabled (Empty Cron).',
scheduleStarted: 'Scheduled task updated successfully.',
scheduleFailed: 'Failed to update schedule.',
syncFailed: 'Sync failed. Please check connection.',
backgroundSyncSuccess: 'Background sync completed successfully.',
backgroundSyncFailed: 'Background sync failed: {error}',
librarySwitched: 'Library switched to {library}',
connectedTo: 'Successfully connected to {name}',
connectionCancelled: 'Connection cancelled by user.',
librarySaveFailed: 'Failed to save library selection.',
},
};
+172
View File
@@ -0,0 +1,172 @@
export const es = {
app: {
title: 'PlexSync',
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
auth: {
title: 'Iniciar Sesión',
subtitle: 'Ingrese para gestionar sus sincronizaciones',
username: 'Usuario',
password: 'Password',
loginBtn: 'Entrar',
logout: 'Salir',
loggingIn: 'Verificando...',
invalidCredentials: 'Usuario o contraseña incorrectos',
welcome: 'Bienvenido, {user}',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
revert: 'Revertir',
delete: 'Eliminar',
done: 'Hecho',
loading: 'Cargando...',
refresh: 'Actualizar',
close: 'Cerrar',
none: 'Ninguno',
disabled: 'Deshabilitado',
add: 'Añadir',
switchLanguage: 'Cambiar idioma',
},
server: {
local: 'Servidor Local',
cloud: 'Servidor Nube',
playlists: '{count} Listas',
notConnected: 'No Conectado',
connectionFailed: 'Conexión fallida',
connecting: 'Conectando...',
waiting: 'Esperando...',
syncing: 'Sincronizando...',
noPlaylists: 'No se encontraron listas.',
cancelRefresh: 'Cancelar',
refreshPlaylists: 'Actualizar Listas',
},
playlist: {
trackCount: 'Pistas',
lastUpdated: 'Actualizado',
},
dashboard: {
mapping: 'Mapeo',
backup: 'Respaldo',
autoSync: 'Auto-Sync',
watch: 'Vigilar',
watchModeActive: 'Modo Vigía: Activo',
watchModeDisabled: 'Modo Vigía: Desactivado',
notSet: 'No Def.',
retain: 'Retener: {count}',
keep: 'Guardar {count}',
connected: 'Conectado a Plex',
disconnected: 'Desconectado',
synchronizing: 'SINCRONIZANDO',
syncComplete: 'SINCRONIZACIÓN COMPLETA',
},
strategies: {
title: 'Estrategia de Sync',
localOverwrite: {
label: 'Sobreescribir Local',
desc: 'La lista local sobreescribe la nube. (Sin Diff)',
},
cloudOverwrite: {
label: 'Sobreescribir Nube',
desc: 'La lista de la nube sobreescribe la local. (Sin Diff)',
},
mergeLocal: {
label: 'Fusión (Prioridad Local)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Local.',
},
mergeCloud: {
label: 'Fusión (Prioridad Nube)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Nube.',
},
syncNow: 'Sincronizar Ahora',
syncing: 'Sincronizando...',
saveWarning: 'Guarde los cambios pendientes (Respaldos/Mapeo) antes de sincronizar.',
},
mapping: {
title: 'Mapeo de Rutas',
simple: 'Mapeo Simple',
regex: 'Reglas Regex',
simpleTitle: 'Mapeo de Rutas',
simpleSubtitle: 'Mapear rutas locales a la nube usando coincidencia simple',
regexPre: 'Pre-Procesamiento (Antes de Sync)',
regexPost: 'Post-Procesamiento (Después de Sync)',
localPath: 'Ruta Local',
cloudPath: 'Ruta Nube',
pattern: 'Patrón',
replace: 'Reemplazo',
saveRules: 'Guardar Reglas',
noRules: 'No hay reglas definidas.',
},
backup: {
title: 'Retención de Respaldo',
enable: 'Habilitar Respaldos',
enableDesc: 'Crear copia antes de cambios',
maxVersions: 'Máx versiones a guardar:',
noAutoDelete: 'Sin auto-borrado',
autoDelete: 'El más antiguo se borra automáticamente',
},
schedule: {
title: 'Tareas Programadas',
cron: 'Cron',
daily: 'Diario',
weekly: 'Semanal',
weekdaysNarrow: {
0: 'D',
1: 'L',
2: 'M',
3: 'X',
4: 'J',
5: 'V',
6: 'S',
},
enableCron: 'Habilitar Cron',
enableDaily: 'Habilitar Ejecución Diaria',
enableWeekly: 'Habilitar Ejecución Semanal',
watchLocal: 'Vigilar Cambios Locales',
watchDesc: 'Auto-sync cuando la lista local se actualiza',
schedule: 'Horario',
notConfigured: 'No configurado',
today: 'Hoy',
tomorrow: 'Mañana',
},
connection: {
titleConnected: 'Servidor Conectado',
titleConnect: 'Conectar Servidor Plex',
serverDetails: 'Detalles del Servidor',
authentication: 'Autenticación',
protocol: 'Protocolo',
address: 'Dirección IP o Dominio',
port: 'Puerto',
token: 'X-Plex-Token (Opcional)',
username: 'Usuario / Email',
password: 'Password',
advanced: 'Opciones Avanzadas',
timeout: 'Tiempo de espera (Segundos)',
connectBtn: 'Conectar Servidor',
connecting: 'Conectando...',
connectedSuccess: 'Conectado Exitosamente',
selectLibrary: 'Seleccionar Librería',
},
toasts: {
localRefreshCancelled: 'Actualización local cancelada.',
cloudRefreshCancelled: 'Actualización nube cancelada.',
strategySaved: 'Estrategia seleccionada "{strategy}" guardada.',
strategySaveFailed: 'Error al guardar estrategia de sync.',
mappingSaved: 'Reglas de mapeo guardadas.',
mappingSaveFailed: 'Error al guardar reglas de mapeo.',
backupSaved: 'Configuración de respaldo guardada.',
backupFailed: 'Error al guardar configuración de respaldo.',
scheduleDisabled: 'Tareas programadas deshabilitadas.',
scheduleEmpty: 'Tareas programadas deshabilitadas (Cron Vacío).',
scheduleStarted: 'Tarea programada actualizada exitosamente.',
scheduleFailed: 'Error al actualizar horario.',
syncFailed: 'Fallo en sync. Revise conexión.',
backgroundSyncSuccess: 'Sync en segundo plano completado.',
backgroundSyncFailed: 'Sync en segundo plano falló: {error}',
librarySwitched: 'Librería cambiada a {library}',
connectedTo: 'Conectado exitosamente a {name}',
connectionCancelled: 'Conexión cancelada por usuario.',
librarySaveFailed: 'Error al guardar selección de librería.',
},
};
+172
View File
@@ -0,0 +1,172 @@
export const zh = {
app: {
title: 'PlexSync',
manager: '管理',
footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。',
},
auth: {
title: '登录',
subtitle: '登录后管理播放列表同步',
username: '用户名',
password: '密码',
loginBtn: '登录',
logout: '登出',
loggingIn: '验证中...',
invalidCredentials: '用户名或密码错误',
welcome: '欢迎,{user}',
},
common: {
save: '保存',
cancel: '取消',
revert: '恢复',
delete: '删除',
done: '完成',
loading: '加载中...',
refresh: '刷新',
close: '关闭',
none: '无',
disabled: '已禁用',
add: '添加',
switchLanguage: '切换语言',
},
server: {
local: '本地服务器',
cloud: '云端服务器',
playlists: '{count} 个播放列表',
notConnected: '未连接',
connectionFailed: '连接失败',
connecting: '正在连接...',
waiting: '等待中...',
syncing: '同步中...',
noPlaylists: '未找到播放列表。',
cancelRefresh: '取消刷新',
refreshPlaylists: '刷新播放列表',
},
playlist: {
trackCount: '曲目数',
lastUpdated: '最近更新',
},
dashboard: {
mapping: '路径映射',
backup: '备份',
autoSync: '自动同步',
watch: '监听',
watchModeActive: '监听模式:启用',
watchModeDisabled: '监听模式:禁用',
notSet: '未设置',
retain: '保留:{count}',
keep: '保留 {count}',
connected: '已连接 Plex',
disconnected: '未连接',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: '同步策略',
localOverwrite: {
label: '本地覆盖',
desc: '本地播放列表完全覆盖云端。(无 Diff)',
},
cloudOverwrite: {
label: '云端覆盖',
desc: '云端播放列表完全覆盖本地。(无 Diff)',
},
mergeLocal: {
label: '双向合并(本地优先)',
desc: '合并两端。冲突以本地版本为准。',
},
mergeCloud: {
label: '双向合并(云端优先)',
desc: '合并两端。冲突以云端版本为准。',
},
syncNow: '立即同步',
syncing: '同步进行中...',
saveWarning: '同步前请先保存待处理的更改(备份/路径映射)。',
},
mapping: {
title: '路径映射',
simple: '简单映射',
regex: '正则规则',
simpleTitle: '路径映射',
simpleSubtitle: '使用简单字符串匹配将本地路径映射到云端路径',
regexPre: '预处理(同步前)',
regexPost: '后处理(同步后 / 结果)',
localPath: '本地路径',
cloudPath: '云端路径',
pattern: '模式',
replace: '替换',
saveRules: '保存规则',
noRules: '尚未定义规则。',
},
backup: {
title: '备份保留',
enable: '启用备份',
enableDesc: '在更改前创建副本',
maxVersions: '保留的最大版本数:',
noAutoDelete: '不自动删除',
autoDelete: '自动删除最旧版本',
},
schedule: {
title: '定时任务',
cron: 'Cron',
daily: '每日',
weekly: '每周',
weekdaysNarrow: {
0: '日',
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
},
enableCron: '启用 Cron 计划',
enableDaily: '启用每日运行',
enableWeekly: '启用每周运行',
watchLocal: '监听本地更改',
watchDesc: '本地播放列表更新时自动同步',
schedule: '计划',
notConfigured: '未配置',
today: '今天',
tomorrow: '明天',
},
connection: {
titleConnected: '服务器已连接',
titleConnect: '连接 Plex 服务器',
serverDetails: '服务器详情',
authentication: '认证',
protocol: '协议',
address: 'IP 地址或域名',
port: '端口',
token: 'X-Plex-Token(可选)',
username: '用户名 / 邮箱',
password: '密码',
advanced: '高级选项',
timeout: '连接超时(秒)',
connectBtn: '连接服务器',
connecting: '连接中...',
connectedSuccess: '连接成功',
selectLibrary: '选择要同步的媒体库',
},
toasts: {
localRefreshCancelled: '本地刷新已取消。',
cloudRefreshCancelled: '云端刷新已取消。',
strategySaved: '已保存选择的策略“{strategy}”。',
strategySaveFailed: '保存同步策略失败。',
mappingSaved: '已保存路径映射规则。',
mappingSaveFailed: '保存路径映射规则失败。',
backupSaved: '已保存备份设置。',
backupFailed: '保存备份设置失败。',
scheduleDisabled: '已禁用定时任务。',
scheduleEmpty: '已禁用定时任务(Cron 为空)。',
scheduleStarted: '定时任务更新成功。',
scheduleFailed: '更新定时任务失败。',
syncFailed: '同步失败。请检查连接。',
backgroundSyncSuccess: '后台同步已成功完成。',
backgroundSyncFailed: '后台同步失败:{error}',
librarySwitched: '媒体库已切换为 {library}',
connectedTo: '已成功连接到 {name}',
connectionCancelled: '用户已取消连接。',
librarySaveFailed: '保存媒体库选择失败。',
},
};
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "PlexSync Manager", "name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.", "description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": [] "requestFramePermissions": []
} }
+143 -24
View File
@@ -1,7 +1,24 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy, ScheduleSettings } from '../types'; import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings, BackupSettings, LoginCredentials, AuthResponse } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
const getAuthToken = (): string | null => {
try {
return localStorage.getItem('plexsync-token');
} catch {
return null;
}
};
const authFetch = (input: RequestInfo | URL, init: RequestInit = {}) => {
const token = getAuthToken();
const headers = new Headers(init.headers || {});
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
};
const MODE_TO_STRATEGY: Record<string, SyncStrategy> = { const MODE_TO_STRATEGY: Record<string, SyncStrategy> = {
local_force: SyncStrategy.LOCAL_OVERWRITE, local_force: SyncStrategy.LOCAL_OVERWRITE,
remote_force: SyncStrategy.CLOUD_OVERWRITE, remote_force: SyncStrategy.CLOUD_OVERWRITE,
@@ -20,6 +37,9 @@ const handleResponse = async <T>(response: Response): Promise<ApiResponse<T>> =>
try { try {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
return { data: data as T, status: 'error', message: 'Unauthorized' };
}
return { data: data as T, status: 'error', message: (data as any)?.detail || response.statusText }; return { data: data as T, status: 'error', message: (data as any)?.detail || response.statusText };
} }
return { data, status: 'success' }; return { data, status: 'success' };
@@ -38,24 +58,96 @@ const mapPlaylist = (item: any): Playlist => ({
const mapLibrary = (item: any): PlexLibrary => ({ const mapLibrary = (item: any): PlexLibrary => ({
id: item.id ?? item.title, id: item.id ?? item.title,
title: item.title ?? item.id, title: item.title ?? item.id,
type: item.type ?? 'artist', type: item.type || item.libraryType || item.library_type || item.section?.type || '',
}); });
const mapRegexRules = (rules: any[]): RegexReplacement[] => // Helper function to map raw rules array to ReplacementRule[]
const mapReplacementRules = (rules: any[]): ReplacementRule[] =>
(rules || []).map((rule, index) => ({ (rules || []).map((rule, index) => ({
id: rule.id || `${rule.pattern || 'rule'}-${index}`, id: rule.id || `${rule.search || 'rule'}-${index}-${Date.now()}`,
pattern: rule.pattern || '', search: rule.search || rule.pattern || '',
replacement: rule.replacement || '', replace: rule.replace || rule.replacement || '',
})); }));
// Helper function to map API path_mapping response to PathMappingConfig
const mapPathMappingConfig = (data: any): PathMappingConfig => {
const defaultConfig: PathMappingConfig = {
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
};
if (!data || !data.path_mapping) {
return defaultConfig;
}
const pm = data.path_mapping;
return {
mode: pm.mode === 'REGEX' ? PathMappingMode.REGEX : PathMappingMode.SIMPLE,
simple: mapReplacementRules(pm.simple || []),
regex: {
localPre: mapReplacementRules(pm.regex?.localPre || pm.regex?.local_pre || []),
localPost: mapReplacementRules(pm.regex?.localPost || pm.regex?.local_post || []),
remotePre: mapReplacementRules(pm.regex?.remotePre || pm.regex?.remote_pre || []),
remotePost: mapReplacementRules(pm.regex?.remotePost || pm.regex?.remote_post || [])
}
};
};
// Helper function to convert PathMappingConfig to API format
const pathMappingToApi = (config: PathMappingConfig) => {
const rulesToApi = (rules: ReplacementRule[]) =>
rules.map(({ id, search, replace }) => ({ id, search, replace }));
return {
mode: config.mode,
simple: rulesToApi(config.simple),
regex: {
local_pre: rulesToApi(config.regex.localPre),
local_post: rulesToApi(config.regex.localPost),
remote_pre: rulesToApi(config.regex.remotePre),
remote_post: rulesToApi(config.regex.remotePost)
}
};
};
export const apiService = { export const apiService = {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>> { async getAuthConfig(): Promise<ApiResponse<{ enabled: boolean }>> {
const response = await fetch(`${API_BASE}/api/settings`); const response = await fetch(`${API_BASE}/api/auth/config`);
return handleResponse(response);
},
async login(creds: LoginCredentials): Promise<ApiResponse<AuthResponse>> {
const response = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds),
});
return handleResponse(response);
},
async me(): Promise<ApiResponse<{ username: string }>> {
const response = await authFetch(`${API_BASE}/api/auth/me`);
return handleResponse(response);
},
async logout(): Promise<ApiResponse<{ status: string }>> {
const response = await authFetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
return handleResponse(response);
},
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>> {
const response = await authFetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
const mode = result.data.sync_mode as string; const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE; const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const regex = mapRegexRules(result.data.path_rules || []); const pathMapping = mapPathMappingConfig(result.data);
const connection: PlexConnectionSettings = { const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https', protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '', address: result.data.server_url || '',
@@ -63,14 +155,14 @@ export const apiService = {
token: result.data.token || '', token: result.data.token || '',
libraryName: result.data.library_name || '', libraryName: result.data.library_name || '',
}; };
return { status: 'success', data: { strategy, regex, connection, localPath: result.data.local_path || '' } }; return { status: 'success', data: { strategy, pathMapping, connection, localPath: result.data.local_path || '' } };
} }
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>; return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>;
}, },
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> { async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
const payload = { mode: STRATEGY_TO_MODE[strategy] }; const payload = { mode: STRATEGY_TO_MODE[strategy] };
const response = await fetch(`${API_BASE}/api/settings/sync-mode`, { const response = await authFetch(`${API_BASE}/api/settings/sync-mode`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -78,9 +170,9 @@ export const apiService = {
return handleResponse(response); return handleResponse(response);
}, },
async saveRegexRules(replacements: RegexReplacement[]): Promise<ApiResponse<{ rules: RegexReplacement[] }>> { async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = { rules: replacements.map(({ pattern, replacement }) => ({ pattern, replacement })) }; const payload = pathMappingToApi(config);
const response = await fetch(`${API_BASE}/api/settings/regex-rules`, { const response = await authFetch(`${API_BASE}/api/settings/path-mapping`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -89,7 +181,7 @@ export const apiService = {
}, },
async updateLibrary(libraryName: string): Promise<ApiResponse<{ library_name: string }>> { async updateLibrary(libraryName: string): Promise<ApiResponse<{ library_name: string }>> {
const response = await fetch(`${API_BASE}/api/settings/library`, { const response = await authFetch(`${API_BASE}/api/settings/library`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_name: libraryName }), body: JSON.stringify({ library_name: libraryName }),
@@ -98,12 +190,12 @@ export const apiService = {
}, },
async getScheduleSettings(): Promise<ApiResponse<ScheduleSettings & { nextRun?: string }>> { async getScheduleSettings(): Promise<ApiResponse<ScheduleSettings & { nextRun?: string }>> {
const response = await fetch(`${API_BASE}/api/schedule`); const response = await authFetch(`${API_BASE}/api/schedule`);
return handleResponse(response); return handleResponse(response);
}, },
async saveScheduleSettings(settings: ScheduleSettings): Promise<ApiResponse<null>> { async saveScheduleSettings(settings: ScheduleSettings): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/schedule`, { const response = await authFetch(`${API_BASE}/api/schedule`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify(settings),
@@ -116,7 +208,7 @@ export const apiService = {
if (serverType === ServerType.LOCAL && localPath) { if (serverType === ServerType.LOCAL && localPath) {
params.append('local_path', localPath); params.append('local_path', localPath);
} }
const response = await fetch(`${API_BASE}/api/playlists?${params.toString()}`, { signal }); const response = await authFetch(`${API_BASE}/api/playlists?${params.toString()}`, { signal });
const result = await handleResponse<any>(response); const result = await handleResponse<any>(response);
if (result.status === 'success' && (result.data as any)?.playlists) { if (result.status === 'success' && (result.data as any)?.playlists) {
return { data: (result.data.playlists as any[]).map(mapPlaylist), status: 'success' }; return { data: (result.data.playlists as any[]).map(mapPlaylist), status: 'success' };
@@ -125,7 +217,7 @@ export const apiService = {
}, },
async getServerStatus(signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> { async getServerStatus(signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> {
const response = await fetch(`${API_BASE}/api/server`, { signal }); const response = await authFetch(`${API_BASE}/api/server`, { signal });
const result = await handleResponse<any>(response); const result = await handleResponse<any>(response);
if (result.status === 'success') { if (result.status === 'success') {
const info = result.data.serverInfo || {}; const info = result.data.serverInfo || {};
@@ -146,7 +238,7 @@ export const apiService = {
}, },
async connectToPlex(settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string; serverInfo: PlexServerConnection }>> { async connectToPlex(settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string; serverInfo: PlexServerConnection }>> {
const response = await fetch(`${API_BASE}/api/connect`, { const response = await authFetch(`${API_BASE}/api/connect`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -170,8 +262,8 @@ export const apiService = {
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>; return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
}, },
async syncPlaylists(strategy: SyncStrategy, _regexRules: RegexReplacement[], localPath?: string): Promise<ApiResponse<null>> { async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE}/api/sync`, { const response = await authFetch(`${API_BASE}/api/sync`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -183,7 +275,34 @@ export const apiService = {
}, },
async getSyncStatus(): Promise<ApiResponse<{ is_syncing: boolean; last_sync_time: string | null; status: string; error: string | null }>> { async getSyncStatus(): Promise<ApiResponse<{ is_syncing: boolean; last_sync_time: string | null; status: string; error: string | null }>> {
const response = await fetch(`${API_BASE}/api/sync/status`); const response = await authFetch(`${API_BASE}/api/sync/status`);
return handleResponse(response);
},
async getBackupSettings(): Promise<ApiResponse<BackupSettings>> {
const response = await authFetch(`${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 authFetch(`${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); return handleResponse(response);
}, },
}; };
+14
View File
@@ -0,0 +1,14 @@
import { en } from './locales/en';
import { es } from './locales/es';
import { zh as chs } from './locales/zh';
import { cht } from './locales/cht';
export const translations = {
en,
es,
chs,
cht,
};
export type Language = keyof typeof translations;
export type TranslationStructure = typeof en;
+2 -1
View File
@@ -11,7 +11,8 @@
], ],
"skipLibCheck": true, "skipLibCheck": true,
"types": [ "types": [
"node" "node",
"vite/client"
], ],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"isolatedModules": true, "isolatedModules": true,
+40
View File
@@ -34,6 +34,35 @@ export enum SyncState {
ERROR = 'ERROR' ERROR = 'ERROR'
} }
export interface ReplacementRule {
id: string;
search: string;
replace: string;
}
export interface PathMappingRules {
localPre: ReplacementRule[];
localPost: ReplacementRule[];
remotePre: ReplacementRule[];
remotePost: ReplacementRule[];
}
export enum PathMappingMode {
SIMPLE = 'SIMPLE',
REGEX = 'REGEX'
}
export interface PathMappingConfig {
mode: PathMappingMode;
simple: ReplacementRule[];
regex: PathMappingRules;
}
export interface BackupSettings {
enabled: boolean;
retentionCount: number;
}
export interface RegexReplacement { export interface RegexReplacement {
id: string; id: string;
pattern: string; pattern: string;
@@ -87,3 +116,14 @@ export interface ApiResponse<T> {
status: 'success' | 'error'; status: 'success' | 'error';
message?: string; message?: string;
} }
export interface LoginCredentials {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
username: string;
expires_in?: number;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+227 -74
View File
@@ -1,5 +1,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from './types'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api'; import { apiService } from './services/api';
import { import {
STRIPE_BASE_SPEED, STRIPE_BASE_SPEED,
@@ -15,7 +18,9 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
import ServerPanel from './components/ServerPanel'; import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector'; import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal'; import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2 } from 'lucide-react'; import LoginScreen from './components/LoginScreen';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut, User } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast { interface Toast {
id: number; id: number;
@@ -112,6 +117,13 @@ const useStripeAnimation = (syncState: SyncState) => {
}; };
const App: React.FC = () => { const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
// Auth State
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState('');
// App Data State
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]); const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]); const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined); const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -131,6 +143,7 @@ const App: React.FC = () => {
// Connection Modal State // Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State // Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE); const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
@@ -157,6 +170,12 @@ const App: React.FC = () => {
autoWatch: false autoWatch: false
}); });
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System // Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({}); const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -188,6 +207,16 @@ const App: React.FC = () => {
timeoutsRef.current[id] = dismissTimer; timeoutsRef.current[id] = dismissTimer;
}; };
// Check auth on mount
useEffect(() => {
const savedToken = localStorage.getItem('plexsync-token');
const savedUser = localStorage.getItem('plexsync-username');
if (savedToken && savedUser) {
setIsAuthenticated(true);
setCurrentUser(savedUser);
}
}, []);
// Effect to trigger the "slide down" animation // Effect to trigger the "slide down" animation
useEffect(() => { useEffect(() => {
const enteringIds = toasts.filter(t => t.entering).map(t => t.id); const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
@@ -226,6 +255,7 @@ const App: React.FC = () => {
// Fetch Local Playlists // Fetch Local Playlists
const refreshLocal = useCallback(async () => { const refreshLocal = useCallback(async () => {
if (!isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController(); const abortController = new AbortController();
localAbortRef.current = abortController; localAbortRef.current = abortController;
@@ -237,19 +267,20 @@ const App: React.FC = () => {
} }
setLoadingLocal(false); setLoadingLocal(false);
localAbortRef.current = null; localAbortRef.current = null;
}, []); }, [isAuthenticated]);
const cancelLocalRefresh = () => { const cancelLocalRefresh = () => {
if (localAbortRef.current) { if (localAbortRef.current) {
localAbortRef.current.abort(); localAbortRef.current.abort();
localAbortRef.current = null; localAbortRef.current = null;
setLoadingLocal(false); setLoadingLocal(false);
addToast("Local refresh cancelled."); addToast(t('toasts.localRefreshCancelled'));
} }
}; };
// Fetch Cloud Playlists and Info // Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => { const refreshCloud = useCallback(async () => {
if (!isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController(); const abortController = new AbortController();
cloudAbortRef.current = abortController; cloudAbortRef.current = abortController;
@@ -271,38 +302,51 @@ const App: React.FC = () => {
setLoadingCloud(false); setLoadingCloud(false);
cloudAbortRef.current = null; cloudAbortRef.current = null;
} }
}, []); }, [isAuthenticated]);
const cancelCloudRefresh = () => { const cancelCloudRefresh = () => {
if (cloudAbortRef.current) { if (cloudAbortRef.current) {
cloudAbortRef.current.abort(); cloudAbortRef.current.abort();
cloudAbortRef.current = null; cloudAbortRef.current = null;
setLoadingCloud(false); setLoadingCloud(false);
addToast("Cloud refresh cancelled."); addToast(t('toasts.cloudRefreshCancelled'));
} }
}; };
// Initial Load // Initial Load (Only if Authenticated)
useEffect(() => { useEffect(() => {
refreshLocal(); if (isAuthenticated) {
refreshCloud(); refreshLocal();
refreshCloud();
}
return () => { return () => {
// Cleanup on unmount // Cleanup on unmount
if (localAbortRef.current) localAbortRef.current.abort(); if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort();
} }
}, [refreshLocal, refreshCloud]); }, [isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change // Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => { const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy); setCurrentStrategy(strategy);
addToast(`Selected strategy "${label}" has been saved.`); addToast(t('toasts.strategySaved', { strategy: label }));
}; };
// Handle Path Mapping Save // Handle Path Mapping Save
const handleSavePathMapping = (config: PathMappingConfig) => { const handleSavePathMapping = (config: PathMappingConfig) => {
setPathMappingConfig(config); setPathMappingConfig(config);
addToast('Path mapping rules have been saved.'); addToast(t('toasts.mappingSaved'));
};
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast(t('toasts.backupSaved'));
} else {
addToast(t('toasts.backupFailed'));
}
}; };
// Handle Schedule Save // Handle Schedule Save
@@ -315,15 +359,15 @@ const App: React.FC = () => {
setScheduleSettings(settings); setScheduleSettings(settings);
if (settings.mode === ScheduleMode.DISABLED) { if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled."); addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') { } else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast("Scheduled tasks disabled (Empty Cron)."); addToast(t('toasts.scheduleEmpty'));
} else { } else {
addToast("Scheduled task started successfully."); addToast(t('toasts.scheduleStarted'));
} }
return true; return true;
} else { } else {
addToast(result.message || "Failed to update schedule."); addToast(result.message || t('toasts.scheduleFailed'));
return false; return false;
} }
}; };
@@ -343,13 +387,6 @@ const App: React.FC = () => {
// Timing Breakdown: // Timing Breakdown:
// T+0.0s: State is SUCCESS. // T+0.0s: State is SUCCESS.
// - JS Animation loop detects change and begins decelerating speed from 56 -> 0 over 0.5s.
// - CSS opacity transitions Yellow -> Green over 0.3s.
// T+0.5s: Deceleration complete. Speed is 0. Background is static.
// We hold this static state for another 0.5s.
// T+1.0s: Total success duration complete. Disappear.
setTimeout(() => { setTimeout(() => {
setSyncState(SyncState.IDLE); setSyncState(SyncState.IDLE);
refreshLocal(); refreshLocal();
@@ -358,7 +395,7 @@ const App: React.FC = () => {
} else { } else {
setSyncState(SyncState.ERROR); setSyncState(SyncState.ERROR);
addToast("Sync failed. Please check connection."); addToast(t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
} }
}; };
@@ -369,6 +406,27 @@ const App: React.FC = () => {
refreshCloud(); refreshCloud();
}; };
const handleLoginSuccess = (token: string, username: string) => {
localStorage.setItem('plexsync-token', token);
localStorage.setItem('plexsync-username', username);
setIsAuthenticated(true);
setCurrentUser(username);
addToast(t('auth.welcome', { user: username }));
};
const handleLoginError = (msg: string) => {
// Toast handles error display, or LoginScreen internal state handles UI
addToast(msg);
};
const handleLogout = () => {
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
addToast(t('toasts.loggedOut'));
};
const getToastStyles = (toast: Toast): React.CSSProperties => { const getToastStyles = (toast: Toast): React.CSSProperties => {
if (toast.exiting || toast.entering) { if (toast.exiting || toast.entering) {
return { return {
@@ -391,21 +449,21 @@ const App: React.FC = () => {
// Helper: Calculate Next Run Info // Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => { const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = { const result = {
label: 'Schedule', label: t('schedule.schedule'),
value: 'Not configured', value: t('schedule.notConfigured'),
active: false, active: false,
autoWatch: settings.autoWatch autoWatch: settings.autoWatch
}; };
if (settings.mode === ScheduleMode.DISABLED) { if (settings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync'; result.label = t('dashboard.autoSync');
result.value = 'Disabled'; result.value = t('common.disabled');
return result; return result;
} }
if (settings.mode === ScheduleMode.CRON) { if (settings.mode === ScheduleMode.CRON) {
result.label = 'Cron Schedule'; result.label = t('schedule.cron');
result.value = settings.cronExpression || 'Pending...'; result.value = settings.cronExpression || t('server.waiting');
result.active = true; result.active = true;
return result; return result;
} }
@@ -435,8 +493,8 @@ const App: React.FC = () => {
const activeDays = [...settings.weeklyDays].sort(); const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) { if (activeDays.length === 0) {
result.label = 'Weekly Schedule'; result.label = t('schedule.weekly');
result.value = 'No days selected'; result.value = t('common.none');
return result; return result;
} }
@@ -470,12 +528,12 @@ const App: React.FC = () => {
const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate(); const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate();
let dateStr = ''; let dateStr = '';
if (isToday) dateStr = 'Today'; if (isToday) dateStr = t('schedule.today');
else if (isTomorrow) dateStr = 'Tomorrow'; else if (isTomorrow) dateStr = t('schedule.tomorrow');
else dateStr = days[nextRun.getDay()]; else dateStr = days[nextRun.getDay()];
result.label = `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`; result.label = settings.mode === ScheduleMode.DAILY ? t('schedule.daily') : t('schedule.weekly');
result.value = `${dateStr} at ${timeStr}`; result.value = `${dateStr} @ ${timeStr}`;
result.active = true; result.active = true;
return result; return result;
} }
@@ -492,6 +550,7 @@ const App: React.FC = () => {
let Icon = Type; let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) { if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = t('common.none').replace('None', 'Simple'); // Fallback hack if simple not in dict, but it is in mapping
modeLabel = 'Simple'; modeLabel = 'Simple';
count = config.simple.length; count = config.simple.length;
Icon = Type; Icon = Type;
@@ -506,15 +565,15 @@ const App: React.FC = () => {
if (count === 0) { if (count === 0) {
return { return {
label: 'Path Mapping', label: t('dashboard.mapping'),
value: 'Not Set', value: t('dashboard.notSet'),
active: false, active: false,
Icon: Icon Icon: Icon
}; };
} }
return { return {
label: 'Path Mapping', label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`, value: `${modeLabel} (${count})`,
active: true, active: true,
Icon: Icon Icon: Icon
@@ -523,6 +582,52 @@ const App: React.FC = () => {
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig); const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false
};
}
return {
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true
};
};
const backupInfo = getBackupDisplayInfo(backupSettings);
// If not authenticated, show Login Screen
if (!isAuthenticated) {
return (
<>
{/* Render toasts over login screen if needed (e.g. login errors pushed to global toast) */}
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={getToastClasses()}
style={getToastStyles(toast)}
>
<ShieldCheck size={16} />
<span>{toast.message}</span>
<button
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
className="ml-2 hover:text-white transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
<LoginScreen onLoginSuccess={handleLoginSuccess} onLoginError={handleLoginError} />
</>
);
}
return ( return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black"> <div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -594,45 +699,82 @@ const App: React.FC = () => {
<ArrowLeftRight size={24} strokeWidth={2.5} /> <ArrowLeftRight size={24} strokeWidth={2.5} />
</div> </div>
<h1 className="text-xl font-bold tracking-tight text-white"> <h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span> <span className="text-plex-orange">PMS</span> Playlist Sync
</h1> </h1>
</div> </div>
{/* Normal Toolbar Right */} {/* Normal Toolbar Right */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Path Mapping Info */}
<div className="flex flex-col items-end hidden md:flex border-r border-gray-700/50 pr-4 mr-1"> {/* Unified Status Dock */}
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider"> <div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
{pathMappingInfo.label}
</span> {/* Path Mapping Section */}
<div className={`text-xs font-mono flex items-center gap-1.5 ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}> <div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
{pathMappingInfo.active && <pathMappingInfo.Icon size={12} />} <span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{pathMappingInfo.label}</span>
<span>{pathMappingInfo.value}</span> <div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
</div> <pathMappingInfo.Icon size={12} strokeWidth={2.5} />
<span className="truncate">{pathMappingInfo.value}</span>
</div>
</div>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{backupInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
<Archive size={12} strokeWidth={2.5} />
<span>{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
</div>
</div>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 min-w-[140px] group/item">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{t('dashboard.autoSync')}</span>
{/* Watch Indicator Badge */}
<div
className={`flex items-center gap-1 px-1 rounded-[2px] transition-colors ${scheduleInfo.autoWatch ? 'text-plex-orange bg-plex-orange/10' : 'text-gray-700 bg-gray-800'}`}
title={scheduleInfo.autoWatch ? t('dashboard.watchModeActive') : t('dashboard.watchModeDisabled')}
>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">{t('dashboard.watch')}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
<Clock size={12} strokeWidth={2.5} />
<span className="truncate max-w-[120px]">{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}</span>
</div>
</div>
</div> </div>
{/* Schedule Info */} {/* Language Switcher */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex"> <div className="relative">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider"> <button
{scheduleInfo.label} onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
</span> className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
<div className="text-xs font-mono flex items-center gap-1.5"> title="Switch Language"
{/* Schedule Part */} >
<div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}> <Languages size={18} />
{scheduleInfo.active && <Clock size={12} />} </button>
<span>{scheduleInfo.value}</span> {isLangMenuOpen && (
</div> <>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
{/* Watch Part */} <div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<span className="text-gray-700 mx-0.5">|</span> <button
<div onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`} className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"} >
> English
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />} </button>
<span className="text-[10px] font-sans font-bold">WATCH</span> <button
</div> onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
</div> className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div> </div>
{/* Connection Status Button */} {/* Connection Status Button */}
@@ -643,10 +785,19 @@ const App: React.FC = () => {
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20" ? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20" : "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`} }`}
title={isConnected ? "Connected to Plex" : "Disconnected"} title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
> >
{isConnected ? <Server size={18} /> : <ServerOff size={18} />} {isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button> </button>
{/* Logout Button */}
<button
onClick={handleLogout}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-red-400 hover:border-red-500/30 hover:bg-red-500/10 transition-all"
title={t('auth.logout') + ` (${currentUser})`}
>
<LogOut size={18} />
</button>
</div> </div>
</> </>
) : ( ) : (
@@ -660,7 +811,7 @@ const App: React.FC = () => {
}} }}
> >
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}> <h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'} {syncState === SyncState.SYNCING ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1> </h1>
</div> </div>
</div> </div>
@@ -719,6 +870,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}
@@ -743,7 +896,7 @@ const App: React.FC = () => {
{/* Footer */} {/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur"> <footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p> <p>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer> </footer>
{/* Modals */} {/* Modals */}
+64
View File
@@ -0,0 +1,64 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { translations, Language, TranslationStructure } from './translations';
interface LanguageContextProps {
language: Language;
setLanguage: (lang: Language) => void;
t: (path: string, params?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextProps | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguageState] = useState<Language>('en');
useEffect(() => {
const savedLang = localStorage.getItem('plexsync-language') as Language;
if (savedLang && translations[savedLang]) {
setLanguageState(savedLang);
}
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('plexsync-language', lang);
};
const t = (path: string, params?: Record<string, string | number>): string => {
const keys = path.split('.');
let current: any = translations[language];
for (const key of keys) {
if (current[key] === undefined) {
console.warn(`Missing translation for key: ${path} in language: ${language}`);
return path;
}
current = current[key];
}
let text = current as string;
if (params) {
Object.entries(params).forEach(([key, value]) => {
text = text.replace(`{${key}}`, String(value));
});
}
return text;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+29 -21
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types'; import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react'; import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps { interface ConnectionModalProps {
isOpen: boolean; isOpen: boolean;
@@ -12,6 +13,7 @@ interface ConnectionModalProps {
} }
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => { const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({ const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http', protocol: 'http',
address: '', address: '',
@@ -71,7 +73,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title }; const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
setConnectedServerInfo(updatedInfo); setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo); onConnectSuccess(updatedInfo);
onShowMessage(`Library switched to ${lib.title}`); onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
} }
}; };
@@ -90,7 +92,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
abortControllerRef.current.abort(); abortControllerRef.current.abort();
abortControllerRef.current = null; abortControllerRef.current = null;
setIsConnecting(false); setIsConnecting(false);
setError("Connection cancelled by user."); setError(t('toasts.connectionCancelled'));
} }
return; return;
} }
@@ -119,7 +121,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const info = result.data.serverInfo; const info = result.data.serverInfo;
setConnectedServerInfo(info); setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`); onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || []; const libs = info.libraries || [];
setLibraries(libs); setLibraries(libs);
@@ -134,21 +136,27 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(info); onConnectSuccess(info);
} }
} else { } else {
setError(result.message || "Connection failed"); setError(result.message || t('server.connectionFailed'));
} }
}; };
const isConnected = !!connectedServerInfo; const isConnected = !!connectedServerInfo;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <div
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"> className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none"> <div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} /> <Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'} {isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3> </h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors"> <button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} /> <X size={20} />
@@ -167,7 +175,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Server Connection */} {/* Server Connection */}
<div className="space-y-3"> <div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label> <label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<div className="col-span-1"> <div className="col-span-1">
<select <select
@@ -191,7 +199,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
name="address" name="address"
required required
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="IP Address or Domain" placeholder={t('connection.address')}
value={formData.address} value={formData.address}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`} className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -205,7 +213,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="port" name="port"
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)" placeholder={t('connection.port')}
value={formData.port} value={formData.port}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`} className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -217,7 +225,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Authentication */} {/* Authentication */}
<div className="space-y-3"> <div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label> <label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */} {/* Token */}
<div className="relative"> <div className="relative">
@@ -228,7 +236,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="token" name="token"
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)" placeholder={t('connection.token')}
value={formData.token} value={formData.token}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`} className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -250,7 +258,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="username" name="username"
disabled={isTokenProvided || isConnecting} disabled={isTokenProvided || isConnecting}
placeholder="Username / Email" placeholder={t('connection.username')}
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`} className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -266,7 +274,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
name="password" name="password"
disabled={isTokenProvided || isConnecting} disabled={isTokenProvided || isConnecting}
placeholder="Password" placeholder={t('connection.password')}
value={formData.password} value={formData.password}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`} className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -294,7 +302,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings size={14} /> <Settings size={14} />
<span>Advanced Options</span> <span>{t('connection.advanced')}</span>
</div> </div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button> </button>
@@ -302,7 +310,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{showAdvanced && ( {showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2"> <div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div> <div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label> <label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input <input
type="number" type="number"
min="1" min="1"
@@ -331,15 +339,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{isConnecting ? ( {isConnecting ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span> <span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</> </>
) : 'Connect Server'} ) : t('connection.connectBtn')}
</button> </button>
) : ( ) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center"> <div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2"> <p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} /> <CheckCircle size={16} />
Connected Successfully {t('connection.connectedSuccess')}
</p> </p>
</div> </div>
)} )}
@@ -348,7 +356,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Library Selection - Appears after connection */} {/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && ( {isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in"> <div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label> <label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" /> <Library size={14} className="text-plex-orange" />
@@ -372,7 +380,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onClick={onClose} onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500" className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
> >
Done {t('common.done')}
</button> </button>
</div> </div>
</div> </div>
+170
View File
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { useLanguage } from '../LanguageContext';
import { apiService } from '../services/api';
import { Lock, User, Loader2, Languages, ArrowRight, ArrowLeftRight } from 'lucide-react';
interface LoginScreenProps {
onLoginSuccess: (token: string, username: string) => void;
onLoginError: (msg: string) => void;
}
const LoginScreen: React.FC<LoginScreenProps> = ({ onLoginSuccess, onLoginError }) => {
const { t, language, setLanguage } = useLanguage();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLocalError(null);
try {
// Mock credentials: admin / password
const response = await apiService.login({ username, password });
if (response.status === 'success') {
onLoginSuccess(response.data.token, response.data.username);
} else {
const errorMsg = response.message || t('auth.invalidCredentials');
setLocalError(errorMsg);
onLoginError(errorMsg);
}
} catch (err) {
setLocalError(t('auth.invalidCredentials'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black p-4">
{/* Background decoration */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-plex-orange/10 rounded-full blur-[100px] opacity-20"></div>
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[100px] opacity-20"></div>
</div>
{/* Language Switcher (Top Right) */}
<div className="absolute top-6 right-6 z-20">
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center space-x-2 px-3 py-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 border border-gray-700 hover:border-gray-600 text-gray-300 transition-all backdrop-blur-sm"
>
<Languages size={16} />
<span className="text-sm font-medium">{language === 'en' ? 'English' : 'Español'}</span>
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 overflow-hidden animate-in fade-in slide-in-from-top-2">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
</div>
{/* Main Card */}
<div className="w-full max-w-md bg-gray-900/60 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-2xl p-8 z-10 animate-in zoom-in-95 duration-300">
<div className="text-center mb-8 flex flex-col items-center">
<div className="inline-flex items-center justify-center p-3 rounded-xl bg-gradient-to-br from-plex-orange to-yellow-600 shadow-lg shadow-plex-orange/20 mb-4">
<ArrowLeftRight size={32} strokeWidth={2.5} className="text-gray-900" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
<form onSubmit={handleLogin} className="space-y-4">
{localError && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center justify-center">
{localError}
</div>
)}
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.username')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="admin"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.password')}</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
placeholder="password"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || !username || !password}
className={`w-full h-12 mt-6 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLoading
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
: 'bg-plex-orange text-gray-900 hover:bg-yellow-500 hover:shadow-plex-orange/30 active:scale-[0.98]'
}`}
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
<span>{t('auth.loggingIn')}</span>
</>
) : (
<>
<span>{t('auth.loginBtn')}</span>
<ArrowRight size={18} />
</>
)}
</button>
</form>
<div className="mt-8 pt-6 border-t border-gray-700/50 text-center">
<p className="text-[10px] text-gray-600">
© PMS Playlist Sync
</p>
</div>
</div>
</div>
);
};
export default LoginScreen;
+5 -2
View File
@@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import { Playlist } from '../types'; import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react'; import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface PlaylistCardProps { interface PlaylistCardProps {
playlist: Playlist; playlist: Playlist;
} }
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => { const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return ( return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm"> <div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -16,11 +19,11 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
</div> </div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400"> <div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count"> <span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" /> <Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount} {playlist.trackCount}
</span> </span>
<span className="flex items-center" title="Last Updated"> <span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" /> <Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()} {new Date(playlist.lastUpdated).toLocaleDateString()}
</span> </span>
+12 -10
View File
@@ -3,6 +3,7 @@ import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types'; import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard'; import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react'; import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ServerPanelProps { interface ServerPanelProps {
type: ServerType; type: ServerType;
@@ -14,6 +15,7 @@ interface ServerPanelProps {
} }
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => { const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL; const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud; let Icon = isLocal ? Server : Cloud;
@@ -28,17 +30,17 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
let displaySubtitle: React.ReactNode = null; let displaySubtitle: React.ReactNode = null;
if (isLocal) { if (isLocal) {
displayTitle = 'Local Server'; displayTitle = t('server.local');
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0"> <p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists {t('server.playlists', { count: playlists.length })}
</p> </p>
); );
} else { } else {
// Cloud Logic // Cloud Logic
if (serverInfo) { if (serverInfo) {
if (serverInfo.isConnected) { if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server'; displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = ( displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0"> <div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span> <span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
@@ -47,20 +49,20 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
</div> </div>
); );
} else { } else {
displayTitle = 'Not Connected'; displayTitle = t('server.notConnected');
Icon = WifiOff; Icon = WifiOff;
headerColor = 'text-red-400'; headerColor = 'text-red-400';
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5"> <p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed {t('server.connectionFailed')}
</p> </p>
); );
} }
} else { } else {
displayTitle = 'Cloud Server'; displayTitle = t('server.cloud');
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5"> <p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'} {isLoading ? t('server.connecting') : t('server.waiting')}
</p> </p>
); );
} }
@@ -121,7 +123,7 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
: 'text-gray-400 hover:text-white hover:bg-white/10' : 'text-gray-400 hover:text-white hover:bg-white/10'
} }
`} `}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"} title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
> >
{isLoading ? ( {isLoading ? (
<div className="relative flex items-center justify-center"> <div className="relative flex items-center justify-center">
@@ -141,11 +143,11 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
{isLoading && playlists.length === 0 ? ( {isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3"> <div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" /> <RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p> <p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div> </div>
) : playlists.length === 0 ? ( ) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500"> <div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p> <p className="text-sm">{t('server.noPlaylists')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2.5 md:space-y-3"> <div className="space-y-2.5 md:space-y-3">
+236 -105
View File
@@ -1,5 +1,6 @@
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,16 +17,19 @@ import {
Calendar, Calendar,
Clock, Clock,
Repeat, Repeat,
CheckSquare,
Square,
Type, Type,
Code2 Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react'; } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface StrategyOption { interface StrategyOption {
value: SyncStrategy; value: SyncStrategy;
label: string; labelKey: string;
description: string; descKey: string;
icon: React.ElementType; icon: React.ElementType;
color: string; color: string;
} }
@@ -33,29 +37,29 @@ interface StrategyOption {
const STRATEGIES: StrategyOption[] = [ const STRATEGIES: StrategyOption[] = [
{ {
value: SyncStrategy.LOCAL_OVERWRITE, value: SyncStrategy.LOCAL_OVERWRITE,
label: 'Local Overwrite', labelKey: 'strategies.localOverwrite.label',
description: 'Local playlist completely overwrites Cloud. (No Diff)', descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle, icon: ArrowRightCircle,
color: 'text-blue-400' color: 'text-blue-400'
}, },
{ {
value: SyncStrategy.CLOUD_OVERWRITE, value: SyncStrategy.CLOUD_OVERWRITE,
label: 'Cloud Overwrite', labelKey: 'strategies.cloudOverwrite.label',
description: 'Cloud playlist completely overwrites Local. (No Diff)', descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle, icon: ArrowLeftCircle,
color: 'text-green-400' color: 'text-green-400'
}, },
{ {
value: SyncStrategy.MERGE_LOCAL, value: SyncStrategy.MERGE_LOCAL,
label: 'Two-way Merge (Local Priority)', labelKey: 'strategies.mergeLocal.label',
description: 'Merge both. Conflicts resolve to Local version.', descKey: 'strategies.mergeLocal.desc',
icon: GitMerge, icon: GitMerge,
color: 'text-blue-300' color: 'text-blue-300'
}, },
{ {
value: SyncStrategy.MERGE_CLOUD, value: SyncStrategy.MERGE_CLOUD,
label: 'Two-way Merge (Cloud Priority)', labelKey: 'strategies.mergeCloud.label',
description: 'Merge both. Conflicts resolve to Cloud version.', descKey: 'strategies.mergeCloud.desc',
icon: GitMerge, icon: GitMerge,
color: 'text-green-300' color: 'text-green-300'
} }
@@ -90,17 +94,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;
}; };
@@ -119,6 +118,7 @@ interface MappingGroupEditorProps {
rightPlaceholder?: string; rightPlaceholder?: string;
leftInputClass?: string; leftInputClass?: string;
rightInputClass?: string; rightInputClass?: string;
t: (key: string) => string;
} }
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
@@ -129,10 +129,11 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
isLocked, isLocked,
borderColor = "border-gray-700", borderColor = "border-gray-700",
bgColor = "bg-gray-900/50", bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern", leftPlaceholder,
rightPlaceholder = "Replace", rightPlaceholder,
leftInputClass, leftInputClass,
rightInputClass rightInputClass,
t
}) => { }) => {
const handleAdd = () => { const handleAdd = () => {
@@ -165,7 +166,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
onClick={handleAdd} onClick={handleAdd}
disabled={isLocked} disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors" className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule" title={t('common.add')}
> >
<Plus size={12} /> <Plus size={12} />
</button> </button>
@@ -174,22 +175,22 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1"> <div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? ( {rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg"> <div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No rules defined. {t('mapping.noRules')}
</div> </div>
) : ( ) : (
rules.map((rule) => ( rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200"> <div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input <input
type="text" type="text"
placeholder={leftPlaceholder} placeholder={leftPlaceholder || t('mapping.pattern')}
value={rule.search} value={rule.search}
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 || t('mapping.replace')}
value={rule.replace} value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)} onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`} className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
@@ -213,6 +214,8 @@ interface StrategySelectorProps {
onSelect: (strategy: SyncStrategy, label: string) => void; onSelect: (strategy: SyncStrategy, label: string) => void;
savedPathMapping: PathMappingConfig; savedPathMapping: PathMappingConfig;
onSavePathMapping: (config: PathMappingConfig) => void; onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings; savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>; onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState; syncState: SyncState;
@@ -224,11 +227,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onSelect, onSelect,
savedPathMapping, savedPathMapping,
onSavePathMapping, onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule, savedSchedule,
onSaveSchedule, onSaveSchedule,
syncState, syncState,
onSync onSync
}) => { }) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -236,6 +242,10 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping); const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isMappingDirty, setIsMappingDirty] = useState(false); const [isMappingDirty, setIsMappingDirty] = useState(false);
// Local state for Backup Settings
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
const [isBackupDirty, setIsBackupDirty] = useState(false);
// Local state for Schedule editing // Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule); const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false); const [isScheduleDirty, setIsScheduleDirty] = useState(false);
@@ -254,6 +264,11 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
setIsMappingDirty(false); setIsMappingDirty(false);
}, [savedPathMapping]); }, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => { useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule))); setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) { if (savedSchedule.mode !== ScheduleMode.DISABLED) {
@@ -268,6 +283,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);
@@ -291,7 +312,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const handleSelect = (strategy: StrategyOption) => { const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return; if (isLocked) return;
onSelect(strategy.value, strategy.label); onSelect(strategy.value, t(strategy.labelKey));
}; };
// --- Path Mapping Handlers --- // --- Path Mapping Handlers ---
@@ -351,6 +372,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;
@@ -407,7 +444,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95" className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`} title={`Current Strategy: ${t(selectedOption.labelKey)}`}
> >
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} /> <selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm"> <div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
@@ -433,7 +470,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1: Sync Strategy */} {/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none"> <div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1"> <div className="space-y-1">
{STRATEGIES.map((strategy) => ( {STRATEGIES.map((strategy) => (
<div <div
@@ -448,7 +485,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center space-x-3 overflow-hidden"> <div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} /> <strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}> <span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label} {t(strategy.labelKey)}
</span> </span>
</div> </div>
@@ -456,7 +493,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="relative group/tooltip"> <div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" /> <HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50"> <div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description} {t(strategy.descKey)}
</div> </div>
</div> </div>
@@ -469,17 +506,91 @@ 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">{t('backup.title')}</h3>
</div>
<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400">
<Archive size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('backup.enable')}</span>
<span className="text-[10px] text-gray-500">{t('backup.enableDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateBackup('enabled', !localBackup.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localBackup.enabled ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localBackup.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Expanded Config */}
<div className={`overflow-hidden transition-all duration-300 ${localBackup.enabled ? 'max-h-20 opacity-100' : 'max-h-0 opacity-50'}`}>
<div className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
<div className="flex items-center space-x-2">
<History size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">{t('backup.maxVersions')}</span>
</div>
<div className="flex items-center space-x-2">
<input
type="number"
min="1"
max="100"
value={localBackup.retentionCount}
onChange={(e) => handleUpdateBackup('retentionCount', parseInt(e.target.value) || 1)}
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
/>
<span className="text-[10px] text-gray-600 italic">{t('backup.autoDelete')}</span>
</div>
</div>
</div>
<div className="flex justify-end items-center gap-2 pt-1">
<button
onClick={handleResetBackup}
disabled={!isBackupDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isBackupDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveBackupClick}
disabled={!isBackupDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isBackupDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>{t('common.save')}</span>
</button>
</div>
</div>
</div>
{/* Section 2: Path Mapping (Tabs + Grid) */} {/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Path Mapping</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('mapping.title')}</h3>
</div> </div>
{/* Tabs for Path Mapping Mode */} {/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4"> <div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[ {[
{ id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type }, { id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 }, { id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -502,17 +613,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Simple Mode: Single Editor // Simple Mode: Single Editor
<div className="animate-in fade-in duration-200"> <div className="animate-in fade-in duration-200">
<MappingGroupEditor <MappingGroupEditor
title="Path Mapping" title={t('mapping.simpleTitle')}
subtitle="Map Local paths to Cloud paths using simple string matching" subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules} rules={simpleRules}
onChange={updateSimpleGroup} onChange={updateSimpleGroup}
isLocked={isLocked} isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor} borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor} bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path" leftPlaceholder={t('mapping.localPath')}
rightPlaceholder="Cloud Path" rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local} leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud} rightInputClass={MAPPING_THEME.inputs.cloud}
t={t}
/> />
</div> </div>
) : ( ) : (
@@ -520,44 +632,48 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */} {/* Row 1: Pre-Processing */}
<MappingGroupEditor <MappingGroupEditor
title="Local Playlist" title={t('server.local')}
subtitle="Pre-Processing (Before Sync)" subtitle={t('mapping.regexPre')}
rules={regexRules.localPre} rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)} onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked} isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor} borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor} bgColor={MAPPING_THEME.local.bgColor}
t={t}
/> />
<MappingGroupEditor <MappingGroupEditor
title="Remote Playlist" title={t('server.cloud')}
subtitle="Pre-Processing (Before Sync)" subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre} rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)} onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked} isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor} borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor} bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/> />
{/* Row 2: Post-Processing */} {/* Row 2: Post-Processing */}
<MappingGroupEditor <MappingGroupEditor
title="Local Playlist" title={t('server.local')}
subtitle="Post-Processing (After Sync / Result)" subtitle={t('mapping.regexPost')}
rules={regexRules.localPost} rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)} onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked} isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor} borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor} bgColor={MAPPING_THEME.local.bgColor}
t={t}
/> />
<MappingGroupEditor <MappingGroupEditor
title="Remote Playlist" title={t('server.cloud')}
subtitle="Post-Processing (After Sync / Result)" subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost} rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)} onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked} isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor} borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor} bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/> />
</div> </div>
)} )}
@@ -573,7 +689,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`} : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
> >
<RotateCcw size={12} /> <RotateCcw size={12} />
<span>Revert</span> <span>{t('common.revert')}</span>
</button> </button>
<button <button
onClick={handleSaveMappingClick} onClick={handleSaveMappingClick}
@@ -584,7 +700,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`} : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
> >
<Save size={12} /> <Save size={12} />
<span>Save Rules</span> <span>{t('mapping.saveRules')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -592,15 +708,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 3: Scheduled Tasks */} {/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3> <h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4"> <div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[ {[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat }, { id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock }, { id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar }, { id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -620,35 +736,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">{t('schedule.enableCron')}</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">{t('schedule.enableDaily')}</span>
<button <button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)} onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.DAILY ? 'bg-plex-orange' : 'bg-gray-700'}`}
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
> >
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />} <span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.DAILY ? 'translate-x-6' : 'translate-x-1'}`} />
</button> </button>
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
</div> </div>
{/* Bottom Row: Centered Native Time Input */} {/* Bottom Row: Centered Native Time Input */}
@@ -666,16 +796,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">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)} onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.WEEKLY ? 'bg-plex-orange' : 'bg-gray-700'}`}
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"} >
> <span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.WEEKLY ? 'translate-x-6' : 'translate-x-1'}`} />
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />} </button>
</button>
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
</div> </div>
{/* Middle Row: Full Width Capsules */} {/* Middle Row: Full Width Capsules */}
@@ -714,20 +843,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">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</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>
@@ -742,7 +873,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`} : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
> >
<RotateCcw size={12} /> <RotateCcw size={12} />
<span>Revert</span> <span>{t('common.revert')}</span>
</button> </button>
<button <button
onClick={handleSaveScheduleClick} onClick={handleSaveScheduleClick}
@@ -753,7 +884,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`} : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
> >
<Save size={12} /> <Save size={12} />
<span>Save</span> <span>{t('common.save')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -767,7 +898,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg 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]'
}`} }`}
@@ -775,18 +906,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{isSyncing ? ( {isSyncing ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span> <span>{t('strategies.syncing')}</span>
</> </>
) : ( ) : (
<> <>
<Zap size={16} fill="currentColor" /> <Zap size={16} fill="currentColor" />
<span>Sync Now</span> <span>{t('strategies.syncNow')}</span>
</> </>
)} )}
</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. {t('strategies.saveWarning')}
</p> </p>
)} )}
</div> </div>
+2 -1
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title> <title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
+5 -1
View File
@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
@@ -10,6 +12,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode> </React.StrictMode>
); );
+162
View File
@@ -0,0 +1,162 @@
export const en = {
app: {
// title and manager are no longer used for branding
title: 'PlexSync',
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
auth: {
title: 'Login',
subtitle: 'Sign in to manage your playlist syncs',
username: 'Username',
password: 'Password',
loginBtn: 'Sign In',
logout: 'Logout',
loggingIn: 'Verifying...',
invalidCredentials: 'Invalid username or password',
welcome: 'Welcome, {user}',
},
common: {
save: 'Save',
cancel: 'Cancel',
revert: 'Revert',
delete: 'Delete',
done: 'Done',
loading: 'Loading...',
refresh: 'Refresh',
close: 'Close',
none: 'None',
disabled: 'Disabled',
add: 'Add',
},
server: {
local: 'Local Server',
cloud: 'Cloud Server',
playlists: '{count} Playlists',
notConnected: 'Not Connected',
connectionFailed: 'Connection failed',
connecting: 'Connecting...',
waiting: 'Waiting...',
syncing: 'Syncing...',
noPlaylists: 'No playlists found.',
cancelRefresh: 'Cancel Refresh',
refreshPlaylists: 'Refresh Playlists',
},
playlist: {
trackCount: 'Track Count',
lastUpdated: 'Last Updated',
},
dashboard: {
mapping: 'Mapping',
backup: 'Backup',
autoSync: 'Auto-Sync',
watch: 'Watch',
watchModeActive: 'Watch Mode: Active',
watchModeDisabled: 'Watch Mode: Disabled',
notSet: 'Not Set',
retain: 'Retain: {count}',
keep: 'Keep {count}',
connected: 'Connected to Plex',
disconnected: 'Disconnected',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: 'Sync Strategy',
localOverwrite: {
label: 'Local Overwrite',
desc: 'Local playlist completely overwrites Cloud. (No Diff)',
},
cloudOverwrite: {
label: 'Cloud Overwrite',
desc: 'Cloud playlist completely overwrites Local. (No Diff)',
},
mergeLocal: {
label: 'Two-way Merge (Local Priority)',
desc: 'Merge both. Conflicts resolve to Local version.',
},
mergeCloud: {
label: 'Two-way Merge (Cloud Priority)',
desc: 'Merge both. Conflicts resolve to Cloud version.',
},
syncNow: 'Sync Now',
syncing: 'Sync in Progress...',
saveWarning: 'Please save pending changes (Backups/Path Mapping) before syncing.',
},
mapping: {
title: 'Path Mapping',
simple: 'Simple Mapping',
regex: 'Regex Rules',
simpleTitle: 'Path Mapping',
simpleSubtitle: 'Map Local paths to Cloud paths using simple string matching',
regexPre: 'Pre-Processing (Before Sync)',
regexPost: 'Post-Processing (After Sync / Result)',
localPath: 'Local Path',
cloudPath: 'Cloud Path',
pattern: 'Pattern',
replace: 'Replace',
saveRules: 'Save Rules',
noRules: 'No rules defined.',
},
backup: {
title: 'Backup Retention',
enable: 'Enable Backups',
enableDesc: 'Create a copy before changes',
maxVersions: 'Max versions to keep:',
autoDelete: 'Oldest deleted automatically',
},
schedule: {
title: 'Scheduled Tasks',
cron: 'Cron',
daily: 'Daily',
weekly: 'Weekly',
enableCron: 'Enable Cron Schedule',
enableDaily: 'Enable Daily Run',
enableWeekly: 'Enable Weekly Run',
watchLocal: 'Watch Local Changes',
watchDesc: 'Auto-sync when local playlist updates',
schedule: 'Schedule',
notConfigured: 'Not configured',
today: 'Today',
tomorrow: 'Tomorrow',
},
connection: {
titleConnected: 'Server Connected',
titleConnect: 'Connect Plex Server',
serverDetails: 'Server Details',
authentication: 'Authentication',
protocol: 'Protocol',
address: 'IP Address or Domain',
port: 'Port',
token: 'X-Plex-Token (Optional)',
username: 'Username / Email',
password: 'Password',
advanced: 'Advanced Options',
timeout: 'Connection Timeout (Seconds)',
connectBtn: 'Connect Server',
connecting: 'Connecting...',
connectedSuccess: 'Connected Successfully',
selectLibrary: 'Select Library to Sync',
},
toasts: {
localRefreshCancelled: 'Local refresh cancelled.',
cloudRefreshCancelled: 'Cloud refresh cancelled.',
strategySaved: 'Selected strategy "{strategy}" has been saved.',
mappingSaved: 'Path mapping rules have been saved.',
backupSaved: 'Backup settings have been saved.',
backupFailed: 'Failed to save backup settings.',
scheduleDisabled: 'Scheduled tasks disabled.',
scheduleEmpty: 'Scheduled tasks disabled (Empty Cron).',
scheduleStarted: 'Scheduled task started successfully.',
scheduleFailed: 'Failed to update schedule.',
syncFailed: 'Sync failed. Please check connection.',
librarySwitched: 'Library switched to {library}',
connectedTo: 'Successfully connected to {name}',
connectionCancelled: 'Connection cancelled by user.',
loginFailed: 'Login failed. Please check credentials.',
loggedOut: 'Successfully logged out.',
}
};
+162
View File
@@ -0,0 +1,162 @@
export const es = {
app: {
// title and manager are no longer used for branding
title: 'PlexSync',
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
auth: {
title: 'Iniciar Sesión',
subtitle: 'Ingrese para gestionar sus sincronizaciones',
username: 'Usuario',
password: 'Password',
loginBtn: 'Entrar',
logout: 'Salir',
loggingIn: 'Verificando...',
invalidCredentials: 'Usuario o contraseña incorrectos',
welcome: 'Bienvenido, {user}',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
revert: 'Revertir',
delete: 'Eliminar',
done: 'Hecho',
loading: 'Cargando...',
refresh: 'Actualizar',
close: 'Cerrar',
none: 'Ninguno',
disabled: 'Deshabilitado',
add: 'Añadir',
},
server: {
local: 'Servidor Local',
cloud: 'Servidor Nube',
playlists: '{count} Listas',
notConnected: 'No Conectado',
connectionFailed: 'Conexión fallida',
connecting: 'Conectando...',
waiting: 'Esperando...',
syncing: 'Sincronizando...',
noPlaylists: 'No se encontraron listas.',
cancelRefresh: 'Cancelar',
refreshPlaylists: 'Actualizar Listas',
},
playlist: {
trackCount: 'Pistas',
lastUpdated: 'Actualizado',
},
dashboard: {
mapping: 'Mapeo',
backup: 'Respaldo',
autoSync: 'Auto-Sync',
watch: 'Vigilar',
watchModeActive: 'Modo Vigía: Activo',
watchModeDisabled: 'Modo Vigía: Desactivado',
notSet: 'No Def.',
retain: 'Retener: {count}',
keep: 'Guardar {count}',
connected: 'Conectado a Plex',
disconnected: 'Desconectado',
synchronizing: 'SINCRONIZANDO',
syncComplete: 'SINCRONIZACIÓN COMPLETA',
},
strategies: {
title: 'Estrategia de Sync',
localOverwrite: {
label: 'Sobreescribir Local',
desc: 'La lista local sobreescribe la nube. (Sin Diff)',
},
cloudOverwrite: {
label: 'Sobreescribir Nube',
desc: 'La lista de la nube sobreescribe la local. (Sin Diff)',
},
mergeLocal: {
label: 'Fusión (Prioridad Local)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Local.',
},
mergeCloud: {
label: 'Fusión (Prioridad Nube)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Nube.',
},
syncNow: 'Sincronizar Ahora',
syncing: 'Sincronizando...',
saveWarning: 'Guarde los cambios pendientes (Respaldos/Mapeo) antes de sincronizar.',
},
mapping: {
title: 'Mapeo de Rutas',
simple: 'Mapeo Simple',
regex: 'Reglas Regex',
simpleTitle: 'Mapeo de Rutas',
simpleSubtitle: 'Mapear rutas locales a la nube usando coincidencia simple',
regexPre: 'Pre-Procesamiento (Antes de Sync)',
regexPost: 'Post-Procesamiento (Después de Sync)',
localPath: 'Ruta Local',
cloudPath: 'Ruta Nube',
pattern: 'Patrón',
replace: 'Reemplazo',
saveRules: 'Guardar Reglas',
noRules: 'No hay reglas definidas.',
},
backup: {
title: 'Retención de Respaldo',
enable: 'Habilitar Respaldos',
enableDesc: 'Crear copia antes de cambios',
maxVersions: 'Máx versiones a guardar:',
autoDelete: 'El más antiguo se borra automáticamente',
},
schedule: {
title: 'Tareas Programadas',
cron: 'Cron',
daily: 'Diario',
weekly: 'Semanal',
enableCron: 'Habilitar Cron',
enableDaily: 'Habilitar Ejecución Diaria',
enableWeekly: 'Habilitar Ejecución Semanal',
watchLocal: 'Vigilar Cambios Locales',
watchDesc: 'Auto-sync cuando la lista local se actualiza',
schedule: 'Horario',
notConfigured: 'No configurado',
today: 'Hoy',
tomorrow: 'Mañana',
},
connection: {
titleConnected: 'Servidor Conectado',
titleConnect: 'Conectar Servidor Plex',
serverDetails: 'Detalles del Servidor',
authentication: 'Autenticación',
protocol: 'Protocolo',
address: 'Dirección IP o Dominio',
port: 'Puerto',
token: 'X-Plex-Token (Opcional)',
username: 'Usuario / Email',
password: 'Password',
advanced: 'Opciones Avanzadas',
timeout: 'Tiempo de espera (Segundos)',
connectBtn: 'Conectar Servidor',
connecting: 'Conectando...',
connectedSuccess: 'Conectado Exitosamente',
selectLibrary: 'Seleccionar Librería',
},
toasts: {
localRefreshCancelled: 'Actualización local cancelada.',
cloudRefreshCancelled: 'Actualización nube cancelada.',
strategySaved: 'Estrategia seleccionada "{strategy}" guardada.',
mappingSaved: 'Reglas de mapeo guardadas.',
backupSaved: 'Configuración de respaldo guardada.',
backupFailed: 'Error al guardar configuración de respaldo.',
scheduleDisabled: 'Tareas programadas deshabilitadas.',
scheduleEmpty: 'Tareas programadas deshabilitadas (Cron Vacío).',
scheduleStarted: 'Tarea programada iniciada exitosamente.',
scheduleFailed: 'Error al actualizar horario.',
syncFailed: 'Fallo en sync. Revise conexión.',
librarySwitched: 'Librería cambiada a {library}',
connectedTo: 'Conectado exitosamente a {name}',
connectionCancelled: 'Conexión cancelada por usuario.',
loginFailed: 'Fallo de inicio de sesión. Verifique credenciales.',
loggedOut: 'Sesión cerrada exitosamente.',
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "PlexSync Manager", "name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.", "description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": [] "requestFramePermissions": []
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "plexsync-manager", "name": "pms-playlist-sync",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
+34 -1
View File
@@ -2,7 +2,10 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode } from '../types';
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode, BackupSettings, LoginCredentials, AuthResponse } from '../types';
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData'; import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
const SIMULATE_DELAY_MS = 800; const SIMULATE_DELAY_MS = 800;
@@ -220,5 +223,35 @@ export const apiService = {
resolve({ data: null, status: 'success', message: 'Schedule updated successfully' }); resolve({ data: null, status: 'success', message: 'Schedule updated successfully' });
}, 500); }, 500);
}); });
},
saveBackupSettings: async (settings: BackupSettings): Promise<ApiResponse<null>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
}, 500);
});
},
// Mock Login - In a real app this would POST to a backend
login: async (creds: LoginCredentials): Promise<ApiResponse<AuthResponse>> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Hardcoded mock credentials for demonstration
if (creds.username === 'admin' && creds.password === 'password') {
resolve({
data: { token: 'mock-jwt-token-123', username: 'admin' },
status: 'success',
message: 'Login successful'
});
} else {
resolve({
data: { token: '', username: '' },
status: 'error',
message: 'Invalid credentials'
});
}
}, 1000);
});
} }
}; };
+11
View File
@@ -0,0 +1,11 @@
import { en } from './locales/en';
import { es } from './locales/es';
export const translations = {
en,
es
};
export type Language = keyof typeof translations;
export type TranslationStructure = typeof en;
+15
View File
@@ -59,6 +59,11 @@ export interface PathMappingConfig {
regex: PathMappingRules; regex: PathMappingRules;
} }
export interface BackupSettings {
enabled: boolean;
retentionCount: number;
}
export enum ScheduleMode { export enum ScheduleMode {
DISABLED = 'DISABLED', DISABLED = 'DISABLED',
CRON = 'CRON', CRON = 'CRON',
@@ -105,3 +110,13 @@ export interface ApiResponse<T> {
status: 'success' | 'error'; status: 'success' | 'error';
message?: string; message?: string;
} }
export interface LoginCredentials {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
username: string;
}