116 Commits

Author SHA1 Message Date
Koha9 a9687f62b2 Merge branch 'fix/filewatch_syncloop_fix' 2026-01-15 16:38:35 +09:00
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
Koha9 f791798206 Squashed 'sample-front-end/' changes from 0e20813..8ae211a
8ae211a feat: Introduce path mapping for sync

git-subtree-dir: sample-front-end
git-subtree-split: 8ae211a79c0d522050553e80674b82e2c9471e0f
2025-11-30 02:58:04 +09:00
Koha9 15e7636a92 PlexPlaylist_UI subtree merge
feat: Introduce path mapping for sync

Merge commit 'f791798206d87c694c14d7bffb52645706af4964'
2025-11-30 02:58:04 +09:00
Koha9 3719cda819 feat: Add a Docker run script to start the PlexPlaylistSync container. 2025-11-30 02:27:21 +09:00
Koha9 2718d817d9 Merge branch 'scheduling-function' 2025-11-30 02:21:32 +09:00
Koha9 5f62040611 feat: add loacal file watcher statement 2025-11-29 13:53:38 +09:00
Koha9 fda9f01da1 Merge commit 'c879c4c0d927c834c557f89b33a06d29956412a9' into scheduling-function 2025-11-29 13:11:40 +09:00
Koha9 c879c4c0d9 fix:The server detail page failed to correctly display server_scheme from the saved config.json. 2025-11-29 13:10:52 +09:00
Koha9 559342fae7 feat: Enhance scheduler and watcher with improved logging and cron trigger helpers 2025-11-29 12:56:18 +09:00
Koha9 d1a4273fb2 Squashed 'sample-front-end/' changes from 9f02555..0e20813
0e20813 feat: Add eye icon for visibility toggles

git-subtree-dir: sample-front-end
git-subtree-split: 0e208135b924170bcd757c693265a5cc1b620ac3
2025-11-29 12:35:27 +09:00
Koha9 432eee153e PlexPlaylist_UI subtree merge
feat: Add eye icon for visibility toggles

Merge commit 'd1a4273fb2f0c2b69e166cace3729fdb02b310ab'
2025-11-29 12:35:27 +09:00
Koha9 fe4061d1a1 feat: Implement sync manager and file watcher for automated playlist synchronization 2025-11-29 12:26:59 +09:00
Koha9 22697fdc1d feat: Enhance schedule handling in StrategySelector component 2025-11-29 11:10:24 +09:00
Koha9 c982fb930f Merge commit '6f234ebc48e506f0c46ebf811b2a791dd8960dcd' into scheduling-function 2025-11-29 10:54:08 +09:00
Koha9 305743d752 Squashed 'sample-front-end/' changes from 99ea3a6..9f02555
9f02555 feat(ui): Improve schedule dirty state detection

git-subtree-dir: sample-front-end
git-subtree-split: 9f02555bbcc1e7bd576ad04763fbeb5d1f0e0b31
2025-11-29 10:52:05 +09:00
Koha9 6f234ebc48 PlexPlaylist_UI subtree merge
feat(ui): Improve schedule dirty state detection
Detects changes in schedule settings more accurately, considering the active tab and deriving the effective schedule state before comparison. This prevents unintended saving of disabled schedules when switching tabs.

Merge commit '305743d752e1a1ecaefba79419929524ad060663'
2025-11-29 10:52:05 +09:00
Koha9 7dae8647e6 feat: Implement scheduling functionality for playlist synchronization
- Added a new scheduler module using APScheduler to manage scheduled sync jobs.
- Introduced cron expression validation and job scheduling based on user-defined settings.
- Enhanced frontend to support schedule settings, including cron, daily, and weekly modes.
- Updated API service to handle fetching and saving schedule settings.
- Modified StrategySelector component to include schedule management UI.
- Added new types for schedule settings and modes in the frontend.
- Updated requirements to include APScheduler for scheduling capabilities.
2025-11-29 10:49:35 +09:00
Koha9 06e49be1f9 Squashed 'sample-front-end/' changes from 552f9c4..99ea3a6
99ea3a6 feat: Display next sync schedule information
fb8d17a feat: Implement schedule settings and basic UI

git-subtree-dir: sample-front-end
git-subtree-split: 99ea3a68de98503b706d3ee5782baf4a66dc7134
2025-11-29 08:23:31 +09:00
Koha9 40f818bd2c PlexPlaylist_UI subtree merge
feat: Implement schedule settings and basic UI
feat: Display next sync schedule information

Merge commit '06e49be1f9c587f66cca97de97cf449b33b04a4b'
2025-11-29 08:23:31 +09:00
Koha9 6b14847598 Merge commit '0ede13717064aaee99c699d8a3720e9a9c478b1e' 2025-11-29 08:20:22 +09:00
Koha9 0ede137170 Support backend connection timeout configuration 2025-11-29 08:18:32 +09:00
Koha9 da6056c1ae Merge commit 'b6408bf12076b250b5b760d8ba513c0998e48a21' 2025-11-29 07:43:17 +09:00
Koha9 b6408bf120 Add sync controls and status header animations 2025-11-29 04:55:41 +09:00
Koha9 74b37a062c Squashed 'sample-front-end/' changes from 601ffe4..552f9c4
552f9c4 feat: Centralize animation and timing constants
cc962c2 feat: Adjust sync animation gradient
e623426 feat: Add playlist sync functionality and animations

git-subtree-dir: sample-front-end
git-subtree-split: 552f9c471324793b85af14534e81d45d319036a2
2025-11-29 04:08:38 +09:00
Koha9 90294a29bf Add a manual sync button to the UI, along with a sync status indicator bar.
Merge commit '74b37a062cb63d578c1584f2ac1df067dc8b5ed3'
2025-11-29 04:08:38 +09:00
Koha9 80a3e373cf Merge branch 'codex/integrate-sample-front-end-with-backend' 2025-11-29 03:35:40 +09:00
Koha9 c0e45dc674 Fixed the issue where the frontend was not copied to Docker. 2025-11-29 03:26:21 +09:00
Koha9 832dbc11d5 Fix frontend entrypoint for Vite build 2025-11-29 00:25:45 +09:00
Koha9 5a29265854 Squashed 'sample-front-end/' changes from 0881bf1..601ffe4
601ffe4 fix: Refine UI layout and visual elements
4689aaa feat: Add request timeout and cancellation to API calls

git-subtree-dir: sample-front-end
git-subtree-split: 601ffe468a78955839eef6c839314d9b96ea204d
2025-11-28 22:43:39 +09:00
Koha9 e5ba790b44 Merge commit '5a29265854cee9c039f746f5685a538eff048fae' 2025-11-28 22:43:39 +09:00
Koha9 4e91c2acdf Squashed 'sample-front-end/' content from commit 0881bf1
git-subtree-dir: sample-front-end
git-subtree-split: 0881bf1c045118585100360b2c47594cd94b89f1
2025-11-28 01:31:35 +09:00
Koha9 4c6af7115e Merge commit '4e91c2acdf7300f4342b83ea290487c4ef664df4' as 'sample-front-end' 2025-11-28 01:31:35 +09:00
Koha9 1b6dddacc8 Add a dropdown menu that allows users to select a library. 2025-11-27 21:48:48 +09:00
Koha9 355886e797 Modify get_libs_name_list to filter out non-music libraries during output. 2025-11-27 08:11:27 +09:00
Koha9 f943105948 Merge branch 'codex/add-regex-rules-for-playlist-path-replacement' 2025-11-27 05:31:25 +09:00
Koha9 b0613d1616 Add cancel button to reset regex-rule form. 2025-11-27 05:30:54 +09:00
Koha9 3ffc90f43b remove pytest from main branch.
update gitignore.
2025-11-27 05:08:57 +09:00
Koha9 c2b429272f Merge branch 'codex/add-regex-rules-for-playlist-path-replacement' 2025-11-27 04:55:47 +09:00
Koha9 0ad64216f5 Add regex-based path preprocessing for playlist sync 2025-11-25 21:33:04 +09:00
Koha9 d7f00408e5 import pytest 2025-11-25 20:55:09 +09:00
Koha9 2a85c03253 Merge branch 'codex/implement-playlist-conflict-handling-feature' 2025-11-25 10:51:05 +09:00
Koha9 5a52831ae8 Fix remote-priority merge postprocessing. 2025-11-25 10:50:37 +09:00
Koha9 0bb624f3c9 Adjust local-priority merge postprocessing 2025-11-25 10:08:49 +09:00
Koha9 97aa598775 Fix local-priority merge to avoid remote reorders in conflicts 2025-11-25 10:08:42 +09:00
Koha9 a912213e2e Load remote playlists from Plex during sync 2025-11-25 04:39:30 +09:00
Koha9 08eed569a9 Sync all playlists and track deletions 2025-11-24 22:21:01 +09:00
Koha9 f80fac4ce5 Merge branch 'codex/add-homepage-playlist-display-feature' 2025-11-24 19:06:23 +09:00
Koha9 61794b8db9 Add playlist overview homepage 2025-11-24 17:35:42 +09:00
Koha9 9ff74550a2 Add docker compose support 2025-11-24 07:41:10 +09:00
Koha9 93cc72d612 load local playlist.
cache plex tracks.
2025-07-23 22:38:51 +09:00
Koha9 1eb067bab7 logger fix 2025-07-19 18:37:23 +09:00
Koha9 c3d1662465 logger added.
plex_client method naming fix.
2025-07-19 18:00:48 +09:00
Koha9 65bd99d3f2 Fix config not save&load 2025-07-19 10:35:14 +09:00
Koha9 ec2673a5c8 Merge pull request #8 from Koha9/codex/add-notification-popup-with-bootstrap
Add bootstrap toast for messages
2025-07-13 07:25:09 +09:00
Koha9 82f18dea9b adjust backdrop blur for glass toast effect. 2025-07-13 07:24:50 +09:00
Koha9 43a9c01f13 Tint toast glass effect with message color 2025-07-13 07:18:14 +09:00
Koha9 bf67d702dc Center toast and add glass style 2025-07-13 07:07:41 +09:00
Koha9 969b32ab68 feat(ui): add bootstrap toast for messages 2025-07-13 06:56:26 +09:00
Koha9 992161f9a9 refactor plex_client,config 2025-07-12 19:00:35 +09:00
Koha9 35bd0c9956 Merge pull request #6 from Koha9/token-connect
Token connect support
2025-07-08 19:59:02 +09:00
Koha9 4003fd5bc1 Merge pull request #5 from Koha9/codex/add-grayout-and-strikethrough-for-user-and-password
UI tweak for token login
2025-07-08 19:56:41 +09:00
Koha9 afd4981ba7 refactor login template 2025-07-08 19:54:32 +09:00
Koha9 77c38cd17d Disable highlight when user/pass grayed out 2025-07-08 19:49:35 +09:00
Koha9 0c3d9d7287 Grey out user/password when token present 2025-07-08 19:44:07 +09:00
Koha9 a8863f911b 支持手动token认证 2025-07-08 19:17:18 +09:00
Koha9 0c2a84f944 Merge pull request #4 from Koha9/codex/support-https-urls-for-server-address
Support HTTPS server URLs
2025-06-21 19:42:13 +09:00
78 changed files with 13958 additions and 119 deletions
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.swp
*.log
.env
.venv
venv
.git
.gitignore
.vscode
**/.DS_Store
*.sqlite3
*.db
.idea
node_modules
+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
+27
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
@@ -129,6 +132,11 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Node / frontend assets
node_modules/
frontend/node_modules/
frontend/dist/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
@@ -160,3 +168,22 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# vscode gitignore
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
!*.code-workspace
# Built Visual Studio Code Extensions
*.vsix
# data
data/*
playlists/*
docker-compose.yml
# Local dev config may contain Plex token
app/config.json
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY frontend ./frontend
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+19 -5
View File
@@ -18,16 +18,30 @@ PlexPlaylistSync 是一个用于同步 Plex 播放列表和本地 `.m3u`/`.m3u8`
默认情况下 Plex 服务器使用 `32400` 端口,可在未修改服务器端口时直接使用该默认值。 默认情况下 Plex 服务器使用 `32400` 端口,可在未修改服务器端口时直接使用该默认值。
登录页面提供选择 `http``https` 的下拉框,服务器地址输入框只需填写域名或 IP,默认值会从 `config.json` 读取。 登录页面提供选择 `http``https` 的下拉框,服务器地址输入框只需填写域名或 IP,默认值会从 `config.json` 读取。
## 安装 ## 开发环境快速启动(Docker Compose
首先安装依赖 项目已内置 Docker 化配置,开发时只需执行一次构建即可运行
```bash ```bash
pip install -r requirements.txt docker compose up --build
``` ```
然后启动服务: ### 配置容器时区
本项目支持通过 `docker-compose.yml` 的环境变量 `TZ` 配置容器运行时区(需要使用有效的 IANA 时区名,例如 `Asia/Shanghai``UTC``America/New_York`)。
- 临时指定(当前终端会话生效):
```bash ```bash
uvicorn app.main:app --port 8080 TZ=UTC docker compose up --build
``` ```
- 或在项目根目录创建 `.env`
```env
TZ=Asia/Shanghai
```
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
-6
View File
@@ -1,6 +0,0 @@
{
"theme": "auto",
"token": "",
"server_url": "",
"server_port": "32400"
}
+932 -60
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -1,3 +1,50 @@
.custom-sidebar { .custom-sidebar {
max-width: 300px !important; /* 设置最大宽度为 300px */ max-width: 300px !important; /* 设置最大宽度为 300px */
} }
/* Toast with frosted glass effect */
.glass-toast {
backdrop-filter: blur(5px);
}
.glass-toast-success {
background-color: rgba(var(--bs-success-rgb), 0.75) !important;
}
.glass-toast-danger {
background-color: rgba(var(--bs-danger-rgb), 0.75) !important;
}
.glass-toast-info {
background-color: rgba(var(--bs-info-rgb), 0.75) !important;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
border: 1px solid var(--bs-border-color);
}
.status-connected {
background-color: var(--bs-success);
}
.status-failed {
background-color: var(--bs-danger);
}
.status-unset {
background-color: var(--bs-secondary);
}
.rule-add-btn {
width: 64px;
height: 64px;
font-size: 1.5rem;
}
.rule-row .form-control {
min-width: 0;
}
+27
View File
@@ -64,6 +64,13 @@
</a> </a>
<hr class="d-none d-md-block"> <hr class="d-none d-md-block">
<ul class="nav nav-pills flex-column"> <ul class="nav nav-pills flex-column">
<li class="nav-item">
<a href="/" class="nav-link {% if path == '/' %}active{% endif %}">
<!-- home icon -->
<i class="bi bi-house-door-fill"></i>
<span class="d-none d-md-inline ms-2">主页</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="/login" class="nav-link {% if path == '/login' %}active{% endif %}"> <a href="/login" class="nav-link {% if path == '/login' %}active{% endif %}">
<!-- login icon --> <!-- login icon -->
@@ -87,6 +94,18 @@
<main class="col ms-sm-auto px-md-4 py-4"> <main class="col ms-sm-auto px-md-4 py-4">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% if message %}
<div class="position-fixed top-0 start-50 translate-middle-x p-3 w-75" style="z-index: 1055; margin-top: 20px;">
<div id="messageToast" class="toast text-bg-{{ message_type | default('info') }} border-0 glass-toast glass-toast-{{ message_type | default('info') }} w-100" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -106,6 +125,14 @@
// listen for system theme changes // listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyAutoTheme); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyAutoTheme);
} }
// show toast message
document.addEventListener('DOMContentLoaded', () => {
const toastEl = document.getElementById('messageToast');
if (toastEl) {
const toast = new bootstrap.Toast(toastEl, { delay: 3000 });
toast.show();
}
});
</script> </script>
</body> </body>
+235
View File
@@ -0,0 +1,235 @@
{% extends "base.html" %}
{% block title %}主页 - Plex Sync{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-md-row align-items-md-center mb-4 gap-2">
<div>
<h1 class="h3 mb-1">播放列表概览</h1>
<p class="text-body-secondary mb-0">浏览本地与云端同步的播放列表,随时刷新最新状态。</p>
</div>
</div>
{% if message %}
<div class="alert alert-{{ message_type or 'info' }}" role="alert">
{{ message }}
</div>
{% endif %}
<div class="card mb-4">
<div class="card-body">
<div class="d-flex flex-column flex-xl-row align-items-xl-center gap-3 mb-3">
<div>
<h5 class="card-title mb-0">选择同步策略</h5>
<small class="text-body-secondary">根据需要选择单向覆盖或双向合并策略</small>
</div>
<form class="ms-xl-auto w-100 w-xl-auto" method="post" action="/sync">
<div class="row gy-2 gx-2 align-items-end">
<div class="col-12 col-lg-8">
<label class="form-label mb-1">同步模式</label>
<select class="form-select" name="mode">
{% for option in sync_modes %}
<option value="{{ option.value }}" {% if selected_mode == option.value %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<label class="form-label d-none d-lg-block">&nbsp;</label>
<div>
<button class="btn btn-primary" type="submit">
<i class="bi bi-play-fill"></i>
<span class="ms-1">开始同步</span>
</button>
</div>
</div>
<input type="hidden" name="local_path" value="{{ local_path }}">
</div>
</form>
</div>
<ul class="mb-0 text-body-secondary small ps-3">
<li><strong>完全本地优先</strong>:本地列表直接覆盖云端,顺序以本地为准(无 diff)。</li>
<li><strong>完全云端优先</strong>:云端列表直接覆盖本地,顺序以云端为准(无 diff)。</li>
<li><strong>双向合并(本地优先)</strong>:三方合并,冲突时选本地。</li>
<li><strong>双向合并(云端优先)</strong>:三方合并,冲突时选云端。</li>
</ul>
{% if sync_result %}
<div class="alert alert-info mt-3 mb-0" role="alert">
<div class="fw-semibold">{{ sync_result.mode_label }}</div>
<div class="small mb-1">播放列表:{{ sync_result.playlist_count }},删除:{{ sync_result.delete_count }},合并曲目:{{ sync_result.merged_count }},冲突数:{{ sync_result.conflict_count }}</div>
<div class="small text-body-secondary">已写入:{{ sync_result.output_dir }}</div>
</div>
{% endif %}
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-3 mb-3">
<div>
<h5 class="card-title mb-0">本地播放列表</h5>
<small class="text-body-secondary">输入目录并刷新,读取挂载的播放列表文件</small>
</div>
<form class="ms-lg-auto w-100 w-lg-auto" method="get" action="/">
<div class="input-group">
<input type="text" class="form-control" name="local_path" value="{{ local_path }}" placeholder="playlist">
<button class="btn btn-outline-secondary" type="submit" title="刷新本地播放列表">
<i class="bi bi-arrow-clockwise"></i>
<span class="d-none d-sm-inline ms-1">刷新</span>
</button>
</div>
</form>
</div>
{% if local_playlists %}
<ul class="list-group list-group-flush">
{% for playlist in local_playlists %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ playlist.name }}</div>
<div class="text-body-secondary small">曲目数</div>
</div>
<span class="badge rounded-pill text-bg-primary">{{ playlist.track_count }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-body-secondary">未发现播放列表,请确认目录或点击刷新。</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-3 mb-3">
<div>
<h5 class="card-title mb-0">云端播放列表</h5>
<small class="text-body-secondary">来自已连接的 Plex 服务器</small>
<div class="text-body-secondary small mt-1">当前音乐库:{{ selected_library or '暂无可用音乐库' }}</div>
</div>
<form class="ms-lg-auto" method="get" action="/">
<input type="hidden" name="local_path" value="{{ local_path }}">
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2 text-body-secondary small">
<span class="status-dot status-{{ connection_status }}"></span>
<span>
{{ server_info.name }}
<span class="text-body-secondary">· {{ server_info.domain }}</span>
</span>
</div>
<button class="btn btn-outline-secondary" type="submit" title="刷新云端播放列表">
<i class="bi bi-arrow-clockwise"></i>
<span class="d-none d-sm-inline ms-1">刷新</span>
</button>
</div>
</form>
</div>
{% if cloud_playlists %}
<ul class="list-group list-group-flush">
{% for playlist in cloud_playlists %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ playlist.name }}</div>
<div class="text-body-secondary small">曲目数</div>
</div>
<span class="badge rounded-pill text-bg-success">{{ playlist.track_count }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-body-secondary">暂无云端播放列表,检查连接或刷新重试。</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-3 mb-3">
<div>
<h5 class="card-title mb-0">路径正则替换</h5>
<small class="text-body-secondary">同步前按顺序处理每一首歌曲的路径,解决不同挂载路径的差异</small>
</div>
</div>
<form id="pathRuleForm" method="post" action="/path-rules">
<input type="hidden" name="local_path" value="{{ local_path }}">
<div id="ruleList" class="d-flex flex-column gap-2 mb-3"></div>
<div class="d-flex align-items-center gap-3 mb-3">
<button class="btn btn-outline-primary rounded-circle rule-add-btn" type="button" id="addRuleBtn"
title="添加新的正则规则">
<i class="bi bi-plus-lg"></i>
</button>
<div class="text-body-secondary small">从上到下依次匹配,左侧填写正则表达式,右侧填写替换结果。</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" type="button" id="cancelRulesBtn">
<i class="bi bi-arrow-counterclockwise"></i>
<span class="ms-1">取消变更</span>
</button>
<button class="btn btn-success" type="submit">
<i class="bi bi-save"></i>
<span class="ms-1">保存规则</span>
</button>
</div>
</form>
</div>
</div>
<template id="ruleRowTemplate">
<div class="input-group rule-row">
<span class="input-group-text">匹配</span>
<input type="text" class="form-control" name="pattern" placeholder="正则表达式" required>
<span class="input-group-text">替换为</span>
<input type="text" class="form-control" name="replacement" placeholder="替换文本">
<button class="btn btn-outline-danger" type="button" title="删除此规则">
<i class="bi bi-x-lg"></i>
</button>
</div>
</template>
<script>
const existingRules = {{ path_rules | tojson | safe }};
const ruleList = document.getElementById('ruleList');
const addRuleBtn = document.getElementById('addRuleBtn');
const cancelRulesBtn = document.getElementById('cancelRulesBtn');
const template = document.getElementById('ruleRowTemplate');
const initialRules = existingRules.length
? existingRules.map(rule => ({ ...rule }))
: [{ pattern: '', replacement: '' }];
function bindRemoveButton(row) {
const removeBtn = row.querySelector('button');
removeBtn.addEventListener('click', () => {
row.remove();
});
}
function addRuleRow(pattern = '', replacement = '') {
const clone = template.content.cloneNode(true);
const row = clone.querySelector('.rule-row');
const [patternInput, replacementInput] = row.querySelectorAll('input');
patternInput.value = pattern;
replacementInput.value = replacement;
bindRemoveButton(row);
ruleList.appendChild(clone);
}
function renderRules(rules) {
ruleList.innerHTML = '';
if (!rules.length) {
addRuleRow();
return;
}
rules.forEach(rule => addRuleRow(rule.pattern || '', rule.replacement || ''));
}
addRuleBtn.addEventListener('click', () => addRuleRow());
cancelRulesBtn.addEventListener('click', () => renderRules(initialRules));
renderRules(initialRules);
</script>
{% endblock %}
+46 -5
View File
@@ -6,12 +6,16 @@
<h2>🔐 登录信息</h2> <h2>🔐 登录信息</h2>
<form method="post" action="/login"> <form method="post" action="/login">
<div class="mb-3"> <div class="mb-3">
<label for="user" class="form-label">用户名</label> <label for="user" class="form-label" id="user-label">用户名</label>
<input type="text" class="form-control" id="user" name="user"> <input type="text" class="form-control" id="user" name="user">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="pw" class="form-label">密码</label> <label for="pw" class="form-label" id="pw-label">密码</label>
<input type="password" class="form-control" id="pw" name="pw"> <input type="password" class="form-control" id="pw" name="pw">
</div>
<div class="mb-3">
<label for="token" class="form-label">Token</label>
<input type="text" class="form-control" id="token" name="token" value="{{ token }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="scheme" class="form-label">协议</label> <label for="scheme" class="form-label">协议</label>
@@ -28,10 +32,47 @@
<label for="port" class="form-label">端口</label> <label for="port" class="form-label">端口</label>
<input type="text" class="form-control" id="port" name="port" value="{{ port }}"> <input type="text" class="form-control" id="port" name="port" value="{{ port }}">
</div> </div>
<div class="mb-3">
<label for="library_name" class="form-label">音乐库</label>
<select class="form-select" id="library_name" name="library_name" {% if not music_libraries %}disabled{% endif %}>
{% if music_libraries %}
{% for library in music_libraries %}
<option value="{{ library }}" {% if selected_library == library %}selected{% endif %}>{{ library }}</option>
{% endfor %}
{% else %}
<option value="" selected>暂无可用的音乐库</option>
{% endif %}
</select>
<div class="form-text">仅显示音乐类型的库,可在连接成功后自动刷新。</div>
</div>
<button type="submit" class="btn btn-primary">连接</button> <button type="submit" class="btn btn-primary">连接</button>
</form> </form>
{% if message %} <script>
<div class="alert alert-{{ 'success' if success else 'danger' }} mt-4">{{ message }}</div> document.addEventListener('DOMContentLoaded', () => {
{% endif %} const tokenInput = document.getElementById('token');
const inputs = [document.getElementById('user'), document.getElementById('pw')];
const labels = [document.getElementById('user-label'), document.getElementById('pw-label')];
const fieldClasses = ['bg-body-secondary', 'text-body-secondary', 'text-decoration-line-through'];
function toggleCredFields() {
const inactive = tokenInput.value.trim() !== '';
inputs.forEach(el => {
el.readOnly = inactive;
el.tabIndex = inactive ? -1 : 0;
el.style.pointerEvents = inactive ? 'none' : '';
if (inactive && document.activeElement === el) {
el.blur();
}
fieldClasses.forEach(cls => el.classList.toggle(cls, inactive));
});
labels.forEach(el => {
['text-decoration-line-through', 'text-body-secondary'].forEach(cls => el.classList.toggle(cls, inactive));
});
}
tokenInput.addEventListener('input', toggleCredFields);
toggleCredFields();
});
</script>
{% endblock %} {% endblock %}
-3
View File
@@ -20,7 +20,4 @@
<button type="submit" class="btn btn-success">保存设置</button> <button type="submit" class="btn btn-success">保存设置</button>
</form> </form>
{% if message %}
<div class="alert alert-info mt-4">{{ message }}</div>
{% endif %}
{% endblock %} {% endblock %}
View File
+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")
+6
View File
@@ -0,0 +1,6 @@
def str_is_empty(s: str) -> bool:
"""Check if a string is empty or contains only whitespace."""
if s is None:
return True
stripped = s.replace(" ", " ")
return not bool(stripped)
+260 -26
View File
@@ -1,35 +1,269 @@
import json import json
import os import os
from urllib.parse import urlparse from app.utils.logger import logger
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "config.json")
def load_config(): def _redact_for_log(value: object) -> object:
if not os.path.exists(CONFIG_PATH): 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 {} 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")
)
# 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:
def __init__(self):
self.theme = "auto"
self.token = ""
self.url = ""
self.scheme = "https"
self.port = "32400"
self.timeout = 9
self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE
# Local playlists folder is intentionally fixed and not part of config.
# 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_cron = ""
self.schedule_daily_time = "00:00"
self.schedule_weekly_days = [0]
self.schedule_weekly_time = "00:00"
self.schedule_auto_watch = False
self.backup_enabled = True
self.backup_retention_count = 5
self.load()
def load(self) -> None:
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f: with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f) config = json.load(f)
logger.debug(f"Loaded server config: {_redact_for_log(config)}")
except FileNotFoundError:
# 如果配置文件不存在,使用默认值
self.save()
logger.debug("Config file not found, using default values.")
return
except json.JSONDecodeError:
# 如果配置文件格式错误,使用默认值
self.save()
logger.debug("Config file is invalid, using default values.")
return
self.theme = config.get("theme", "auto")
self.token = config.get("token", "")
self.url = config.get("server_url", "")
self.scheme = config.get("server_scheme", "https")
self.port = config.get("server_port", "32400")
self.timeout = config.get("timeout", 9)
self.library_name = config.get("library_name", "")
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
# local_path is fixed by design and not configurable.
self.local_path = LOCAL_PLAYLISTS_FOLDER
self.path_rules = config.get("path_rules", []) or []
def save_config(new_config): # Load path_mapping with default fallback
with open(CONFIG_PATH, "w", encoding="utf-8") as f: path_mapping_config = config.get("path_mapping")
json.dump(new_config, f, indent=4, ensure_ascii=False) if path_mapping_config:
self.path_mapping = {
"mode": path_mapping_config.get("mode", "SIMPLE"),
def get_server_settings(config): "simple": path_mapping_config.get("simple", []),
"""Return (scheme, host, port) using defaults when not configured.""" "regex": {
scheme = "https" "local_pre": path_mapping_config.get("regex", {}).get("local_pre", []),
host = "" "local_post": path_mapping_config.get("regex", {}).get("local_post", []),
port = config.get("server_port", "32400") or "32400" "remote_pre": path_mapping_config.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping_config.get("regex", {}).get("remote_post", [])
url = config.get("server_url", "") }
if url: }
parsed = urlparse(url)
if parsed.scheme:
scheme = parsed.scheme
host = parsed.hostname or parsed.netloc
if parsed.port:
port = str(parsed.port)
else: else:
host = url self.path_mapping = DEFAULT_PATH_MAPPING.copy()
return scheme, host, port self.schedule_mode = config.get("schedule_mode", "DISABLED")
self.schedule_cron = config.get("schedule_cron", "")
self.schedule_daily_time = config.get("schedule_daily_time", "02:00")
self.schedule_weekly_days = config.get("schedule_weekly_days", [0])
self.schedule_weekly_time = config.get("schedule_weekly_time", "03:00")
self.schedule_auto_watch = config.get("schedule_auto_watch", False)
self.backup_enabled = config.get("backup_enabled", False)
self.backup_retention_count = config.get("backup_retention_count", 5)
logger.info(f"Server config loaded.")
logger.debug(f"Current server config: {_redact_server_config_dict(self.__dict__)}")
def save(self):
_ensure_parent_dir(CONFIG_PATH)
config = {
"theme": self.theme,
"token": self.token,
"server_url": self.url,
"server_scheme": self.scheme,
"server_port": self.port,
"timeout": self.timeout,
"library_name": self.library_name,
"sync_mode": self.sync_mode,
"path_rules": self.path_rules,
"path_mapping": self.path_mapping,
"schedule_mode": self.schedule_mode,
"schedule_cron": self.schedule_cron,
"schedule_daily_time": self.schedule_daily_time,
"schedule_weekly_days": self.schedule_weekly_days,
"schedule_weekly_time": self.schedule_weekly_time,
"schedule_auto_watch": self.schedule_auto_watch,
"backup_enabled": self.backup_enabled,
"backup_retention_count": self.backup_retention_count,
}
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved.")
logger.debug(f"Saved server config: {_redact_for_log(config)}")
def set_url(self, url: str) -> None:
self.url = url
def set_scheme(self, scheme: str) -> None:
self.scheme = scheme
def set_port(self, port: str) -> None:
self.port = port
def set_timeout(self, timeout: int) -> None:
self.timeout = timeout if timeout and timeout > 0 else 9
def set_token(self, token: str) -> None:
self.token = token
def set_library(self, library_name: str) -> None:
self.library_name = library_name or ""
def set_sync_mode(self, sync_mode: str) -> None:
self.sync_mode = sync_mode
def set_theme(self, theme: str) -> None:
# check theme is valid
if theme not in ["auto", "dark", "light"]:
logger.error(f"Invalid theme: {theme}")
raise ValueError("Invalid theme. Must be 'auto', 'dark', or 'light'.")
self.theme = theme
def set_path_rules(self, path_rules: list[dict[str, str]]) -> None:
self.path_rules = path_rules or []
def set_path_mapping(self, path_mapping: dict) -> None:
if path_mapping:
self.path_mapping = {
"mode": path_mapping.get("mode", "SIMPLE"),
"simple": path_mapping.get("simple", []),
"regex": {
"local_pre": path_mapping.get("regex", {}).get("local_pre", []),
"local_post": path_mapping.get("regex", {}).get("local_post", []),
"remote_pre": path_mapping.get("regex", {}).get("remote_pre", []),
"remote_post": path_mapping.get("regex", {}).get("remote_post", [])
}
}
else:
self.path_mapping = DEFAULT_PATH_MAPPING.copy()
def set_schedule(
self,
mode: str,
cron: str,
daily_time: str,
weekly_days: list[int],
weekly_time: str,
auto_watch: bool,
) -> None:
self.schedule_mode = mode
self.schedule_cron = cron
self.schedule_daily_time = daily_time
self.schedule_weekly_days = weekly_days
self.schedule_weekly_time = weekly_time
self.schedule_auto_watch = auto_watch
self.save()
def set_backup(
self,
enabled: bool,
retention_count: int,
) -> None:
self.backup_enabled = enabled
self.backup_retention_count = retention_count
self.save()
def set_and_save_config(
self,
theme: str = None,
token: str = None,
url: str = None,
scheme: str = None,
port: str = None,
timeout: int | None = None,
library_name: str | None = None,
sync_mode: str | None = None,
path_rules: list[dict[str, str]] | None = None,
path_mapping: dict | None = None,
) -> None:
if theme is not None:
self.set_theme(theme)
if token is not None:
self.set_token(token)
if url is not None:
self.set_url(url)
if scheme is not None:
self.set_scheme(scheme)
if port is not None:
self.set_port(port)
if timeout is not None:
self.set_timeout(timeout)
if library_name is not None:
self.set_library(library_name)
if sync_mode is not None:
self.set_sync_mode(sync_mode)
if path_rules is not None:
self.set_path_rules(path_rules)
if path_mapping is not None:
self.set_path_mapping(path_mapping)
self.save()
server_config = ServerConfig()
+130
View File
@@ -0,0 +1,130 @@
import os
from typing import List
from app.utils.logger import logger
def load_local_playlist(playlist_path: str) -> List[str]:
"""
Load a local playlist from a m3u or m3u8 file.
Skip # comments and empty lines.
Args:
playlist_path (str): The path to the playlist file.
Returns:
list: A list of songs in the playlist.
"""
try:
result = []
with open(playlist_path, 'r', encoding="utf-8") as file:
for line in file:
line = line.strip()
if not line:
# Skip empty lines
continue
if line.startswith('#'):
# Skip comments
continue
result.append(line)
logger.info(f"Loaded {len(result)} songs from the playlist: {playlist_path}")
return result
except FileNotFoundError:
logger.error(f"Error: The file {playlist_path} does not exist.")
return []
except Exception as e:
logger.error(f"An error occurred while loading the playlist: {e}")
return []
def scan_local_playlists(base_path: str) -> list[dict]:
"""Scan a directory for playlist files and return their basic info.
Args:
base_path: Directory that contains playlist files.
Returns:
A list of dictionaries with ``name`` and ``track_count`` keys.
"""
playlists: list[dict] = []
if not base_path:
logger.warning("No base path provided for local playlists scan.")
return playlists
absolute_path = os.path.abspath(base_path)
if not os.path.isdir(absolute_path):
logger.warning(f"Playlist path does not exist or is not a directory: {absolute_path}")
return playlists
for entry in os.scandir(absolute_path):
if not entry.is_file():
continue
if not entry.name.lower().endswith((".m3u", ".m3u8")):
continue
tracks = load_local_playlist(entry.path)
playlists.append({"name": entry.name, "track_count": len(tracks)})
playlists.sort(key=lambda item: item["name"].lower())
logger.info(f"Found {len(playlists)} playlists under {absolute_path}.")
return playlists
def write_local_playlist(playlist_path: str, tracks: List[str]) -> bool:
"""
Write a list of tracks to a local playlist file.
Args:
playlist_path (str): The path to the playlist file.
tracks (list): A list of songs to write to the playlist.
Returns:
bool: True if successful, False otherwise.
"""
try:
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
+55
View File
@@ -0,0 +1,55 @@
import logging
import os
LOG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "logs", "app.log"))
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:
"""Initialize the logger for the application. Return a logger that logs to console and a app.log."""
logger = logging.getLogger("PlexPlaylistSync")
if logger.hasHandlers():
return logger
# check log path exists, if not create it
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
logger.setLevel(LOG_LEVEL)
# 2025-07-19 17:23:05,116 [levelname]: message
formatter = logging.Formatter("%(asctime)s [%(levelname)s]: %(message)s")
file_handler = logging.FileHandler(LOG_PATH, encoding="utf-8")
file_handler.setFormatter(formatter)
# add file handler
logger.addHandler(file_handler)
# console handler
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
logger = logger_initialize()
+919
View File
@@ -0,0 +1,919 @@
import os
import re
from collections import Counter
from dataclasses import dataclass
from enum import Enum
from typing import Literal, Sequence
from app.utils.config import server_config
from app.utils.logger import logger
from app.utils.plex_client import plex_client
from merge3 import Merge3
SYNC_ARTIFACTS_DIR = os.path.abspath(
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):
LOCAL_PRIORITY = "local_priority"
REMOTE_PRIORITY = "remote_priority"
DELETE_PRIORITY = "delete_priority"
KEEP_PRIORITY = "keep_priority"
class SyncMode(str, Enum):
LOCAL_FORCE = "local_force"
REMOTE_FORCE = "remote_force"
MERGE_LOCAL_PRIMARY = "merge_local_primary"
MERGE_REMOTE_PRIMARY = "merge_remote_primary"
@dataclass
class PlaylistSyncResult:
name: str
merged_paths: list[str]
conflicts: list[dict]
action: Literal["synced", "deleted"]
output_dir: str
@dataclass
class CompiledRegexRules:
"""Holds compiled regex rules for all four processing stages."""
local_pre: list[tuple[re.Pattern[str], str]]
local_post: list[tuple[re.Pattern[str], str]]
remote_pre: list[tuple[re.Pattern[str], str]]
remote_post: list[tuple[re.Pattern[str], str]]
def load_paths(text: str) -> list[str]:
"""Normalize playlist text into a list of absolute paths.
* Remove blank lines.
* Ignore comment or metadata lines starting with ``#``.
* Preserve order and allow duplicates.
"""
output: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("#"):
continue
output.append(line)
return output
def playlist_to_text(paths: Sequence[str]) -> str:
"""Convert list of paths into diff3-friendly text."""
return "\n".join(paths) + "\n"
def save_paths(paths: Sequence[str]) -> str:
"""Format playlist paths into m3u/m3u8 text."""
return "#EXTM3U\n" + "\n".join(paths) + "\n"
def _compile_regex_rules(rules: Sequence[dict[str, str]]) -> list[tuple[re.Pattern[str], str]]:
"""Compile regex rules into pattern/replacement pairs.
Supports both legacy format (pattern/replacement) and new format (search/replace).
"""
compiled: list[tuple[re.Pattern[str], str]] = []
for rule in rules:
# Support both legacy (pattern/replacement) and new (search/replace) field names
# Use explicit None checks to allow empty strings as valid values
pattern = rule.get("pattern") if rule.get("pattern") is not None else rule.get("search")
if not pattern:
continue
# For replacement, empty string is a valid value (for deletion)
replacement = rule.get("replacement") if rule.get("replacement") is not None else rule.get("replace")
if replacement is None:
replacement = ""
try:
compiled.append((re.compile(pattern), replacement))
except re.error as exc:
logger.warning(f"Skipping invalid regex '{pattern}': {exc}")
return compiled
def _apply_compiled_rules_to_paths(
paths: Sequence[str], compiled_rules: Sequence[tuple[re.Pattern[str], str]]
) -> list[str]:
if not compiled_rules:
return list(paths)
updated: list[str] = []
for path in paths:
new_path = path
for pattern, replacement in compiled_rules:
new_path = pattern.sub(replacement, new_path)
updated.append(new_path)
return updated
def apply_regex_rules_to_paths(
paths: Sequence[str], rules: Sequence[dict[str, str]]
) -> list[str]:
"""Apply regex replacement rules to each path in order."""
compiled_rules = _compile_regex_rules(rules)
return _apply_compiled_rules_to_paths(paths, compiled_rules)
def preprocess_playlist_text(
text: str,
rules: Sequence[dict[str, str]],
compiled_rules: Sequence[tuple[re.Pattern[str], str]] | None = None,
) -> str:
"""Normalize playlist text and apply regex replacements."""
if not text:
text = "#EXTM3U\n"
paths = load_paths(text)
compiled = compiled_rules if compiled_rules is not None else _compile_regex_rules(rules)
replaced_paths = _apply_compiled_rules_to_paths(paths, compiled)
return save_paths(replaced_paths)
@dataclass
class MergeChunk:
type: Literal["normal", "conflict"]
text: str | None = None
base_text: str | None = None
local_text: str | None = None
remote_text: str | None = None
origin: str | None = None
@dataclass
class MergeResult:
merged_paths: list[str]
conflicts: list[dict]
def _ensure_dir(path: str) -> str:
os.makedirs(path, exist_ok=True)
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]:
try:
with open(path, "r", encoding="utf-8") as file:
return file.read(), True
except FileNotFoundError:
return "", False
except OSError:
return "", False
def _save_playlist_text(path: str, text: str) -> str:
"""Write text if changed (avoid triggering unnecessary file events)."""
_ensure_dir(os.path.dirname(path))
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as file:
if file.read() == text:
return path
except OSError:
pass
with open(path, "w", encoding="utf-8") as file:
file.write(text)
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(
base_text: str, local_text: str, remote_text: str, folder: str
) -> tuple[list[str], list[str], list[str]]:
"""Normalize playlist inputs and persist snapshots for inspection."""
base_paths = load_paths(base_text)
local_paths = load_paths(local_text)
remote_paths = load_paths(remote_text)
_save_playlist_paths(_artifact_file(folder, "base", "base_prev.m3u8"), base_paths)
_save_playlist_paths(_artifact_file(folder, "inputs", "local_input.m3u8"), local_paths)
_save_playlist_paths(_artifact_file(folder, "inputs", "remote_input.m3u8"), remote_paths)
return base_paths, local_paths, remote_paths
def _merge_chunks(
base_paths: Sequence[str], local_paths: Sequence[str], remote_paths: Sequence[str]
) -> list[MergeChunk]:
merger = Merge3(base_paths, local_paths, remote_paths)
raw_chunks = list(merger.merge_groups())
chunks: list[MergeChunk] = []
for kind, *payload in raw_chunks:
if kind == "conflict":
base_lines, local_lines, remote_lines = payload # type: ignore[misc]
# Absorb trailing side-only chunks that actually belong to this
# conflicting region (common in reorder scenarios).
while chunks and chunks[-1].origin in ("a", "b"):
previous = chunks.pop()
previous_lines = previous.text.splitlines() if previous.text else []
if previous.origin == "a":
local_lines = previous_lines + local_lines
else:
remote_lines = previous_lines + remote_lines
chunks.append(
MergeChunk(
type="conflict",
base_text=playlist_to_text(base_lines),
local_text=playlist_to_text(local_lines),
remote_text=playlist_to_text(remote_lines),
)
)
else:
lines = payload[0]
if not lines:
continue
chunk = MergeChunk(type="normal", text=playlist_to_text(lines), origin=kind)
chunks.append(chunk)
return chunks
def _write_results(
merged_lines: Sequence[str],
folder: str,
compiled_rules: CompiledRegexRules | None = None
) -> None:
"""Write sync results to the test folder.
If compiled_rules is provided with post-processing rules:
- local_result.m3u8: merged_lines processed with local_post rules
- remote_result.m3u8: merged_lines processed with remote_post rules
- base_next.m3u8: unprocessed merged_lines (normalized sync result)
"""
# Apply post-processing regex rules if provided
if compiled_rules and compiled_rules.local_post:
local_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.local_post)
else:
local_lines = list(merged_lines)
if compiled_rules and compiled_rules.remote_post:
remote_lines = _apply_compiled_rules_to_paths(merged_lines, compiled_rules.remote_post)
else:
remote_lines = list(merged_lines)
_save_playlist_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:
marker_path = _artifact_file(folder, "meta", "delete.txt")
with open(marker_path, "w", encoding="utf-8") as file:
file.write(f"delete playlist {playlist}")
return marker_path
def _resolve_conflict(conflict: dict, strategy: ConflictResolutionStrategy) -> list[str]:
local_lines = conflict["local"]
remote_lines = conflict["remote"]
if strategy == ConflictResolutionStrategy.LOCAL_PRIORITY:
return local_lines
if strategy == ConflictResolutionStrategy.REMOTE_PRIORITY:
return remote_lines
if strategy == ConflictResolutionStrategy.DELETE_PRIORITY:
if not local_lines or not remote_lines:
return []
return local_lines
if strategy == ConflictResolutionStrategy.KEEP_PRIORITY:
if local_lines:
return local_lines
if remote_lines:
return remote_lines
return []
raise ValueError(f"Unsupported conflict resolution strategy: {strategy}")
def _postprocess_local_priority_merge(
merged_lines: list[str],
base_paths: Sequence[str],
local_paths: Sequence[str],
remote_paths: Sequence[str],
) -> list[str]:
"""Fix local-priority edge cases without disturbing existing order."""
base_counter = Counter(base_paths)
local_counter = Counter(local_paths)
remote_counter = Counter(remote_paths)
merged_counter = Counter(merged_lines)
merged = list(merged_lines)
# 1) Preserve tracks that only Remote added (not in Base or Local).
for track, remote_count in remote_counter.items():
if base_counter[track] == 0 and local_counter[track] == 0 and remote_count > 0:
current = merged_counter.get(track, 0)
while current < remote_count:
merged.append(track)
merged_counter[track] += 1
current += 1
# 2) For tracks only new in Local+Remote (not in Base), keep Local's count.
candidate_tracks = set(local_counter.keys()) | set(remote_counter.keys())
for track in candidate_tracks:
if base_counter[track] == 0 and local_counter[track] > 0 and remote_counter[track] > 0:
target = local_counter[track]
current = merged_counter.get(track, 0)
if current > target:
remove_needed = current - target
trimmed: list[str] = []
for path in reversed(merged):
if remove_needed and path == track:
remove_needed -= 1
continue
trimmed.append(path)
trimmed.reverse()
merged = trimmed
merged_counter = Counter(merged)
current = merged_counter.get(track, 0)
while current < target:
merged.append(track)
current += 1
merged_counter[track] = current
return merged
def _postprocess_remote_priority_merge(
merged_lines: list[str],
base_paths: Sequence[str],
local_paths: Sequence[str],
remote_paths: Sequence[str],
) -> list[str]:
base_counter = Counter(base_paths)
local_counter = Counter(local_paths)
remote_counter = Counter(remote_paths)
remote_base_tracks: list[str] = [
track for track in remote_paths if base_counter[track] > 0
]
target_new_counter: dict[str, int] = {}
all_tracks = set(local_counter.keys()) | set(remote_counter.keys())
for track in all_tracks:
if base_counter[track] > 0:
continue
r_cnt = remote_counter.get(track, 0)
l_cnt = local_counter.get(track, 0)
if r_cnt > 0:
target_new_counter[track] = r_cnt
elif l_cnt > 0:
target_new_counter[track] = l_cnt
new_tracks: list[str] = []
used_counter: Counter[str] = Counter()
for track in remote_paths:
if base_counter[track] > 0:
continue
if track not in target_new_counter:
continue
if used_counter[track] >= target_new_counter[track]:
continue
new_tracks.append(track)
used_counter[track] += 1
for track in local_paths:
if base_counter[track] > 0:
continue
if track not in target_new_counter:
continue
if used_counter[track] >= target_new_counter[track]:
continue
new_tracks.append(track)
used_counter[track] += 1
return remote_base_tracks + new_tracks
def merge_playlists(
base_text: str,
local_text: str,
remote_text: str,
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.LOCAL_PRIORITY,
test_folder: str = SYNC_ARTIFACTS_DIR,
compiled_rules: CompiledRegexRules | None = None,
) -> MergeResult:
"""Merge playlists using diff3 and resolve conflicts per strategy.
The base, local, and remote normalized playlists are saved into ``test_folder``
for inspection. The merged playlist is also stored twice to simulate the
versions intended for local save and cloud upload.
If compiled_rules is provided, post-processing regex rules will be applied
to the results before writing.
"""
base_paths, local_paths, remote_paths = _normalize_inputs(
base_text, local_text, remote_text, test_folder
)
chunks = _merge_chunks(base_paths, local_paths, remote_paths)
has_conflict = any(chunk.type == "conflict" for chunk in chunks)
merged_lines: list[str] = []
conflicts: list[dict] = []
for chunk in chunks:
if chunk.type == "normal":
merged_lines.extend(chunk.text.splitlines())
else:
conflict_info = {
"base": chunk.base_text.splitlines(),
"local": chunk.local_text.splitlines(),
"remote": chunk.remote_text.splitlines(),
}
conflicts.append(conflict_info)
resolved = _resolve_conflict(conflict_info, strategy)
merged_lines.extend(resolved)
if strategy == ConflictResolutionStrategy.LOCAL_PRIORITY:
merged_lines = _postprocess_local_priority_merge(
merged_lines, base_paths, local_paths, remote_paths
)
if strategy == ConflictResolutionStrategy.REMOTE_PRIORITY:
merged_lines = _postprocess_remote_priority_merge(
merged_lines, base_paths, local_paths, remote_paths
)
_write_results(merged_lines, test_folder, compiled_rules)
return MergeResult(merged_paths=merged_lines, conflicts=conflicts)
def _load_local_playlists(local_dir: str) -> dict[str, str]:
"""Read all local playlists under ``local_dir`` keyed by stem name."""
playlists: dict[str, str] = {}
if not local_dir:
return playlists
absolute = os.path.abspath(local_dir)
if not os.path.isdir(absolute):
return playlists
for entry in os.scandir(absolute):
if entry.is_file() and entry.name.lower().endswith((".m3u", ".m3u8")):
stem, _ = os.path.splitext(entry.name)
try:
with open(entry.path, "r", encoding="utf-8") as file:
playlists[stem] = file.read()
except OSError:
playlists[stem] = ""
return playlists
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."""
playlist_folder = _playlist_artifact_dir(folder, playlist)
# Prefer new organized layout.
base_text, base_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base", "base_next.m3u8")
)
if not base_text:
alt_text, alt_exists = _read_text_if_exists(
os.path.join(playlist_folder, "base", "base_prev.m3u8")
)
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(
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
def _fetch_remote_playlists() -> dict[str, str]:
"""Retrieve remote playlists from Plex when connected.
Returns a mapping of playlist name to serialized playlist text. Failures to
connect or fetch playlists are logged and result in an empty mapping to
avoid blocking local testing.
"""
server_config.load()
if not server_config.url:
return {}
try:
plex_client.connect(
token=server_config.token,
scheme=server_config.scheme,
url=server_config.url,
port=server_config.port,
)
except Exception as exc: # pragma: no cover - network access
logger.warning(f"Failed to connect to Plex for remote playlists: {exc}")
return {}
playlists: dict[str, str] = {}
try:
for playlist in plex_client.server.playlists():
paths: list[str] = []
try:
for track in playlist.items():
locations = getattr(track, "locations", None) or []
if locations:
paths.append(locations[0])
except Exception as exc: # pragma: no cover - plex runtime
logger.warning(f"Failed to read playlist '{playlist.title}': {exc}")
playlists[playlist.title] = save_paths(paths)
except Exception as exc: # pragma: no cover - plex runtime
logger.warning(f"Failed to enumerate remote playlists: {exc}")
return {}
return playlists
def _sync_single_playlist(
playlist: str,
mode: SyncMode,
local_text: str | None,
base_text: str,
remote_text: str,
playlist_folder: str,
remote_present: bool,
compiled_rules: CompiledRegexRules | None = None,
) -> PlaylistSyncResult:
local_present = local_text is not None
local_text = local_text or ""
if not base_text:
base_text = "#EXTM3U\n"
if not remote_text:
remote_text = "#EXTM3U\n"
if mode == SyncMode.LOCAL_FORCE:
if not local_present:
_write_delete_marker(playlist, playlist_folder)
return PlaylistSyncResult(playlist, [], [], "deleted", playlist_folder)
base_paths, local_paths, _ = _normalize_inputs(
base_text, local_text, remote_text, playlist_folder
)
merged_lines = list(local_paths)
_write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode == SyncMode.REMOTE_FORCE:
if not remote_present:
_write_delete_marker(playlist, playlist_folder)
return PlaylistSyncResult(playlist, [], [], "deleted", playlist_folder)
base_paths, _, remote_paths = _normalize_inputs(
base_text, local_text, remote_text, playlist_folder
)
merged_lines = list(remote_paths)
_write_results(merged_lines, playlist_folder, compiled_rules)
return PlaylistSyncResult(playlist, merged_lines, [], "synced", playlist_folder)
if mode not in (SyncMode.MERGE_LOCAL_PRIMARY, SyncMode.MERGE_REMOTE_PRIMARY):
raise ValueError(f"Unsupported sync mode: {mode}")
merge_strategy = (
ConflictResolutionStrategy.LOCAL_PRIORITY
if mode == SyncMode.MERGE_LOCAL_PRIMARY
else ConflictResolutionStrategy.REMOTE_PRIORITY
)
merge_result = merge_playlists(
base_text=base_text,
local_text=local_text,
remote_text=remote_text,
strategy=merge_strategy,
test_folder=playlist_folder,
compiled_rules=compiled_rules,
)
if not merge_result.merged_paths and (not local_present or not remote_present):
_write_delete_marker(playlist, playlist_folder)
return PlaylistSyncResult(
playlist, merge_result.merged_paths, merge_result.conflicts, "deleted", playlist_folder
)
return PlaylistSyncResult(
playlist, merge_result.merged_paths, merge_result.conflicts, "synced", playlist_folder
)
def _compile_path_mapping_rules(path_mapping: dict) -> CompiledRegexRules:
"""Compile regex rules from path_mapping config for all four processing stages."""
regex_config = path_mapping.get("regex", {})
return CompiledRegexRules(
local_pre=_compile_regex_rules(regex_config.get("local_pre", [])),
local_post=_compile_regex_rules(regex_config.get("local_post", [])),
remote_pre=_compile_regex_rules(regex_config.get("remote_pre", [])),
remote_post=_compile_regex_rules(regex_config.get("remote_post", [])),
)
def _compile_simple_mapping_rules(simple_mappings: list[dict]) -> CompiledRegexRules:
"""Compile simple mapping pairs into four rule groups using UUID-based mapping_ids.
Each simple mapping has:
- id: UUID used as the mapping_id (unique identifier to prevent conflicts)
- search: Local path prefix
- replace: Cloud path prefix
This generates four rule sets:
- local_pre: Replace local path (search) with mapping_id
- remote_pre: Replace cloud path (replace) with mapping_id
- local_post: Replace mapping_id with local path (search)
- remote_post: Replace mapping_id with cloud path (replace)
The mapping_id is wrapped with special markers to prevent conflicts with actual paths.
"""
local_pre_rules: list[dict[str, str]] = []
local_post_rules: list[dict[str, str]] = []
remote_pre_rules: list[dict[str, str]] = []
remote_post_rules: list[dict[str, str]] = []
# UUID pattern for validation (accepts standard UUID format with or without hyphens)
uuid_pattern = re.compile(r'^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$')
for mapping in simple_mappings:
# Get the mapping values
mapping_id = mapping.get("id")
local_path = mapping.get("search", "") # Local path is stored in 'search' field
cloud_path = mapping.get("replace", "") # Cloud path is stored in 'replace' field
# Validate mapping_id is a proper UUID to prevent injection attacks
if not mapping_id or not isinstance(mapping_id, str):
logger.warning(f"Skipping mapping with missing or invalid id: {mapping}")
continue
if not uuid_pattern.match(mapping_id):
logger.warning(f"Skipping mapping with non-UUID id format: {mapping_id}")
continue
# Paths must be non-empty strings
if not local_path or not isinstance(local_path, str):
logger.warning(f"Skipping mapping with missing local path: {mapping}")
continue
if not cloud_path or not isinstance(cloud_path, str):
logger.warning(f"Skipping mapping with missing cloud path: {mapping}")
continue
# Normalize Windows paths: Replace double backslashes with single backslashes
# This handles cases where users enter escaped paths like \\Koha9-Main\\Music
# when the actual playlist content uses \Koha9-Main\Music
original_local = local_path
original_cloud = cloud_path
local_path = local_path.replace("\\\\", "\\")
cloud_path = cloud_path.replace("\\\\", "\\")
if local_path != original_local or cloud_path != original_cloud:
logger.info(f"Normalized Windows paths:")
logger.info(f" Local: {repr(original_local)} -> {repr(local_path)}")
logger.info(f" Cloud: {repr(original_cloud)} -> {repr(cloud_path)}")
# Create a unique placeholder using the validated UUID
# Using special markers to prevent conflicts with actual paths
placeholder = f"__MAPPING__{mapping_id}__"
# Debug logging for path mapping
logger.debug(f"Simple mapping pair:")
logger.debug(f" Local path (search): {repr(local_path)}")
logger.debug(f" Cloud path (replace): {repr(cloud_path)}")
logger.debug(f" Placeholder: {placeholder}")
# Pre-processing rules (use re.escape to treat paths as literal strings)
# local_pre: Replace local path with placeholder
local_pattern = re.escape(local_path)
logger.debug(f" Local pre pattern: {repr(local_pattern)}")
local_pre_rules.append({
"pattern": local_pattern,
"replacement": placeholder
})
# remote_pre: Replace cloud path with placeholder
remote_pattern = re.escape(cloud_path)
logger.debug(f" Remote pre pattern: {repr(remote_pattern)}")
remote_pre_rules.append({
"pattern": remote_pattern,
"replacement": placeholder
})
# Post-processing rules
# local_post: Replace placeholder with local path
# Note: In regex replacement, backslashes need to be escaped
local_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": local_path.replace("\\", "\\\\")
})
# remote_post: Replace placeholder with cloud path
remote_post_rules.append({
"pattern": re.escape(placeholder),
"replacement": cloud_path.replace("\\", "\\\\")
})
logger.info(f"Compiled {len(local_pre_rules)} simple mapping pairs into rules")
return CompiledRegexRules(
local_pre=_compile_regex_rules(local_pre_rules),
local_post=_compile_regex_rules(local_post_rules),
remote_pre=_compile_regex_rules(remote_pre_rules),
remote_post=_compile_regex_rules(remote_post_rules),
)
def sync_all_playlists(
local_dir: str, mode: SyncMode, test_folder: str = SYNC_ARTIFACTS_DIR
) -> list[PlaylistSyncResult]:
"""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()
# 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)
remote_playlists = _fetch_remote_playlists()
playlist_names: set[str] = set(local_playlists.keys())
playlist_names.update(remote_playlists.keys())
results: list[PlaylistSyncResult] = []
for playlist in sorted(playlist_names):
base_text, snapshot_remote_text, playlist_folder, remote_exists, _ = _load_playlist_snapshots(
playlist, test_folder
)
local_text = local_playlists.get(playlist)
remote_text = remote_playlists.get(playlist)
remote_present = False
if remote_text:
remote_present = True
else:
remote_text = snapshot_remote_text
remote_present = bool(remote_text.strip()) or remote_exists
if compiled_rules:
# Apply pre-processing rules for REGEX or SIMPLE mode
# base_text doesn't need pre-processing as it's the normalized state
if local_text is not None and compiled_rules.local_pre:
logger.debug(f"Applying local_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(local_text[:200])}")
local_text = preprocess_playlist_text(
local_text, [], compiled_rules.local_pre
)
logger.debug(f" After preprocessing (first 200 chars): {repr(local_text[:200])}")
if remote_text and compiled_rules.remote_pre:
logger.debug(f"Applying remote_pre rules to playlist: {playlist}")
logger.debug(f" Before preprocessing (first 200 chars): {repr(remote_text[:200])}")
remote_text = preprocess_playlist_text(
remote_text, [], compiled_rules.remote_pre
)
logger.debug(f" After preprocessing (first 200 chars): {repr(remote_text[:200])}")
elif legacy_compiled_rules:
# Use legacy preprocessing for all texts
base_text = preprocess_playlist_text(
base_text, server_config.path_rules, 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.
result = _sync_single_playlist(
playlist=playlist,
mode=mode,
local_text=local_text,
base_text=base_text,
remote_text=remote_text,
playlist_folder=playlist_folder,
remote_present=remote_present,
compiled_rules=compiled_rules,
)
results.append(result)
return results
+387 -12
View File
@@ -1,17 +1,27 @@
import os
import pickle
from plexapi.myplex import MyPlexAccount from plexapi.myplex import MyPlexAccount
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.library import MusicSection
from urllib.parse import urlparse from urllib.parse import urlparse
from app.utils.common import str_is_empty
from app.utils.logger import logger
MUSIC_LIBRARY_TYPE = "artist"
UNMATCHED_TRACK_RATING_KEY = "unmatched_track"
CACHE_DIR_ID_LENGTH = 6
CACHE_TRACKS_BASE_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "cache")
)
def connect_plex(config, username, password, url, port="32400"): def build_plex_url(scheme, url, port="32400"):
"""Return a connected PlexServer instance and update config with token and server info.""" """Build a full Plex URL from scheme, url, and port."""
token = config.get("token") # 如果url不以http://或https://开头,则添加scheme
if not token: full_url = url
account = MyPlexAccount(username, password) if not full_url.startswith("http://") and not full_url.startswith("https://"):
token = account.authenticationToken full_url = f"{scheme}://{url}"
config["token"] = token parsed = urlparse(full_url)
parsed = urlparse(url)
if parsed.scheme in ("http", "https"): if parsed.scheme in ("http", "https"):
netloc = parsed.netloc or parsed.path netloc = parsed.netloc or parsed.path
@@ -20,6 +30,371 @@ def connect_plex(config, username, password, url, port="32400"):
base_url = f"{parsed.scheme}://{netloc}" base_url = f"{parsed.scheme}://{netloc}"
else: else:
base_url = f"http://{url}:{port}" base_url = f"http://{url}:{port}"
server = PlexServer(base_url, token) return base_url
config.update({"server_url": url, "server_port": port})
return server
class PlexClient:
"""A client for interacting with a Plex server."""
def __init__(self) -> None:
self.server: PlexServer | None = None
self.token: str | None = None
self.base_url: str | None = None
self.connected = False
def connect(
self,
username: str = "",
password: str = "",
token: str = "",
scheme: str = "https",
url: str = "",
port: str = "32400",
timeout: int | None = None,
) -> tuple[PlexServer, str]:
"""Connect to the Plex server using username/password or token.
Parameters:
username (str): Plex username.
password (str): Plex password.
token (str): Plex authentication token.
scheme (str): URL scheme (http or https).
url (str): Plex server URL.
port (str): Plex server port, default is 32400.
Returns:
PlexServer: A connected PlexServer instance.
str: The authentication token used for the connection.
"""
# Connect Server with token if token is provided, otherwise use username/password
try:
if not str_is_empty(token):
self.server, self.token = self._connect_with_token(
token, scheme, url, port, timeout
)
else:
self.server, self.token = self._connect_with_pw(
username, password, scheme, url, port, timeout
)
# Update the base URL and connection status
self.base_url = build_plex_url(scheme, url, port)
self.connected = True
logger.info(f"Connected to Plex server at {self.base_url}.")
return self.server, self.token
except Exception as e:
logger.warning(f"Failed to connect to Plex server: {str(e)}")
self.connected = False
raise
def _connect_with_pw(
self,
username: str,
password: str,
scheme: str,
url: str,
port: str = "32400",
timeout: int | None = None,
):
"""Return a connected PlexServer instance and update config with token and server info."""
# url 初始化
self.base_url = build_plex_url(scheme, url, port)
# account 初始化
account = MyPlexAccount(username, password, timeout=timeout)
# token 获取
self.token = account.authenticationToken
self.server = PlexServer(self.base_url, self.token, timeout=timeout)
logger.debug(f"Connected to Plex server with username: {username}.")
return self.server, self.token
def _connect_with_token(
self,
token: str,
scheme: str,
url: str,
port: str = "32400",
timeout: int | None = None,
):
"""Return a connected PlexServer instance using a token."""
# URL 初始化
self.base_url = build_plex_url(scheme, url, port)
self.server = PlexServer(self.base_url, token, timeout=timeout)
logger.debug("Connected to Plex server with token.")
return self.server, token
def _connect_check(self):
"""Check if the Plex server is connected."""
if not self.connected or not self.server:
logger.error("Plex client is not connected.")
raise RuntimeError("Plex client is not connected.")
def get_server(self) -> PlexServer | None:
"""Return the connected Plex server instance.
returns:
PlexServer: A connected PlexServer instance.
"""
self._connect_check()
return self.server
def get_libs_name_list(self) -> list[str]:
"""Return a list of library names from the Plex server.
returns:
list[str]: A list of all library names.
"""
self._connect_check()
try:
libraries = self.server.library.sections()
music_libs = [lib for lib in libraries if getattr(lib, "type", None) == MUSIC_LIBRARY_TYPE]
lib_names = [lib.title for lib in music_libs]
logger.info(
f"Fetched {len(lib_names)} music library names from the server (type='{MUSIC_LIBRARY_TYPE}')."
)
return lib_names
except Exception as e:
logger.warning(f"Failed to fetch library names: {str(e)}")
raise RuntimeError(f"Failed to fetch library names: {str(e)}")
def get_lib(self, library_name: str) -> MusicSection | None:
"""Return a specific library from the Plex server.
Args:
library_name (str): Name of the library to fetch.
returns:
MusicSection: A MusicSection representing the specified library.
"""
self._connect_check()
try:
lib = self.server.library.section(library_name)
if not lib:
logger.error(f"Library '{library_name}' not found.")
raise ValueError(f"Library '{library_name}' not found.")
elif lib.type != MUSIC_LIBRARY_TYPE:
logger.error(
f"Library '{library_name}' is not a music library. Only music libraries are supported."
)
raise ValueError(
f"Library '{library_name}' is not a music library. Only music libraries are supported."
)
logger.debug(f"Fetched library '{library_name}' from the server.")
return lib
except Exception as e:
logger.warning(f"Failed to fetch library '{library_name}': {str(e)}")
raise RuntimeError(f"Failed to fetch library '{library_name}': {str(e)}")
def get_lib_playlists(self, library_name: str) -> list | None:
"""Return all playlists from the Plex server.
Args:
library_name (str): Name of the library to fetch playlists from.
returns:
list: A list of all playlists in the specified library.
"""
self._connect_check()
lib = self.get_lib(library_name)
# Fetch playlists from the library
try:
playlists = lib.playlists()
logger.info(f"Fetched {len(playlists)} playlists from the server.")
return playlists
except Exception as e:
logger.warning(f"Failed to fetch playlists: {str(e)}")
raise RuntimeError(f"Failed to fetch playlists: {str(e)}")
def get_lib_tracks(self, library_name: str) -> list:
"""Cache all tracks from the Plex server.
Args:
library_name (str): Name of the library to fetch tracks from.
returns:
list: A list of all tracks in the specified library.
"""
self._connect_check()
lib = self.get_lib(library_name)
try:
tracks = lib.all(libtype="track")
logger.info(f"Fetched {len(tracks)} tracks from library '{library_name}'.")
return tracks
except Exception as e:
logger.warning(
f"Failed to fetch tracks from library '{library_name}': {str(e)}"
)
raise RuntimeError(
f"Failed to fetch tracks from library '{library_name}': {str(e)}"
)
def cache_lib_tracks(self, library_name: str) -> dict:
"""Cache all tracks from the Plex server.
All tracks are cached as a mapping of file paths to rating keys.
like: {file_path: ratingKey, ...}
Args:
library_name (str): Name of the library to cache tracks from.
Returns:
dict: A mapping of file paths to rating keys.
"""
try:
all_tracks = self.get_lib_tracks(library_name)
dir_2_ratingKey = {}
for track in all_tracks:
dir_2_ratingKey[track.media[0].parts[0].file] = track.ratingKey
# only use top CACHE_DIR_ID_LENGTH characters of server.machineIdentifier and library key
server_id = self.server.machineIdentifier[:CACHE_DIR_ID_LENGTH]
lib_id = self.get_lib(library_name).uuid[:CACHE_DIR_ID_LENGTH]
# cache directory path
cache_dir = os.path.join(CACHE_TRACKS_BASE_DIR, server_id)
cache_name = f"{lib_id}.pkl"
# Ensure the cache directory exists
os.makedirs(cache_dir, exist_ok=True)
with open(os.path.join(cache_dir, cache_name), "wb") as f:
pickle.dump(dir_2_ratingKey, f, protocol=pickle.HIGHEST_PROTOCOL)
logger.info(
f"Cached {len(dir_2_ratingKey)} tracks from library '{library_name}' to {cache_dir}."
)
return dir_2_ratingKey
except Exception as e:
logger.warning(f"Failed to cache tracks from library '{library_name}': {str(e)}")
raise RuntimeError(f"Failed to cache tracks from library '{library_name}': {str(e)}")
def load_cached_tracks(self, library_name: str) -> dict:
"""Load cached tracks from the local cache file.
Cached tracks will be loaded as a mapping of file paths to rating keys.
like: {file_path: ratingKey, ...}
Args:
library_name (str): Name of the library to load cached tracks from.
Returns:
dict: A mapping of file paths to rating keys.
"""
self._connect_check()
server_id = self.server.machineIdentifier[:CACHE_DIR_ID_LENGTH]
lib_id = self.get_lib(library_name).uuid[:CACHE_DIR_ID_LENGTH]
# check if cache directory exists
cache_dir = os.path.join(CACHE_TRACKS_BASE_DIR, server_id)
if not os.path.exists(cache_dir):
logger.warning(f"Cache directory {cache_dir} does not exist.")
raise FileNotFoundError(f"Cache directory {cache_dir} does not exist. Server may not have cached tracks.")
# check if cache file exists
cache_name = f"{lib_id}.pkl"
cache_path = os.path.join(cache_dir, cache_name)
if not os.path.exists(cache_path):
logger.warning(f"Cache file {cache_path} does not exist.")
raise FileNotFoundError(f"Cache file {cache_path} does not exist. Library {library_name} may not have cached tracks.")
# load cache file
with open(cache_path, "rb") as f:
return pickle.load(f)
def match_tracks(self, library_name: str, local_tracks: list) -> dict:
"""Match local tracks with Plex server tracks ratingKey.
Will return a mapping of local file paths to Plex rating keys.
like: {local_file_path: plex_ratingKey, ...}
unmatched tracks will be assigned a special rating key.
like: {local_file_path: 'unmatched_track'}
Args:
library_name (str): Name of the library to match tracks from.
tracks (list): List of local tracks to match.
Returns:
bidict: A bidirectional mapping of local file paths to Plex rating keys.
"""
self._connect_check()
cached_tracks = self.load_cached_tracks(library_name)
local_2_plex = {}
matched_count = 0
for track in local_tracks:
if track in cached_tracks:
local_2_plex[track] = cached_tracks[track]
matched_count += 1
else:
local_2_plex[track] = UNMATCHED_TRACK_RATING_KEY
logger.info(
f"Matched {matched_count}/{len(local_tracks)} local tracks with Plex server tracks in library '{library_name}'."
)
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()
+160
View File
@@ -0,0 +1,160 @@
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.base import BaseTrigger
from app.utils.config import server_config
from app.utils.logger import logger
from app.utils.watcher import watcher_manager
from app.utils.sync_manager import sync_manager
import os
# Initialize the scheduler
scheduler = BackgroundScheduler()
def validate_cron_expression(expression: str) -> bool:
"""
Validates a cron expression.
Expected format: "minute hour day month day_of_week"
"""
try:
parts = expression.split()
if len(parts) != 5:
return False
# Try to create a trigger to validate
CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4]
)
return True
except Exception:
return False
def job_function():
"""
The function to be executed by the scheduler.
Triggers the sync process.
"""
logger.info("Executing scheduled sync job...")
try:
sync_manager.run_sync(trigger_source="scheduler", wait=False)
except Exception as e:
logger.error(f"Error during scheduled sync job: {e}", exc_info=True)
def start_scheduler():
"""
Starts the background scheduler if it's not already running.
"""
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started.")
update_scheduler_job()
def _create_cron_trigger(cron_exp: str) -> Optional[CronTrigger]:
"""Helper to create a CronTrigger from a cron expression string."""
try:
# 5 parts: minute hour day month day_of_week
parts = cron_exp.split()
if len(parts) == 5:
return CronTrigger(
minute=parts[0],
hour=parts[1],
day=parts[2],
month=parts[3],
day_of_week=parts[4]
)
else:
logger.error(f"Invalid cron expression format (needs 5 parts): {cron_exp}")
except Exception as e:
logger.error(f"Invalid cron expression: {cron_exp}, error: {e}")
return None
def _create_daily_trigger(time_str: str) -> Optional[CronTrigger]:
"""Helper to create a CronTrigger for daily execution at a specific time."""
try:
hour, minute = map(int, time_str.split(':'))
return CronTrigger(hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid daily time format: {time_str}")
return None
def _create_weekly_trigger(days: list[int], time_str: str) -> Optional[CronTrigger]:
"""
Helper to create a CronTrigger for weekly execution.
days: List of integers 0-6 where 0 is Sunday, 1 is Monday, ..., 6 is Saturday.
APScheduler expects: 0 = Monday, ..., 6 = Sunday.
"""
# Convert Frontend days (0=Sun...6=Sat) to APScheduler days (0=Mon...6=Sun)
aps_days = []
for d in days:
if d == 0:
aps_days.append(6) # Sunday
else:
aps_days.append(d - 1) # Mon(1)->0, ..., Sat(6)->5
days_str = ",".join(map(str, aps_days))
try:
hour, minute = map(int, time_str.split(':'))
return CronTrigger(day_of_week=days_str, hour=hour, minute=minute)
except ValueError:
logger.error(f"Invalid weekly time format: {time_str}")
return None
def update_scheduler_job():
"""
Updates the scheduler jobs based on the current configuration.
Reloads configuration, handles auto-watch, and sets up the sync job trigger.
"""
scheduler.remove_all_jobs()
# Reload config to get latest schedule settings
server_config.load()
logger.info("Configuration reloaded for scheduler update.")
# Handle Auto Watch
if server_config.schedule_auto_watch:
# Ensure we have an absolute path
local_path = os.path.abspath(server_config.local_path)
watcher_manager.start(local_path)
logger.info(f"Auto-watch started for path: {local_path}")
else:
watcher_manager.stop()
logger.info("Auto-watch stopped.")
mode = server_config.schedule_mode
logger.info(f"Updating scheduler with mode: {mode}")
if mode == "DISABLED":
logger.info("Schedule is disabled. No jobs added.")
return
trigger: Optional[BaseTrigger] = None
if mode == "CRON":
trigger = _create_cron_trigger(server_config.schedule_cron)
elif mode == "DAILY":
trigger = _create_daily_trigger(server_config.schedule_daily_time)
elif mode == "WEEKLY":
trigger = _create_weekly_trigger(server_config.schedule_weekly_days, server_config.schedule_weekly_time)
if trigger:
scheduler.add_job(job_function, trigger, misfire_grace_time=60, coalesce=True)
logger.info(f"Added scheduled job with mode '{mode}' and trigger: {trigger}")
else:
logger.warning(f"Failed to create trigger for mode '{mode}'. No job added.")
def get_next_run_time():
"""
Returns the next run time of the scheduled job, if any.
"""
jobs = scheduler.get_jobs()
if not jobs:
return None
# Assuming only one job is scheduled for sync
job = jobs[0]
return job.next_run_time
+261
View File
@@ -0,0 +1,261 @@
import threading
import asyncio
import json
import os
import hashlib
import re
import time
from datetime import datetime
from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode
from app.utils.config import server_config
from app.utils.backup import perform_backup_before_sync
from app.utils.local_playlist import load_local_playlist, write_local_playlist, delete_local_playlist
from app.utils.plex_client import plex_client
class SyncManager:
def __init__(self):
self._lock = threading.Lock()
self._is_syncing = False
self._last_sync_time = None
self._last_status = "idle" # idle, syncing, success, error
self._last_error = None
self._listeners = [] # List of asyncio.Queue
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):
self._loop = loop
async def subscribe(self):
q = asyncio.Queue()
self._listeners.append(q)
# Send current status immediately
await q.put(json.dumps(self.status))
return q
def unsubscribe(self, q):
if q in self._listeners:
self._listeners.remove(q)
def _notify_listeners(self):
if not self._loop or not self._listeners:
return
status_json = json.dumps(self.status)
for q in self._listeners:
try:
self._loop.call_soon_threadsafe(q.put_nowait, status_json)
except Exception as e:
logger.error(f"Error notifying listener: {e}")
@property
def is_syncing(self):
with self._lock:
return self._is_syncing
@property
def status(self):
with self._lock:
return {
"is_syncing": self._is_syncing,
"last_sync_time": self._last_sync_time.isoformat() if self._last_sync_time else None,
"status": self._last_status,
"error": str(self._last_error) if self._last_error else None
}
def run_sync(self, trigger_source="manual", wait=False, sync_kwargs=None):
"""
Thread-safe sync execution.
If wait=True, blocks until sync completes and returns result.
If wait=False, runs in background and returns True if started.
"""
with self._lock:
if self._is_syncing:
logger.warning(f"Sync requested ({trigger_source}) but already in progress.")
if wait:
raise Exception("Sync already in progress")
return False
self._is_syncing = True
self._last_status = "syncing"
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()
logger.info(f"Starting sync (Source: {trigger_source})...")
if wait:
try:
result = self._perform_sync(sync_kwargs)
self._complete_sync("success")
return result
except Exception as e:
self._complete_sync("error", e)
raise e
else:
thread = threading.Thread(target=self._sync_worker, args=(trigger_source, sync_kwargs))
thread.start()
return True
def _sync_worker(self, trigger_source, sync_kwargs=None):
try:
self._perform_sync(sync_kwargs)
self._complete_sync("success")
logger.info(f"Sync completed successfully (Source: {trigger_source}).")
except Exception as e:
logger.error(f"Sync failed (Source: {trigger_source}): {e}")
self._complete_sync("error", e)
def _perform_sync(self, sync_kwargs=None):
# Reload config to ensure latest values
server_config.load()
kwargs = {
"local_dir": server_config.local_path,
"mode": SyncMode(server_config.sync_mode)
}
if sync_kwargs:
kwargs.update(sync_kwargs)
# Perform backup before sync if enabled
local_dir = kwargs.get("local_dir", server_config.local_path)
perform_backup_before_sync(local_dir, server_config.library_name)
# Execute sync
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):
now = time.monotonic()
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_error = error
self._last_sync_time = datetime.now()
self._is_syncing = False
self._notify_listeners()
sync_manager = SyncManager()
+129
View File
@@ -0,0 +1,129 @@
import os
import threading
from typing import Optional
from watchdog.observers.polling import PollingObserver as Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from app.utils.logger import logger
from app.utils.sync_manager import sync_manager
class PlaylistEventHandler(FileSystemEventHandler):
"""
Handles file system events for the playlist directory.
Triggers a sync operation when changes are detected, with debouncing.
"""
def __init__(self):
self.debounce_timer: Optional[threading.Timer] = None
self.debounce_interval = 5.0 # Seconds
def on_any_event(self, event: FileSystemEvent):
# Log all events at DEBUG level to avoid cluttering INFO logs
logger.debug(f"[Watcher] Event detected: {event.event_type} {event.src_path}")
if event.is_directory:
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.
# 'opened' and 'closed' (without write) are read events and should be ignored.
if event.event_type not in ['created', 'modified', 'deleted', 'moved']:
return
# Ignore temporary files or hidden files
filename = os.path.basename(event_path)
if filename.startswith('.'):
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
if sync_manager.is_syncing:
logger.debug(f"[Watcher] Ignoring event {event.event_type} on {event_path} because sync is in progress.")
return
# 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()
def trigger_sync(self):
"""
Triggers the sync process after a debounce interval.
"""
if self.debounce_timer:
self.debounce_timer.cancel()
logger.debug(f"[Watcher] Debouncing sync for {self.debounce_interval} seconds...")
self.debounce_timer = threading.Timer(self.debounce_interval, self.run_sync)
self.debounce_timer.start()
def run_sync(self):
"""
Executes the sync via SyncManager.
"""
logger.info("[Watcher] Debounce timer expired. Triggering sync due to file changes.")
try:
sync_manager.run_sync(trigger_source="watcher", wait=False)
except Exception as e:
logger.error(f"[Watcher] Failed to trigger sync: {e}", exc_info=True)
class WatcherManager:
"""
Manages the lifecycle of the file watcher.
"""
def __init__(self):
self.observer: Optional[Observer] = None
self.handler: Optional[PlaylistEventHandler] = None
self.current_path: Optional[str] = None
def start(self, path: str):
"""
Starts watching the specified directory.
"""
# If already watching the same path, do nothing
if self.observer and self.observer.is_alive() and self.current_path == path:
logger.info(f"[Watcher] Already running on {path}")
return
self.stop()
if not os.path.exists(path):
logger.warning(f"[Watcher] Cannot watch path {path}: Directory does not exist.")
return
logger.info(f"[Watcher] Starting file watcher on: {path}")
try:
files = os.listdir(path)
logger.debug(f"[Watcher] Initial files in watch directory: {files}")
except Exception as e:
logger.error(f"[Watcher] Failed to list files in watch directory: {e}")
self.handler = PlaylistEventHandler()
# Explicitly set timeout for PollingObserver
self.observer = Observer(timeout=1.0)
self.observer.schedule(self.handler, path, recursive=True)
self.observer.start()
self.current_path = path
logger.info("[Watcher] Watcher started successfully.")
def stop(self):
"""
Stops the file watcher.
"""
if self.observer:
logger.info("[Watcher] Stopping file watcher...")
self.observer.stop()
self.observer.join()
self.observer = None
self.current_path = None
logger.info("[Watcher] Watcher stopped.")
watcher_manager = WatcherManager()
+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 "$@"
+6
View File
@@ -0,0 +1,6 @@
Write-Output "Starting PlexPlaylistSync Docker Container..."
Set-Location ./frontend
npm run build
Set-Location ..
docker compose down
docker compose up --build
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1015
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
// Animation and timing configuration centralization.
// Adjust these values for debugging or tuning animation behavior.
export const STRIPE_TILE_SIZE = 56.57; // px size for repeating background pattern
export const STRIPE_BASE_SPEED = 56.57; // px per second initial scroll speed
export const STRIPE_DECEL_DURATION_MS = 500; // ms duration of deceleration phase
export const SYNC_SUCCESS_TOTAL_MS = 1000; // ms until header returns to idle after success
export const SYNC_ERROR_RESET_MS = 2000; // ms until reset after error state
export const TOAST_AUTO_DISMISS_MS = 3000; // ms before toast begins exit
export const TOAST_EXIT_DURATION_MS = 300; // ms exit animation duration
// If needed later for entrance timing tweaks
export const TOAST_ENTER_FRAME_DELAY_MS = 0; // logical placeholder (double rAF currently)
// Helper: derive CSS backgroundSize string
export const STRIPE_BACKGROUND_SIZE = `${STRIPE_TILE_SIZE}px ${STRIPE_TILE_SIZE}px`;
// Sync banner sizing (background behind SYNCHRONIZING / SYNC COMPLETE text)
// Adjust these to change the black rectangle size.
export const SYNC_BANNER_PADDING_X = 32; // horizontal padding in px
export const SYNC_BANNER_PADDING_Y = 6; // vertical padding in px
export const SYNC_BANNER_MIN_WIDTH = 260; // optional minimum width (px)
+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;
};
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1HGbFKaSambWckOUfemMSKy_Vm-94xh4D
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+423
View File
@@ -0,0 +1,423 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onConnectSuccess: (serverInfo: PlexServerConnection) => void | Promise<void>;
onShowMessage: (message: string) => void;
initialSettings?: Partial<PlexConnectionSettings>;
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
port: '32400',
token: '',
username: '',
password: '',
timeout: 9,
libraryName: ''
});
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Post-connection state
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null);
const prevIsOpenRef = useRef(isOpen);
// Reset state when opening
useEffect(() => {
// Only execute reset logic when modal opens (isOpen changes from false to true)
if (isOpen && !prevIsOpenRef.current) {
setError(null);
setConnectedServerInfo(null);
setLibraries([]);
setSelectedLibraryId('');
if (initialSettings) {
setFormData(prev => ({
...prev,
protocol: initialSettings.protocol || prev.protocol,
address: initialSettings.address || prev.address,
port: initialSettings.port || prev.port,
token: initialSettings.token || prev.token,
libraryName: initialSettings.libraryName || prev.libraryName,
}));
}
}
// Cleanup when closing
if (!isOpen && prevIsOpenRef.current) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}
prevIsOpenRef.current = isOpen;
}, [isOpen, initialSettings]);
if (!isOpen) return null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value) || 0;
setFormData(prev => ({ ...prev, timeout: val }));
};
const handleLibraryChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newId = e.target.value;
setSelectedLibraryId(newId);
const lib = libraries.find(l => l.id === newId);
if (lib && connectedServerInfo) {
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo);
const saveResult = await apiService.updateLibrary(lib.title);
if (saveResult.status !== 'success') {
onShowMessage(saveResult.message || t('toasts.librarySaveFailed'));
} else {
onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
}
}
};
const isTokenProvided = formData.token.trim().length > 0;
const disabledInputClass = isTokenProvided
? "bg-gray-700/50 text-gray-500 line-through decoration-gray-500 cursor-not-allowed border-gray-700"
: "bg-gray-800 text-gray-100 border-gray-600 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// If already connecting, this acts as Cancel
if (isConnecting) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError(t('toasts.connectionCancelled'));
}
return;
}
setError(null);
setIsConnecting(true);
const abortController = new AbortController();
abortControllerRef.current = abortController;
const result = await apiService.connectToPlex(formData, abortController.signal);
// Only proceed if we weren't aborted/cancelled (though apiService handles error msg)
if (abortController.signal.aborted) return;
setIsConnecting(false);
abortControllerRef.current = null;
if (result.status === 'success' && result.data) {
setFormData(prev => ({
...prev,
token: result.data.token,
username: '',
password: ''
}));
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || [];
const musicLibraries = libs.filter((lib) => lib.type === 'artist').sort((a, b) => a.title.localeCompare(b.title));
setLibraries(musicLibraries);
if (musicLibraries.length > 0) {
const preferred = info.libraryName || formData.libraryName;
const defaultLib = musicLibraries.find(lib => lib.title === preferred) || musicLibraries[0];
setSelectedLibraryId(defaultLib.id);
setFormData(prev => ({ ...prev, libraryName: defaultLib.title }));
onConnectSuccess({
...info,
libraryName: defaultLib.title
});
const saveResult = await apiService.updateLibrary(defaultLib.title);
if (saveResult.status !== 'success') {
setError(saveResult.message || t('toasts.librarySaveFailed'));
}
} else {
onConnectSuccess(info);
}
} else {
setError(result.message || t('server.connectionFailed'));
}
};
const isConnected = !!connectedServerInfo;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-md">
{error}
</div>
)}
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
name="protocol"
value={formData.protocol}
onChange={handleChange}
disabled={isConnected || isConnecting}
className={`w-full h-10 px-2 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div className="col-span-3">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Globe size={14} className="text-gray-500" />
</div>
<input
type="text"
name="address"
required
disabled={isConnected || isConnecting}
placeholder={t('connection.address')}
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
<div>
<input
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder={t('connection.port')}
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
<div className="h-px bg-gray-800 my-4" />
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Key size={14} className="text-plex-orange" />
</div>
<input
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder={t('connection.token')}
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
{!isConnected && (
<>
<div className="text-center text-[10px] text-gray-500 uppercase tracking-widest font-semibold py-1">
OR
</div>
{/* Username */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder={t('connection.username')}
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
</div>
{/* Password */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder={t('connection.password')}
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
<button
type="button"
disabled={isTokenProvided || isConnecting}
onClick={() => setShowPassword(!showPassword)}
className={`absolute inset-y-0 right-0 pr-3 flex items-center ${isTokenProvided ? 'cursor-not-allowed opacity-50' : 'cursor-pointer text-gray-400 hover:text-white'}`}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</>
)}
</div>
{/* Advanced Options */}
{!isConnected && (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-xs font-medium text-gray-400 hover:text-gray-200 transition-colors"
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>{t('connection.advanced')}</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input
type="number"
min="1"
max="60"
name="timeout"
value={formData.timeout || 9}
onChange={handleTimeoutChange}
disabled={isConnecting}
className="w-full h-8 px-2 bg-gray-800 border border-gray-700 rounded-md text-xs text-white focus:border-plex-orange focus:outline-none"
/>
</div>
</div>
)}
</div>
)}
{!isConnected ? (
<button
type="submit"
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg flex items-center justify-center gap-2
${isConnecting
? 'bg-red-500/80 hover:bg-red-500 text-white animate-pulse'
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
}`}
>
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</>
) : t('connection.connectBtn')}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
{t('connection.connectedSuccess')}
</p>
</div>
)}
</form>
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
</div>
<select
value={selectedLibraryId}
onChange={handleLibraryChange}
className="w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange appearance-none cursor-pointer hover:bg-gray-700/50 transition-colors"
>
{libraries.map(lib => (
<option key={lib.id} value={lib.id}>{lib.title}</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<ChevronDown size={14} className="text-gray-500" />
</div>
</div>
<div className="mt-6 flex justify-end">
<button
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"
>
{t('common.done')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ConnectionModal;
+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;
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface PlaylistCardProps {
playlist: Playlist;
}
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
<OverflowMarquee>
{playlist.title}
</OverflowMarquee>
</h4>
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
</div>
</div>
);
};
export default PlaylistCard;
+170
View File
@@ -0,0 +1,170 @@
import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface ServerPanelProps {
type: ServerType;
playlists: Playlist[];
isLoading: boolean;
onRefresh: () => void;
onCancel?: () => void;
serverInfo?: PlexServerConnection;
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud;
let headerColor = isLocal ? 'text-blue-400' : 'text-green-400';
const borderColor = isLocal ? 'border-blue-500/30' : 'border-green-500/30';
const bgGradient = isLocal
? 'bg-gradient-to-br from-gray-800/80 to-gray-900/80'
: 'bg-gradient-to-bl from-gray-800/80 to-gray-900/80';
// Resolve Title and Subtitle Logic
let displayTitle = '';
let displaySubtitle: React.ReactNode = null;
if (isLocal) {
displayTitle = t('server.local');
displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{t('server.playlists', { count: playlists.length })}
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange font-semibold min-w-0 max-w-full">
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
<OverflowMarquee className="hidden md:inline-block">
{serverInfo.libraryName}
</OverflowMarquee>
</span>
<span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div>
);
} else {
displayTitle = t('server.notConnected');
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{t('server.connectionFailed')}
</p>
);
}
} else {
displayTitle = t('server.cloud');
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? t('server.connecting') : t('server.waiting')}
</p>
);
}
}
// Handle Refresh/Cancel Click
const handleAction = () => {
if (isLoading && onCancel) {
onCancel();
} else {
onRefresh();
}
};
return (
<div className={`flex flex-row md:flex-col h-full ${bgGradient} rounded-2xl border ${borderColor} backdrop-blur-xl shadow-xl overflow-hidden transition-all duration-300`}>
{/* Header */}
<div
className={`
relative flex-none
order-last md:order-first
w-[72px] md:w-full
h-full md:h-auto md:min-h-[80px]
flex flex-col md:flex-row items-center justify-between
py-6 md:py-0 md:px-8
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
`}
>
{/* Title Group */}
<div className="flex flex-col md:flex-row items-center md:space-x-4 overflow-hidden w-full md:w-auto h-full md:h-full md:py-4">
{/* Icon Box */}
<div className={`p-2.5 rounded-xl bg-gray-900/50 border border-white/5 ${headerColor} shadow-inner flex-shrink-0 mb-4 md:mb-0`}>
<Icon size={22} strokeWidth={2} />
</div>
{/* Text Container */}
<div className="flex-1 min-w-0 flex flex-col justify-center items-center md:items-start h-full md:h-auto w-full md:w-auto">
<div className="flex flex-col justify-center w-full md:w-auto [writing-mode:vertical-rl] rotate-180 md:[writing-mode:horizontal-tb] md:rotate-0 items-center md:items-start gap-1 md:gap-0">
<h2 className="text-sm md:text-lg font-bold text-gray-100 tracking-wide whitespace-nowrap" title={displayTitle}>
{displayTitle}
</h2>
<div className="transform md:translate-y-0">
{displaySubtitle}
</div>
</div>
</div>
</div>
{/* Refresh/Stop Button */}
<button
onClick={handleAction}
className={`flex-shrink-0 p-2.5 rounded-full transition-all active:scale-90 mt-4 md:mt-0 md:ml-4 border border-transparent group relative
${isLoading
? 'text-plex-orange bg-plex-orange/10 border-plex-orange/20 hover:bg-red-500/10 hover:border-red-500/30'
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
{/* Outer Spinner */}
<RefreshCw size={20} strokeWidth={2} className="animate-spin opacity-40 group-hover:opacity-20 transition-opacity" />
{/* Inner Cancel X */}
<X size={12} strokeWidth={3} className="absolute text-plex-orange group-hover:text-red-400 transition-colors" />
</div>
) : (
<RefreshCw size={20} strokeWidth={2} />
)}
</button>
</div>
{/* Content List */}
<div className="flex-1 overflow-y-auto p-3 md:p-5 custom-scrollbar bg-black/20">
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">{t('server.noPlaylists')}</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
{playlists.map((playlist) => (
<PlaylistCard key={playlist.id} playlist={playlist} />
))}
</div>
)}
</div>
</div>
);
};
export default ServerPanel;
+938
View File
@@ -0,0 +1,938 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import {
ArrowRightCircle,
ArrowLeftCircle,
GitMerge,
ChevronDown,
Check,
HelpCircle,
Plus,
Trash2,
Save,
RotateCcw,
Zap,
Loader2,
Calendar,
Clock,
Repeat,
Type,
Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react';
import { useLanguage } from '../LanguageContext';
// Generate a UUID for mapping rules
const generateUUID = (): string => {
// Use crypto.randomUUID() if available (modern browsers)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback to manual UUID v4 generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
interface StrategyOption {
value: SyncStrategy;
labelKey: string;
descKey: string;
icon: React.ElementType;
color: string;
}
const STRATEGIES: StrategyOption[] = [
{
value: SyncStrategy.LOCAL_OVERWRITE,
labelKey: 'strategies.localOverwrite.label',
descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle,
color: 'text-blue-400'
},
{
value: SyncStrategy.CLOUD_OVERWRITE,
labelKey: 'strategies.cloudOverwrite.label',
descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle,
color: 'text-green-400'
},
{
value: SyncStrategy.MERGE_LOCAL,
labelKey: 'strategies.mergeLocal.label',
descKey: 'strategies.mergeLocal.desc',
icon: GitMerge,
color: 'text-blue-300'
},
{
value: SyncStrategy.MERGE_CLOUD,
labelKey: 'strategies.mergeCloud.label',
descKey: 'strategies.mergeCloud.desc',
icon: GitMerge,
color: 'text-green-300'
}
];
const WEEK_DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6];
// Color Theme Variables for Mapping Editors
const MAPPING_THEME = {
// Container Themes
local: {
borderColor: "border-blue-500/20",
bgColor: "bg-blue-900/10"
},
remote: {
borderColor: "border-green-500/20",
bgColor: "bg-green-900/10"
},
simple: {
borderColor: "border-gray-700/50",
bgColor: "bg-gray-900/40"
},
// Input Field Themes
inputs: {
default: "bg-gray-800 border-gray-700 text-gray-200 focus:border-plex-orange placeholder-gray-600",
local: "bg-blue-500/10 border-blue-500/30 text-blue-100 focus:border-blue-400 placeholder-blue-300/30",
cloud: "bg-green-500/10 border-green-500/30 text-green-100 focus:border-green-400 placeholder-green-300/30"
}
};
// Helper to determine the actual mode and settings that would be saved based on the current UI state
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule };
// Unified logic: If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was DISABLED), then in the context of this tab, it remains Disabled until the user toggles the switch.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
return derived;
};
// Sub-component for a single Mapping Group Editor
interface MappingGroupEditorProps {
title: string;
subtitle?: string;
rules: ReplacementRule[];
onChange: (newRules: ReplacementRule[]) => void;
isLocked: boolean;
borderColor?: string;
bgColor?: string;
// Input specific props
leftPlaceholder?: string;
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
title,
subtitle,
rules,
onChange,
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftInputClass,
rightInputClass
}) => {
const { t } = useLanguage();
const handleAdd = () => {
if (isLocked) return;
const newId = generateUUID();
onChange([...rules, { id: newId, search: '', replace: '' }]);
};
const handleUpdate = (id: string, field: 'search' | 'replace', value: string) => {
if (isLocked) return;
onChange(rules.map(r => r.id === id ? { ...r, [field]: value } : r));
};
const handleDelete = (id: string) => {
if (isLocked) return;
onChange(rules.filter(r => r.id !== id));
};
// Default input style if not provided
const defaultInputStyle = MAPPING_THEME.inputs.default;
return (
<div className={`p-3 rounded-lg border ${borderColor} ${bgColor} flex flex-col h-full transition-colors`}>
<div className="flex items-center justify-between mb-2">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-gray-400">{title}</h4>
{subtitle && <p className="text-[9px] text-gray-500">{subtitle}</p>}
</div>
<button
onClick={handleAdd}
disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title={t('common.add')}
>
<Plus size={12} />
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
{t('mapping.noRules')}
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder || t('mapping.pattern')}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
/>
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder || t('mapping.replace')}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
/>
<button
onClick={() => handleDelete(rule.id)}
className="text-gray-600 hover:text-red-400 p-1 rounded hover:bg-red-500/10 transition-colors flex-none"
>
<Trash2 size={12} />
</button>
</div>
))
)}
</div>
</div>
);
};
interface StrategySelectorProps {
currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void;
savedPathMapping: PathMappingConfig;
onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState;
onSync: () => void;
}
const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy,
onSelect,
savedPathMapping,
onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule,
onSaveSchedule,
syncState,
onSync
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for path mapping editing (stores all lists for both modes)
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isMappingDirty, setIsMappingDirty] = useState(false);
// Local state for Backup Settings
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
const [isBackupDirty, setIsBackupDirty] = useState(false);
// Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs
const [activeScheduleTab, setActiveScheduleTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
);
const isSyncing = syncState === SyncState.SYNCING;
const isLocked = isSyncing || syncState === SyncState.SUCCESS;
// Initialize local state when prop updates
useEffect(() => {
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
setIsMappingDirty(false);
}, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode);
}
setIsScheduleDirty(false);
}, [savedSchedule]);
// Check dirty state whenever local mapping changes
useEffect(() => {
const isDifferent = JSON.stringify(localPathMapping) !== JSON.stringify(savedPathMapping);
setIsMappingDirty(isDifferent);
}, [localPathMapping, savedPathMapping]);
// Check dirty state for backup
useEffect(() => {
const isDifferent = JSON.stringify(localBackup) !== JSON.stringify(savedBackup);
setIsBackupDirty(isDifferent);
}, [localBackup, savedBackup]);
// Check dirty state for Schedule (including Active Tab changes)
useEffect(() => {
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeScheduleTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const isScheduleActionable = isScheduleDirty || (activeScheduleTab !== (savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode));
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, t(strategy.labelKey));
};
// --- Path Mapping Handlers ---
const currentMappingMode = localPathMapping.mode;
const updateRegexGroup = (section: keyof PathMappingRules, newRules: ReplacementRule[]) => {
if (isLocked) return;
setLocalPathMapping(prev => ({
...prev,
regex: {
...prev.regex,
[section]: newRules
}
}));
};
const updateSimpleGroup = (newRules: ReplacementRule[]) => {
if (isLocked) return;
setLocalPathMapping(prev => ({
...prev,
simple: newRules
}));
};
const setMappingMode = (mode: PathMappingMode) => {
if (isLocked) return;
setLocalPathMapping(prev => ({ ...prev, mode }));
};
const handleResetMapping = () => {
if (isLocked) return;
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
};
const handleSaveMappingClick = () => {
if (isLocked) return;
const clean = (rules: ReplacementRule[]) => rules.filter(r => r.search.trim() !== '');
// Clean regex rules
const cleanRegex = (rules: PathMappingRules): PathMappingRules => ({
localPre: clean(rules.localPre),
localPost: clean(rules.localPost),
remotePre: clean(rules.remotePre),
remotePost: clean(rules.remotePost),
});
const cleanedConfig: PathMappingConfig = {
mode: localPathMapping.mode,
simple: clean(localPathMapping.simple),
regex: cleanRegex(localPathMapping.regex),
};
setLocalPathMapping(cleanedConfig);
onSavePathMapping(cleanedConfig);
};
const regexRules = localPathMapping.regex;
const simpleRules = localPathMapping.simple;
// --- Backup Handlers ---
const handleUpdateBackup = (field: keyof BackupSettings, value: any) => {
if (isLocked) return;
setLocalBackup(prev => ({ ...prev, [field]: value }));
};
const handleResetBackup = () => {
if (isLocked) return;
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
};
const handleSaveBackupClick = () => {
if (isLocked) return;
onSaveBackup(localBackup);
};
// --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return;
setLocalSchedule(prev => ({ ...prev, [field]: value }));
};
const toggleWeekDay = (dayIndex: number) => {
if (isLocked) return;
const currentDays = localSchedule.weeklyDays;
const newDays = currentDays.includes(dayIndex)
? currentDays.filter(d => d !== dayIndex)
: [...currentDays, dayIndex].sort();
handleUpdateSchedule('weeklyDays', newDays);
};
const handleResetSchedule = () => {
if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode);
} else {
setActiveScheduleTab(ScheduleMode.CRON);
}
};
const handleSaveScheduleClick = async () => {
if (isLocked) return;
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const success = await onSaveSchedule(settingsToSave);
if (success) {
setLocalSchedule(settingsToSave);
}
};
const handleSyncClick = () => {
if (isLocked) return;
onSync();
};
const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return;
if (localSchedule.mode === targetMode) {
handleUpdateSchedule('mode', ScheduleMode.DISABLED);
} else {
handleUpdateSchedule('mode', targetMode);
}
};
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return (
<div className="relative group" ref={dropdownRef}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`${t('strategies.title')}: ${t(selectedOption.labelKey)}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
<ChevronDown size={10} className="text-gray-400" />
</div>
</button>
{/* Dropdown Menu */}
<div
className={`absolute
top-14
/* Mobile: Open to left (max width of screen) */
right-0 w-[90vw] max-w-[90vw] origin-top-right
/* Desktop: Center alignment, wider */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2 md:w-[60rem] md:max-w-[60rem]
bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
>
<div className={`flex flex-col max-h-[75vh] md:max-h-[85vh] overflow-y-auto custom-scrollbar ${contentClass}`}>
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
key={strategy.value}
onClick={() => handleSelect(strategy)}
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
currentStrategy === strategy.value
? 'bg-white/10 border-white/10 shadow-sm'
: 'hover:bg-white/5 border-transparent'
}`}
>
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{t(strategy.labelKey)}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{t(strategy.descKey)}
</div>
</div>
{currentStrategy === strategy.value && (
<Check size={14} className="text-plex-orange" strokeWidth={3} />
)}
</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="0"
max="100"
value={localBackup.retentionCount}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
handleUpdateBackup('retentionCount', isNaN(value) ? 0 : Math.max(0, value));
}}
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
/>
<span className="text-[10px] text-gray-600 italic">{localBackup.retentionCount === 0 ? t('backup.noAutoDelete') : t('backup.autoDelete')}</span>
</div>
</div>
</div>
<div className="flex justify-end items-center gap-2 pt-1">
<button
onClick={handleResetBackup}
disabled={!isBackupDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isBackupDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveBackupClick}
disabled={!isBackupDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isBackupDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>{t('common.save')}</span>
</button>
</div>
</div>
</div>
{/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('mapping.title')}</h3>
</div>
{/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => (
<button
key={tab.id}
onClick={() => setMappingMode(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${currentMappingMode === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className="mb-4">
{currentMappingMode === PathMappingMode.SIMPLE ? (
// Simple Mode: Single Editor
<div className="animate-in fade-in duration-200">
<MappingGroupEditor
title={t('mapping.simpleTitle')}
subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder={t('mapping.localPath')}
rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
/>
</div>
) : (
// Regex Mode: 2x2 Grid
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title={t('server.local')}
subtitle={t('mapping.regexPre')}
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title={t('server.cloud')}
subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title={t('server.local')}
subtitle={t('mapping.regexPost')}
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
/>
<MappingGroupEditor
title={t('server.cloud')}
subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
/>
</div>
)}
</div>
<div className="flex justify-end items-center gap-2">
<button
onClick={handleResetMapping}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isMappingDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveMappingClick}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isMappingDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>{t('mapping.saveRules')}</span>
</button>
</div>
</div>
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div>
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveScheduleTab(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${activeScheduleTab === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="mb-4 min-h-[50px]">
{activeScheduleTab === ScheduleMode.CRON && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableCron')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.CRON ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Content */}
<div className={`space-y-2 transition-opacity duration-200 ${localSchedule.mode !== ScheduleMode.CRON ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span>
<input
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>
)}
{activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableDaily')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
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'}`}
>
<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>
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-2">
<input
type="time"
value={localSchedule.dailyTime}
onChange={(e) => handleUpdateSchedule('dailyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.DAILY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.DAILY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
{activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
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'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.WEEKLY ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Middle Row: Full Width Capsules */}
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
{WEEK_DAY_INDEXES.map((dayIndex) => {
const isSelected = localSchedule.weeklyDays.includes(dayIndex);
return (
<button
key={dayIndex}
onClick={() => toggleWeekDay(dayIndex)}
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
first:rounded-l-lg last:rounded-r-lg
${isSelected
? 'bg-plex-orange text-gray-900 border-plex-orange z-10'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
}
`}
>
{t(`schedule.weekdaysNarrow.${dayIndex}`)}
</button>
)
})}
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-1">
<input
type="time"
value={localSchedule.weeklyTime}
onChange={(e) => handleUpdateSchedule('weeklyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.WEEKLY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
</div>
{/* Auto Watch Switch */}
<div className="flex items-center justify-between mb-4 mt-2 px-1">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 text-orange-400">
<Eye size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
className={`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'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button
onClick={handleResetSchedule}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isScheduleActionable
? '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={handleSaveScheduleClick}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isScheduleActionable
? '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 4: Sync Now Button */}
<div className="p-4 bg-gray-950/50 border-t border-white/5 flex-none">
<button
onClick={handleSyncClick}
disabled={isLocked}
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isMappingDirty || isBackupDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`}
>
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>{t('strategies.syncing')}</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>{t('strategies.syncNow')}</span>
</>
)}
</button>
{(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2">
{t('strategies.saveWarning')}
</p>
)}
</div>
</div>
</div>
);
};
export default StrategySelector;
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
plex: {
orange: '#e5a00d',
dark: '#1f2937',
darker: '#111827',
card: '#374151'
}
}
}
}
}
</script>
<style>
/* Custom scrollbar for webkit to match dark theme */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #111827;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Overflow marquee (auto-scroll when truncated) */
.overflow-marquee {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.overflow-marquee__text {
display: inline-block;
will-change: transform;
}
.overflow-marquee__text--animate {
animation: overflow-marquee-scroll var(--marquee-duration, 6s) linear infinite;
}
@keyframes overflow-marquee-scroll {
0%, 12% {
transform: translateX(0);
}
70%, 86% {
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.overflow-marquee__text--animate {
animation: none;
}
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
}
}
</script>
<script type="module" src="/index.tsx"></script>
</head>
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
<div id="root"></div>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</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: '保存媒体库选择失败。',
},
};
+5
View File
@@ -0,0 +1,5 @@
{
"name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+1771
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "plexsync-manager",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+308
View File
@@ -0,0 +1,308 @@
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 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> = {
local_force: SyncStrategy.LOCAL_OVERWRITE,
remote_force: SyncStrategy.CLOUD_OVERWRITE,
merge_local_primary: SyncStrategy.MERGE_LOCAL,
merge_remote_primary: SyncStrategy.MERGE_CLOUD,
};
const STRATEGY_TO_MODE: Record<SyncStrategy, string> = {
[SyncStrategy.LOCAL_OVERWRITE]: 'local_force',
[SyncStrategy.CLOUD_OVERWRITE]: 'remote_force',
[SyncStrategy.MERGE_LOCAL]: 'merge_local_primary',
[SyncStrategy.MERGE_CLOUD]: 'merge_remote_primary',
};
const handleResponse = async <T>(response: Response): Promise<ApiResponse<T>> => {
try {
const data = await response.json();
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, status: 'success' };
} catch (error: any) {
return { data: {} as T, status: 'error', message: error?.message || 'Unexpected error' };
}
};
const mapPlaylist = (item: any): Playlist => ({
id: item.id || `${item.title}-${item.trackCount}`,
title: item.title ?? item.name ?? 'Unknown',
trackCount: item.trackCount ?? item.track_count ?? 0,
lastUpdated: item.lastUpdated || item.last_updated || new Date().toISOString(),
});
const mapLibrary = (item: any): PlexLibrary => ({
id: item.id ?? item.title,
title: item.title ?? item.id,
type: item.type || item.libraryType || item.library_type || item.section?.type || '',
});
// Helper function to map raw rules array to ReplacementRule[]
const mapReplacementRules = (rules: any[]): ReplacementRule[] =>
(rules || []).map((rule, index) => ({
id: rule.id || `${rule.search || 'rule'}-${index}-${Date.now()}`,
search: rule.search || rule.pattern || '',
replace: rule.replace || rule.replacement || '',
}));
// Helper function to map API path_mapping response to PathMappingConfig
const mapPathMappingConfig = (data: any): PathMappingConfig => {
const defaultConfig: PathMappingConfig = {
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
};
if (!data || !data.path_mapping) {
return defaultConfig;
}
const pm = data.path_mapping;
return {
mode: pm.mode === 'REGEX' ? PathMappingMode.REGEX : PathMappingMode.SIMPLE,
simple: mapReplacementRules(pm.simple || []),
regex: {
localPre: mapReplacementRules(pm.regex?.localPre || pm.regex?.local_pre || []),
localPost: mapReplacementRules(pm.regex?.localPost || pm.regex?.local_post || []),
remotePre: mapReplacementRules(pm.regex?.remotePre || pm.regex?.remote_pre || []),
remotePost: mapReplacementRules(pm.regex?.remotePost || pm.regex?.remote_post || [])
}
};
};
// Helper function to convert PathMappingConfig to API format
const pathMappingToApi = (config: PathMappingConfig) => {
const rulesToApi = (rules: ReplacementRule[]) =>
rules.map(({ id, search, replace }) => ({ id, search, replace }));
return {
mode: config.mode,
simple: rulesToApi(config.simple),
regex: {
local_pre: rulesToApi(config.regex.localPre),
local_post: rulesToApi(config.regex.localPost),
remote_pre: rulesToApi(config.regex.remotePre),
remote_post: rulesToApi(config.regex.remotePost)
}
};
};
export const apiService = {
async getAuthConfig(): Promise<ApiResponse<{ enabled: boolean }>> {
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);
if (result.status === 'success') {
const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const pathMapping = mapPathMappingConfig(result.data);
const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '',
port: result.data.port || '32400',
token: result.data.token || '',
libraryName: result.data.library_name || '',
};
return { status: 'success', data: { strategy, pathMapping, connection, localPath: result.data.local_path || '' } };
}
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; pathMapping: PathMappingConfig; connection: PlexConnectionSettings; localPath: string }>;
},
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
const payload = { mode: STRATEGY_TO_MODE[strategy] };
const response = await authFetch(`${API_BASE}/api/settings/sync-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return handleResponse(response);
},
async savePathMapping(config: PathMappingConfig): Promise<ApiResponse<null>> {
const payload = pathMappingToApi(config);
const response = await authFetch(`${API_BASE}/api/settings/path-mapping`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return handleResponse(response);
},
async updateLibrary(libraryName: string): Promise<ApiResponse<{ library_name: string }>> {
const response = await authFetch(`${API_BASE}/api/settings/library`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_name: libraryName }),
});
return handleResponse(response);
},
async getScheduleSettings(): Promise<ApiResponse<ScheduleSettings & { nextRun?: string }>> {
const response = await authFetch(`${API_BASE}/api/schedule`);
return handleResponse(response);
},
async saveScheduleSettings(settings: ScheduleSettings): Promise<ApiResponse<null>> {
const response = await authFetch(`${API_BASE}/api/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return handleResponse(response);
},
async getPlaylists(serverType: ServerType, signal?: AbortSignal, localPath?: string): Promise<ApiResponse<Playlist[]>> {
const params = new URLSearchParams({ server: serverType.toLowerCase() });
if (serverType === ServerType.LOCAL && localPath) {
params.append('local_path', localPath);
}
const response = await authFetch(`${API_BASE}/api/playlists?${params.toString()}`, { signal });
const result = await handleResponse<any>(response);
if (result.status === 'success' && (result.data as any)?.playlists) {
return { data: (result.data.playlists as any[]).map(mapPlaylist), status: 'success' };
}
return result as ApiResponse<Playlist[]>;
},
async getServerStatus(signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> {
const response = await authFetch(`${API_BASE}/api/server`, { signal });
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const info = result.data.serverInfo || {};
const libraries: PlexLibrary[] = (result.data.libraries || []).map(mapLibrary);
return {
status: 'success',
data: {
isConnected: !!info.isConnected,
name: info.name,
ip: info.ip,
port: info.port ? Number(info.port) : undefined,
libraryName: info.libraryName,
libraries,
},
};
}
return result as ApiResponse<PlexServerConnection>;
},
async connectToPlex(settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string; serverInfo: PlexServerConnection }>> {
const response = await authFetch(`${API_BASE}/api/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
protocol: settings.protocol,
address: settings.address,
port: settings.port,
token: settings.token,
username: settings.username,
password: settings.password,
library_name: settings.libraryName,
timeout: settings.timeout,
}),
signal,
});
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const info = result.data.serverInfo;
info.libraries = (info.libraries || []).map(mapLibrary);
return { status: 'success', data: { token: result.data.token, serverInfo: info } };
}
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
},
async syncPlaylists(strategy: SyncStrategy, _pathMapping: PathMappingConfig, localPath?: string): Promise<ApiResponse<null>> {
const response = await authFetch(`${API_BASE}/api/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: STRATEGY_TO_MODE[strategy],
local_path: localPath,
}),
});
return handleResponse(response);
},
async getSyncStatus(): Promise<ApiResponse<{ is_syncing: boolean; last_sync_time: string | null; status: string; error: string | null }>> {
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);
},
};
+14
View File
@@ -0,0 +1,14 @@
import { Playlist } from '../types';
export const MOCK_LOCAL_PLAYLISTS: Playlist[] = [
{ id: 'l1', title: 'Road Trip 2024', trackCount: 45, lastUpdated: '2023-10-25T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' },
{ id: 'l2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' },
{ id: 'l3', title: '90s Rock', trackCount: 32, lastUpdated: '2023-10-20T09:15:00Z', thumbnail: 'https://picsum.photos/200/200?random=3' },
{ id: 'l4', title: 'Gym Pump', trackCount: 50, lastUpdated: '2023-10-22T18:45:00Z', thumbnail: 'https://picsum.photos/200/200?random=4' },
];
export const MOCK_CLOUD_PLAYLISTS: Playlist[] = [
{ id: 'c1', title: 'Road Trip 2024', trackCount: 42, lastUpdated: '2023-10-24T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' }, // Slightly out of sync
{ id: 'c2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' },
{ id: 'c5', title: 'Chill Vibes', trackCount: 88, lastUpdated: '2023-10-19T20:20:00Z', thumbnail: 'https://picsum.photos/200/200?random=5' },
];
+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;
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node",
"vite/client"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+129
View File
@@ -0,0 +1,129 @@
export interface Track {
id: string;
title: string;
artist: string;
duration: number; // in seconds
}
export interface Playlist {
id: string;
title: string;
trackCount: number;
thumbnail?: string;
lastUpdated: string;
tracks?: Track[]; // Optional detailed track list
}
export enum ServerType {
LOCAL = 'LOCAL',
CLOUD = 'CLOUD'
}
export enum SyncStrategy {
LOCAL_OVERWRITE = 'LOCAL_OVERWRITE',
CLOUD_OVERWRITE = 'CLOUD_OVERWRITE',
MERGE_LOCAL = 'MERGE_LOCAL',
MERGE_CLOUD = 'MERGE_CLOUD'
}
export enum SyncState {
IDLE = 'IDLE',
SYNCING = 'SYNCING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR'
}
export interface ReplacementRule {
id: string;
search: string;
replace: string;
}
export interface PathMappingRules {
localPre: ReplacementRule[];
localPost: ReplacementRule[];
remotePre: ReplacementRule[];
remotePost: ReplacementRule[];
}
export enum PathMappingMode {
SIMPLE = 'SIMPLE',
REGEX = 'REGEX'
}
export interface PathMappingConfig {
mode: PathMappingMode;
simple: ReplacementRule[];
regex: PathMappingRules;
}
export interface BackupSettings {
enabled: boolean;
retentionCount: number;
}
export interface RegexReplacement {
id: string;
pattern: string;
replacement: string;
}
export enum ScheduleMode {
DISABLED = 'DISABLED',
CRON = 'CRON',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY'
}
export interface ScheduleSettings {
mode: ScheduleMode;
cronExpression: string;
dailyTime: string;
weeklyDays: number[]; // 0 = Sunday, 1 = Monday, etc.
weeklyTime: string;
autoWatch: boolean;
}
export interface PlexLibrary {
id: string;
title: string;
type: string;
}
export interface PlexServerConnection {
isConnected: boolean;
name?: string;
ip?: string;
port?: number;
libraryName?: string;
libraries?: PlexLibrary[];
}
export interface PlexConnectionSettings {
protocol: 'http' | 'https';
address: string;
port: string;
token: string;
username?: string;
password?: string;
libraryName?: string;
timeout?: number; // in seconds
}
export interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
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" />
+30
View File
@@ -0,0 +1,30 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path,
},
},
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});
+3
View File
@@ -3,3 +3,6 @@ uvicorn[standard]
jinja2 jinja2
python-multipart python-multipart
plexapi plexapi
merge3
apscheduler
watchdog
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+913
View File
@@ -0,0 +1,913 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api';
import {
STRIPE_BASE_SPEED,
STRIPE_DECEL_DURATION_MS,
STRIPE_TILE_SIZE,
STRIPE_BACKGROUND_SIZE,
SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS
} from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal';
import 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 {
id: number;
message: string;
exiting: boolean;
entering: boolean;
}
// Custom hook to handle the stripe animation logic
const useStripeAnimation = (syncState: SyncState) => {
const leftYellowRef = useRef<HTMLDivElement>(null);
const leftGreenRef = useRef<HTMLDivElement>(null);
const rightYellowRef = useRef<HTMLDivElement>(null);
const rightGreenRef = useRef<HTMLDivElement>(null);
const requestRef = useRef<number | undefined>(undefined);
const lastTimeRef = useRef<number>(0);
const offsetRef = useRef<number>(0);
// State tracking for deceleration
const isDeceleratingRef = useRef(false);
const decelStartTimeRef = useRef(0);
const animate = (time: number) => {
if (lastTimeRef.current === 0) lastTimeRef.current = time;
const dt = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
let speed = STRIPE_BASE_SPEED; // pixels per second
if (isDeceleratingRef.current) {
const t = time - decelStartTimeRef.current;
const duration = STRIPE_DECEL_DURATION_MS; // deceleration duration
if (t >= duration) {
speed = 0;
} else {
// Linear slow down
speed = speed * (1 - (t / duration));
}
}
// Update offset
offsetRef.current += speed * dt;
const modOffset = offsetRef.current % STRIPE_TILE_SIZE;
// Apply to DOM elements directly for performance
const leftPos = `right ${modOffset}px top 0`;
const rightPos = `left ${modOffset}px top 0`;
if (leftYellowRef.current) leftYellowRef.current.style.backgroundPosition = leftPos;
if (leftGreenRef.current) leftGreenRef.current.style.backgroundPosition = leftPos;
if (rightYellowRef.current) rightYellowRef.current.style.backgroundPosition = rightPos;
if (rightGreenRef.current) rightGreenRef.current.style.backgroundPosition = rightPos;
// Continue loop if moving or if we are in the middle of decelerating
if (speed > 0 || (isDeceleratingRef.current && (time - decelStartTimeRef.current) < STRIPE_DECEL_DURATION_MS)) {
requestRef.current = requestAnimationFrame(animate);
}
};
useEffect(() => {
if (syncState === SyncState.SYNCING) {
isDeceleratingRef.current = false;
lastTimeRef.current = 0;
// Start animation loop
if (!requestRef.current) {
requestRef.current = requestAnimationFrame(animate);
}
} else if (syncState === SyncState.SUCCESS) {
isDeceleratingRef.current = true;
decelStartTimeRef.current = performance.now();
// Ensure loop is running to handle deceleration phase
if (!requestRef.current) {
requestRef.current = requestAnimationFrame(animate);
}
} else {
// IDLE or ERROR: Stop animation
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = undefined;
}
offsetRef.current = 0;
}
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = undefined;
}
};
}, [syncState]);
return { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef };
};
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 [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
const [loadingLocal, setLoadingLocal] = useState(false);
const [loadingCloud, setLoadingCloud] = useState(false);
// Sync State
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
// Animation Refs
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
// Abort Controllers for Refresh Actions
const localAbortRef = useRef<AbortController | null>(null);
const cloudAbortRef = useRef<AbortController | null>(null);
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Path Mapping State (Includes Simple and Regex Rules)
const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
mode: ScheduleMode.DISABLED,
cronExpression: '',
dailyTime: '02:00',
weeklyDays: [0], // Sunday
weeklyTime: '03:00',
autoWatch: false
});
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id));
if (timeoutsRef.current[id]) {
clearTimeout(timeoutsRef.current[id]);
delete timeoutsRef.current[id];
}
};
const addToast = (message: string) => {
const id = Date.now();
// Start with entering: true to position it above
const newToast: Toast = { id, message, exiting: false, entering: true };
setToasts(prev => {
// Mark all existing toasts as exiting immediately so they slide up
const exitingToasts = prev.map(t => ({ ...t, exiting: true, entering: false }));
return [...exitingToasts, newToast];
});
// Auto dismiss the new toast after 3 seconds
const dismissTimer = setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
}, TOAST_AUTO_DISMISS_MS);
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
useEffect(() => {
const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
if (enteringIds.length > 0) {
let raf1: number;
let raf2: number;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
setToasts(prev => prev.map(t =>
enteringIds.includes(t.id) ? { ...t, entering: false } : t
));
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [toasts]);
// Cleanup effect for exiting toasts
useEffect(() => {
const exitingToasts = toasts.filter(t => t.exiting);
exitingToasts.forEach(t => {
if (!timeoutsRef.current[`remove-${t.id}`]) {
timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => {
removeToast(t.id);
delete timeoutsRef.current[`remove-${t.id}`];
}, TOAST_EXIT_DURATION_MS);
}
});
}, [toasts]);
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (!isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
setLoadingLocal(true);
const result = await apiService.getPlaylists(ServerType.LOCAL, abortController.signal);
if (result.status === 'success') {
setLocalPlaylists(result.data);
}
setLoadingLocal(false);
localAbortRef.current = null;
}, [isAuthenticated]);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast(t('toasts.localRefreshCancelled'));
}
};
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (!isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
setLoadingCloud(true);
// Fetch playlists
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD, abortController.signal);
if (!abortController.signal.aborted) {
if (playlistResult.status === 'success') {
setCloudPlaylists(playlistResult.data);
}
// Fetch server info
const infoResult = await apiService.getServerStatus(abortController.signal);
if (infoResult.status === 'success') {
setCloudServerInfo(infoResult.data);
}
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, [isAuthenticated]);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast(t('toasts.cloudRefreshCancelled'));
}
};
// Initial Load (Only if Authenticated)
useEffect(() => {
if (isAuthenticated) {
refreshLocal();
refreshCloud();
}
return () => {
// Cleanup on unmount
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy);
addToast(t('toasts.strategySaved', { strategy: label }));
};
// Handle Path Mapping Save
const handleSavePathMapping = (config: PathMappingConfig) => {
setPathMappingConfig(config);
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
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
// Call API (validation happens in Mock)
const result = await apiService.saveScheduleSettings(settings);
if (result.status === 'success') {
// Only update local state if successful
setScheduleSettings(settings);
if (settings.mode === ScheduleMode.DISABLED) {
addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast(t('toasts.scheduleEmpty'));
} else {
addToast(t('toasts.scheduleStarted'));
}
return true;
} else {
addToast(result.message || t('toasts.scheduleFailed'));
return false;
}
};
// Handle Sync Trigger
const handleSyncTrigger = async () => {
if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING);
// Note: We deliberately do not clear playlists here to keep UI populated during sync
const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig);
if (result.status === 'success') {
// Transition to Success state
setSyncState(SyncState.SUCCESS);
// Timing Breakdown:
// T+0.0s: State is SUCCESS.
setTimeout(() => {
setSyncState(SyncState.IDLE);
refreshLocal();
refreshCloud();
}, SYNC_SUCCESS_TOTAL_MS);
} else {
setSyncState(SyncState.ERROR);
addToast(t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo);
// Refresh playlists after new connection
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 => {
if (toast.exiting || toast.entering) {
return {
opacity: 0,
transform: 'translateY(-40px) scale(0.95)',
};
}
return {
opacity: 1,
transform: 'translateY(0) scale(1)',
};
};
const getToastClasses = () => {
return "absolute top-2 flex items-center space-x-2 px-4 py-2 rounded-full shadow-lg border text-sm font-medium pointer-events-auto bg-gray-800 text-plex-orange border-plex-orange/30 transition-all duration-300 ease-out origin-top z-50 backdrop-blur-md";
};
const isConnected = cloudServerInfo?.isConnected;
// Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = {
label: t('schedule.schedule'),
value: t('schedule.notConfigured'),
active: false,
autoWatch: settings.autoWatch
};
if (settings.mode === ScheduleMode.DISABLED) {
result.label = t('dashboard.autoSync');
result.value = t('common.disabled');
return result;
}
if (settings.mode === ScheduleMode.CRON) {
result.label = t('schedule.cron');
result.value = settings.cronExpression || t('server.waiting');
result.active = true;
return result;
}
const now = new Date();
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let nextRun: Date | null = null;
let timeStr = '';
if (settings.mode === ScheduleMode.DAILY) {
const [h, m] = settings.dailyTime.split(':').map(Number);
const target = new Date();
target.setHours(h, m, 0, 0);
timeStr = settings.dailyTime;
if (now < target) {
nextRun = target;
} else {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(h, m, 0, 0);
nextRun = tomorrow;
}
} else if (settings.mode === ScheduleMode.WEEKLY) {
timeStr = settings.weeklyTime;
const [h, m] = settings.weeklyTime.split(':').map(Number);
const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) {
result.label = t('schedule.weekly');
result.value = t('common.none');
return result;
}
// Check rest of today
if (activeDays.includes(now.getDay())) {
const todayTarget = new Date();
todayTarget.setHours(h, m, 0, 0);
if (todayTarget > now) {
nextRun = todayTarget;
}
}
// Check future days
if (!nextRun) {
for (let i = 1; i <= 7; i++) {
const nextDayIndex = (now.getDay() + i) % 7;
if (activeDays.includes(nextDayIndex)) {
const d = new Date();
d.setDate(now.getDate() + i);
d.setHours(h, m, 0, 0);
nextRun = d;
break;
}
}
}
}
if (nextRun) {
// Format logic
const isToday = nextRun.getDate() === now.getDate() && nextRun.getMonth() === now.getMonth();
const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate();
let dateStr = '';
if (isToday) dateStr = t('schedule.today');
else if (isTomorrow) dateStr = t('schedule.tomorrow');
else dateStr = days[nextRun.getDay()];
result.label = settings.mode === ScheduleMode.DAILY ? t('schedule.daily') : t('schedule.weekly');
result.value = `${dateStr} @ ${timeStr}`;
result.active = true;
return result;
}
return result;
};
const scheduleInfo = getScheduleDisplayInfo(scheduleSettings);
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let modeLabel = '';
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = t('common.none').replace('None', 'Simple'); // Fallback hack if simple not in dict, but it is in mapping
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
} else {
modeLabel = 'Regex';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon: Icon
};
}
return {
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon: 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);
// 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 (
<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">
{/* App Header */}
<header className={`flex-none shadow-md z-20 relative backdrop-blur-md transition-all duration-500 ease-in-out h-16 ${syncState === SyncState.IDLE ? 'bg-gray-800/80 border-b border-white/5' : 'bg-black border-none'}`}>
{/* Syncing/Success Animated Background Layer */}
{syncState !== SyncState.IDLE && (
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black">
{/* Left Side: Gradient 135deg (TR -> BL /), Anchored RIGHT (Center). Moves LEFT (Right offset increases). */}
<div className="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
{/* Layer 1: Yellow (Syncing) */}
<div
ref={leftYellowRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
style={{
backgroundPosition: 'right 0 top 0',
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
{/* Layer 2: Green (Success) - Fade In */}
<div
ref={leftGreenRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
style={{
backgroundPosition: 'right 0 top 0',
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
</div>
{/* Right Side: Gradient 225deg (TL -> BR \), Anchored LEFT (Center). Moves RIGHT (Left offset increases). */}
<div className="absolute right-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
{/* Layer 1: Yellow (Syncing) */}
<div
ref={rightYellowRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
style={{
backgroundPosition: 'left 0 top 0',
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
{/* Layer 2: Green (Success) - Fade In */}
<div
ref={rightGreenRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
style={{
backgroundPosition: 'left 0 top 0',
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
</div>
</div>
)}
{/* Content Container */}
<div className="relative max-w-7xl mx-auto px-4 md:px-6 h-full flex items-center justify-between">
{syncState === SyncState.IDLE ? (
<>
{/* Normal Toolbar Left */}
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
{/* Normal Toolbar Right */}
<div className="flex items-center gap-4">
{/* Unified Status Dock */}
<div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
{/* Path Mapping Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{pathMappingInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
<pathMappingInfo.Icon size={12} strokeWidth={2.5} />
<span className="truncate">{pathMappingInfo.value}</span>
</div>
</div>
{/* 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>
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title="Switch Language"
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */}
<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 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>
</>
) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div
className="bg-black shadow-none rounded-none border-none"
style={{
padding: `${SYNC_BANNER_PADDING_Y}px ${SYNC_BANNER_PADDING_X}px`,
minWidth: `${SYNC_BANNER_MIN_WIDTH}px`,
}}
>
<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 ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1>
</div>
</div>
)}
</div>
</header>
{/* Notification Toasts Container */}
<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>
{/* Main Content Area */}
<main className="flex-1 overflow-hidden relative z-10">
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
<div className="absolute inset-0 flex flex-col md:flex-row max-w-7xl mx-auto p-4 md:p-6 gap-2 md:gap-3">
{/* Left Column - Local */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.LOCAL}
playlists={localPlaylists}
isLoading={loadingLocal}
onRefresh={refreshLocal}
onCancel={cancelLocalRefresh}
/>
</div>
{/* Strategy Selector - Positioned specifically between headers */}
<div className="absolute
z-30
/* Mobile Positioning: Center Vertically, Anchored Right */
top-1/2 right-[52px] transform translate-x-1/2 -translate-y-1/2
/* Desktop Positioning: Center Horizontally, Anchored Top */
md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2"
>
<StrategySelector
currentStrategy={currentStrategy}
onSelect={handleStrategyChange}
savedPathMapping={pathMappingConfig}
onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState}
onSync={handleSyncTrigger}
/>
</div>
{/* Right Column - Cloud */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.CLOUD}
playlists={cloudPlaylists}
isLoading={loadingCloud}
onRefresh={refreshCloud}
onCancel={cancelCloudRefresh}
serverInfo={cloudServerInfo}
/>
</div>
</div>
</main>
{/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer>
{/* Modals */}
<ConnectionModal
isOpen={isConnectionModalOpen}
onClose={() => setIsConnectionModalOpen(false)}
onConnectSuccess={handleConnectSuccess}
onShowMessage={addToast}
/>
</div>
);
};
export default App;
+23
View File
@@ -0,0 +1,23 @@
// Animation and timing configuration centralization.
// Adjust these values for debugging or tuning animation behavior.
export const STRIPE_TILE_SIZE = 56.57; // px size for repeating background pattern
export const STRIPE_BASE_SPEED = 56.57; // px per second initial scroll speed
export const STRIPE_DECEL_DURATION_MS = 500; // ms duration of deceleration phase
export const SYNC_SUCCESS_TOTAL_MS = 1000; // ms until header returns to idle after success
export const SYNC_ERROR_RESET_MS = 2000; // ms until reset after error state
export const TOAST_AUTO_DISMISS_MS = 3000; // ms before toast begins exit
export const TOAST_EXIT_DURATION_MS = 300; // ms exit animation duration
// If needed later for entrance timing tweaks
export const TOAST_ENTER_FRAME_DELAY_MS = 0; // logical placeholder (double rAF currently)
// Helper: derive CSS backgroundSize string
export const STRIPE_BACKGROUND_SIZE = `${STRIPE_TILE_SIZE}px ${STRIPE_TILE_SIZE}px`;
// Sync banner sizing (background behind SYNCHRONIZING / SYNC COMPLETE text)
// Adjust these to change the black rectangle size.
export const SYNC_BANNER_PADDING_X = 32; // horizontal padding in px
export const SYNC_BANNER_PADDING_Y = 6; // vertical padding in px
export const SYNC_BANNER_MIN_WIDTH = 260; // optional minimum width (px)
+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;
};
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1HGbFKaSambWckOUfemMSKy_Vm-94xh4D
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
@@ -0,0 +1,394 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onConnectSuccess: (serverInfo: PlexServerConnection) => void;
onShowMessage: (message: string) => void;
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
port: '32400',
token: '',
username: '',
password: '',
timeout: 9
});
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Post-connection state
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null);
// Reset state when opening
useEffect(() => {
if (isOpen) {
setError(null);
setConnectedServerInfo(null);
setLibraries([]);
setSelectedLibraryId('');
}
return () => {
// Cleanup any pending request if modal closes
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isOpen]);
if (!isOpen) return null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value) || 0;
setFormData(prev => ({ ...prev, timeout: val }));
};
const handleLibraryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newId = e.target.value;
setSelectedLibraryId(newId);
const lib = libraries.find(l => l.id === newId);
if (lib && connectedServerInfo) {
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo);
onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
}
};
const isTokenProvided = formData.token.trim().length > 0;
const disabledInputClass = isTokenProvided
? "bg-gray-700/50 text-gray-500 line-through decoration-gray-500 cursor-not-allowed border-gray-700"
: "bg-gray-800 text-gray-100 border-gray-600 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// If already connecting, this acts as Cancel
if (isConnecting) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError(t('toasts.connectionCancelled'));
}
return;
}
setError(null);
setIsConnecting(true);
const abortController = new AbortController();
abortControllerRef.current = abortController;
const result = await apiService.connectToPlex(formData, abortController.signal);
// Only proceed if we weren't aborted/cancelled (though apiService handles error msg)
if (abortController.signal.aborted) return;
setIsConnecting(false);
abortControllerRef.current = null;
if (result.status === 'success' && result.data) {
setFormData(prev => ({
...prev,
token: result.data.token,
username: '',
password: ''
}));
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || [];
setLibraries(libs);
if (libs.length > 0) {
const defaultLib = libs[0];
setSelectedLibraryId(defaultLib.id);
onConnectSuccess({
...info,
libraryName: defaultLib.title
});
} else {
onConnectSuccess(info);
}
} else {
setError(result.message || t('server.connectionFailed'));
}
};
const isConnected = !!connectedServerInfo;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-md">
{error}
</div>
)}
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
name="protocol"
value={formData.protocol}
onChange={handleChange}
disabled={isConnected || isConnecting}
className={`w-full h-10 px-2 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div className="col-span-3">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Globe size={14} className="text-gray-500" />
</div>
<input
type="text"
name="address"
required
disabled={isConnected || isConnecting}
placeholder={t('connection.address')}
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
<div>
<input
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder={t('connection.port')}
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
<div className="h-px bg-gray-800 my-4" />
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Key size={14} className="text-plex-orange" />
</div>
<input
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder={t('connection.token')}
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
{!isConnected && (
<>
<div className="text-center text-[10px] text-gray-500 uppercase tracking-widest font-semibold py-1">
OR
</div>
{/* Username */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder={t('connection.username')}
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
</div>
{/* Password */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder={t('connection.password')}
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
<button
type="button"
disabled={isTokenProvided || isConnecting}
onClick={() => setShowPassword(!showPassword)}
className={`absolute inset-y-0 right-0 pr-3 flex items-center ${isTokenProvided ? 'cursor-not-allowed opacity-50' : 'cursor-pointer text-gray-400 hover:text-white'}`}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</>
)}
</div>
{/* Advanced Options */}
{!isConnected && (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-xs font-medium text-gray-400 hover:text-gray-200 transition-colors"
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>{t('connection.advanced')}</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input
type="number"
min="1"
max="60"
name="timeout"
value={formData.timeout || 9}
onChange={handleTimeoutChange}
disabled={isConnecting}
className="w-full h-8 px-2 bg-gray-800 border border-gray-700 rounded-md text-xs text-white focus:border-plex-orange focus:outline-none"
/>
</div>
</div>
)}
</div>
)}
{!isConnected ? (
<button
type="submit"
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg flex items-center justify-center gap-2
${isConnecting
? 'bg-red-500/80 hover:bg-red-500 text-white animate-pulse'
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
}`}
>
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</>
) : t('connection.connectBtn')}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
{t('connection.connectedSuccess')}
</p>
</div>
)}
</form>
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
</div>
<select
value={selectedLibraryId}
onChange={handleLibraryChange}
className="w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange appearance-none cursor-pointer hover:bg-gray-700/50 transition-colors"
>
{libraries.map(lib => (
<option key={lib.id} value={lib.id}>{lib.title}</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<ChevronDown size={14} className="text-gray-500" />
</div>
</div>
<div className="mt-6 flex justify-end">
<button
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"
>
{t('common.done')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ConnectionModal;
+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;
@@ -0,0 +1,35 @@
import React from 'react';
import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface PlaylistCardProps {
playlist: Playlist;
}
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
{playlist.title}
</h4>
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
</div>
</div>
);
};
export default PlaylistCard;
+164
View File
@@ -0,0 +1,164 @@
import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ServerPanelProps {
type: ServerType;
playlists: Playlist[];
isLoading: boolean;
onRefresh: () => void;
onCancel?: () => void;
serverInfo?: PlexServerConnection;
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud;
let headerColor = isLocal ? 'text-blue-400' : 'text-green-400';
const borderColor = isLocal ? 'border-blue-500/30' : 'border-green-500/30';
const bgGradient = isLocal
? 'bg-gradient-to-br from-gray-800/80 to-gray-900/80'
: 'bg-gradient-to-bl from-gray-800/80 to-gray-900/80';
// Resolve Title and Subtitle Logic
let displayTitle = '';
let displaySubtitle: React.ReactNode = null;
if (isLocal) {
displayTitle = t('server.local');
displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{t('server.playlists', { count: playlists.length })}
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
<span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div>
);
} else {
displayTitle = t('server.notConnected');
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{t('server.connectionFailed')}
</p>
);
}
} else {
displayTitle = t('server.cloud');
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? t('server.connecting') : t('server.waiting')}
</p>
);
}
}
// Handle Refresh/Cancel Click
const handleAction = () => {
if (isLoading && onCancel) {
onCancel();
} else {
onRefresh();
}
};
return (
<div className={`flex flex-row md:flex-col h-full ${bgGradient} rounded-2xl border ${borderColor} backdrop-blur-xl shadow-xl overflow-hidden transition-all duration-300`}>
{/* Header */}
<div
className={`
relative flex-none
order-last md:order-first
w-[72px] md:w-full
h-full md:h-auto md:min-h-[80px]
flex flex-col md:flex-row items-center justify-between
py-6 md:py-0 md:px-8
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
`}
>
{/* Title Group */}
<div className="flex flex-col md:flex-row items-center md:space-x-4 overflow-hidden w-full md:w-auto h-full md:h-full md:py-4">
{/* Icon Box */}
<div className={`p-2.5 rounded-xl bg-gray-900/50 border border-white/5 ${headerColor} shadow-inner flex-shrink-0 mb-4 md:mb-0`}>
<Icon size={22} strokeWidth={2} />
</div>
{/* Text Container */}
<div className="flex-1 min-w-0 flex flex-col justify-center items-center md:items-start h-full md:h-auto w-full md:w-auto">
<div className="flex flex-col justify-center w-full md:w-auto [writing-mode:vertical-rl] rotate-180 md:[writing-mode:horizontal-tb] md:rotate-0 items-center md:items-start gap-1 md:gap-0">
<h2 className="text-sm md:text-lg font-bold text-gray-100 tracking-wide whitespace-nowrap" title={displayTitle}>
{displayTitle}
</h2>
<div className="transform md:translate-y-0">
{displaySubtitle}
</div>
</div>
</div>
</div>
{/* Refresh/Stop Button */}
<button
onClick={handleAction}
className={`flex-shrink-0 p-2.5 rounded-full transition-all active:scale-90 mt-4 md:mt-0 md:ml-4 border border-transparent group relative
${isLoading
? 'text-plex-orange bg-plex-orange/10 border-plex-orange/20 hover:bg-red-500/10 hover:border-red-500/30'
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
{/* Outer Spinner */}
<RefreshCw size={20} strokeWidth={2} className="animate-spin opacity-40 group-hover:opacity-20 transition-opacity" />
{/* Inner Cancel X */}
<X size={12} strokeWidth={3} className="absolute text-plex-orange group-hover:text-red-400 transition-colors" />
</div>
) : (
<RefreshCw size={20} strokeWidth={2} />
)}
</button>
</div>
{/* Content List */}
<div className="flex-1 overflow-y-auto p-3 md:p-5 custom-scrollbar bg-black/20">
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">{t('server.noPlaylists')}</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
{playlists.map((playlist) => (
<PlaylistCard key={playlist.id} playlist={playlist} />
))}
</div>
)}
</div>
</div>
);
};
export default ServerPanel;
@@ -0,0 +1,929 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import {
ArrowRightCircle,
ArrowLeftCircle,
GitMerge,
ChevronDown,
Check,
HelpCircle,
Plus,
Trash2,
Save,
RotateCcw,
Zap,
Loader2,
Calendar,
Clock,
Repeat,
Type,
Code2,
Link,
Archive,
History,
Eye
} from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface StrategyOption {
value: SyncStrategy;
labelKey: string;
descKey: string;
icon: React.ElementType;
color: string;
}
const STRATEGIES: StrategyOption[] = [
{
value: SyncStrategy.LOCAL_OVERWRITE,
labelKey: 'strategies.localOverwrite.label',
descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle,
color: 'text-blue-400'
},
{
value: SyncStrategy.CLOUD_OVERWRITE,
labelKey: 'strategies.cloudOverwrite.label',
descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle,
color: 'text-green-400'
},
{
value: SyncStrategy.MERGE_LOCAL,
labelKey: 'strategies.mergeLocal.label',
descKey: 'strategies.mergeLocal.desc',
icon: GitMerge,
color: 'text-blue-300'
},
{
value: SyncStrategy.MERGE_CLOUD,
labelKey: 'strategies.mergeCloud.label',
descKey: 'strategies.mergeCloud.desc',
icon: GitMerge,
color: 'text-green-300'
}
];
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// Color Theme Variables for Mapping Editors
const MAPPING_THEME = {
// Container Themes
local: {
borderColor: "border-blue-500/20",
bgColor: "bg-blue-900/10"
},
remote: {
borderColor: "border-green-500/20",
bgColor: "bg-green-900/10"
},
simple: {
borderColor: "border-gray-700/50",
bgColor: "bg-gray-900/40"
},
// Input Field Themes
inputs: {
default: "bg-gray-800 border-gray-700 text-gray-200 focus:border-plex-orange placeholder-gray-600",
local: "bg-blue-500/10 border-blue-500/30 text-blue-100 focus:border-blue-400 placeholder-blue-300/30",
cloud: "bg-green-500/10 border-green-500/30 text-green-100 focus:border-green-400 placeholder-green-300/30"
}
};
// Helper to determine the actual mode and settings that would be saved based on the current UI state
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
const derived = { ...schedule };
// Unified logic: If the mode matches the tab, we keep it (Enabled).
// If the mode doesn't match (e.g. it was DISABLED), then in the context of this tab, it remains Disabled until the user toggles the switch.
if (derived.mode === tab) {
derived.mode = tab;
} else {
derived.mode = ScheduleMode.DISABLED;
}
return derived;
};
// Sub-component for a single Mapping Group Editor
interface MappingGroupEditorProps {
title: string;
subtitle?: string;
rules: ReplacementRule[];
onChange: (newRules: ReplacementRule[]) => void;
isLocked: boolean;
borderColor?: string;
bgColor?: string;
// Input specific props
leftPlaceholder?: string;
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
t: (key: string) => string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
title,
subtitle,
rules,
onChange,
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder,
rightPlaceholder,
leftInputClass,
rightInputClass,
t
}) => {
const handleAdd = () => {
if (isLocked) return;
const newId = Date.now().toString() + Math.random().toString();
onChange([...rules, { id: newId, search: '', replace: '' }]);
};
const handleUpdate = (id: string, field: 'search' | 'replace', value: string) => {
if (isLocked) return;
onChange(rules.map(r => r.id === id ? { ...r, [field]: value } : r));
};
const handleDelete = (id: string) => {
if (isLocked) return;
onChange(rules.filter(r => r.id !== id));
};
// Default input style if not provided
const defaultInputStyle = MAPPING_THEME.inputs.default;
return (
<div className={`p-3 rounded-lg border ${borderColor} ${bgColor} flex flex-col h-full transition-colors`}>
<div className="flex items-center justify-between mb-2">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-gray-400">{title}</h4>
{subtitle && <p className="text-[9px] text-gray-500">{subtitle}</p>}
</div>
<button
onClick={handleAdd}
disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title={t('common.add')}
>
<Plus size={12} />
</button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
{t('mapping.noRules')}
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder || t('mapping.pattern')}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
/>
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder || t('mapping.replace')}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
/>
<button
onClick={() => handleDelete(rule.id)}
className="text-gray-600 hover:text-red-400 p-1 rounded hover:bg-red-500/10 transition-colors flex-none"
>
<Trash2 size={12} />
</button>
</div>
))
)}
</div>
</div>
);
};
interface StrategySelectorProps {
currentStrategy: SyncStrategy;
onSelect: (strategy: SyncStrategy, label: string) => void;
savedPathMapping: PathMappingConfig;
onSavePathMapping: (config: PathMappingConfig) => void;
savedBackup: BackupSettings;
onSaveBackup: (settings: BackupSettings) => void;
savedSchedule: ScheduleSettings;
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
syncState: SyncState;
onSync: () => void;
}
const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy,
onSelect,
savedPathMapping,
onSavePathMapping,
savedBackup,
onSaveBackup,
savedSchedule,
onSaveSchedule,
syncState,
onSync
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for path mapping editing (stores all lists for both modes)
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
const [isMappingDirty, setIsMappingDirty] = useState(false);
// Local state for Backup Settings
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
const [isBackupDirty, setIsBackupDirty] = useState(false);
// Local state for Schedule editing
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
// UI State for Schedule Tabs
const [activeScheduleTab, setActiveScheduleTab] = useState<ScheduleMode>(
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
);
const isSyncing = syncState === SyncState.SYNCING;
const isLocked = isSyncing || syncState === SyncState.SUCCESS;
// Initialize local state when prop updates
useEffect(() => {
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
setIsMappingDirty(false);
}, [savedPathMapping]);
useEffect(() => {
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
setIsBackupDirty(false);
}, [savedBackup]);
useEffect(() => {
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode);
}
setIsScheduleDirty(false);
}, [savedSchedule]);
// Check dirty state whenever local mapping changes
useEffect(() => {
const isDifferent = JSON.stringify(localPathMapping) !== JSON.stringify(savedPathMapping);
setIsMappingDirty(isDifferent);
}, [localPathMapping, savedPathMapping]);
// Check dirty state for backup
useEffect(() => {
const isDifferent = JSON.stringify(localBackup) !== JSON.stringify(savedBackup);
setIsBackupDirty(isDifferent);
}, [localBackup, savedBackup]);
// Check dirty state for Schedule (including Active Tab changes)
useEffect(() => {
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
setIsScheduleDirty(isDifferent);
}, [localSchedule, savedSchedule, activeScheduleTab]);
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const isScheduleActionable = isScheduleDirty || (activeScheduleTab !== (savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode));
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, t(strategy.labelKey));
};
// --- Path Mapping Handlers ---
const currentMappingMode = localPathMapping.mode;
const updateRegexGroup = (section: keyof PathMappingRules, newRules: ReplacementRule[]) => {
if (isLocked) return;
setLocalPathMapping(prev => ({
...prev,
regex: {
...prev.regex,
[section]: newRules
}
}));
};
const updateSimpleGroup = (newRules: ReplacementRule[]) => {
if (isLocked) return;
setLocalPathMapping(prev => ({
...prev,
simple: newRules
}));
};
const setMappingMode = (mode: PathMappingMode) => {
if (isLocked) return;
setLocalPathMapping(prev => ({ ...prev, mode }));
};
const handleResetMapping = () => {
if (isLocked) return;
setLocalPathMapping(JSON.parse(JSON.stringify(savedPathMapping)));
};
const handleSaveMappingClick = () => {
if (isLocked) return;
const clean = (rules: ReplacementRule[]) => rules.filter(r => r.search.trim() !== '');
// Clean regex rules
const cleanRegex = (rules: PathMappingRules): PathMappingRules => ({
localPre: clean(rules.localPre),
localPost: clean(rules.localPost),
remotePre: clean(rules.remotePre),
remotePost: clean(rules.remotePost),
});
const cleanedConfig: PathMappingConfig = {
mode: localPathMapping.mode,
simple: clean(localPathMapping.simple),
regex: cleanRegex(localPathMapping.regex),
};
setLocalPathMapping(cleanedConfig);
onSavePathMapping(cleanedConfig);
};
const regexRules = localPathMapping.regex;
const simpleRules = localPathMapping.simple;
// --- Backup Handlers ---
const handleUpdateBackup = (field: keyof BackupSettings, value: any) => {
if (isLocked) return;
setLocalBackup(prev => ({ ...prev, [field]: value }));
};
const handleResetBackup = () => {
if (isLocked) return;
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
};
const handleSaveBackupClick = () => {
if (isLocked) return;
onSaveBackup(localBackup);
};
// --- Schedule Handlers ---
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
if (isLocked) return;
setLocalSchedule(prev => ({ ...prev, [field]: value }));
};
const toggleWeekDay = (dayIndex: number) => {
if (isLocked) return;
const currentDays = localSchedule.weeklyDays;
const newDays = currentDays.includes(dayIndex)
? currentDays.filter(d => d !== dayIndex)
: [...currentDays, dayIndex].sort();
handleUpdateSchedule('weeklyDays', newDays);
};
const handleResetSchedule = () => {
if (isLocked) return;
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
setActiveScheduleTab(savedSchedule.mode);
} else {
setActiveScheduleTab(ScheduleMode.CRON);
}
};
const handleSaveScheduleClick = async () => {
if (isLocked) return;
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
const success = await onSaveSchedule(settingsToSave);
if (success) {
setLocalSchedule(settingsToSave);
}
};
const handleSyncClick = () => {
if (isLocked) return;
onSync();
};
const toggleScheduleEnable = (targetMode: ScheduleMode) => {
if (isLocked) return;
if (localSchedule.mode === targetMode) {
handleUpdateSchedule('mode', ScheduleMode.DISABLED);
} else {
handleUpdateSchedule('mode', targetMode);
}
};
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return (
<div className="relative group" ref={dropdownRef}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${t(selectedOption.labelKey)}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
<ChevronDown size={10} className="text-gray-400" />
</div>
</button>
{/* Dropdown Menu */}
<div
className={`absolute
top-14
/* Mobile: Open to left (max width of screen) */
right-0 w-[90vw] max-w-[90vw] origin-top-right
/* Desktop: Center alignment, wider */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2 md:w-[60rem] md:max-w-[60rem]
bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
>
<div className={`flex flex-col max-h-[75vh] md:max-h-[85vh] overflow-y-auto custom-scrollbar ${contentClass}`}>
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
key={strategy.value}
onClick={() => handleSelect(strategy)}
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
currentStrategy === strategy.value
? 'bg-white/10 border-white/10 shadow-sm'
: 'hover:bg-white/5 border-transparent'
}`}
>
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{t(strategy.labelKey)}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{t(strategy.descKey)}
</div>
</div>
{currentStrategy === strategy.value && (
<Check size={14} className="text-plex-orange" strokeWidth={3} />
)}
</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) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('mapping.title')}</h3>
</div>
{/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => (
<button
key={tab.id}
onClick={() => setMappingMode(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${currentMappingMode === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className="mb-4">
{currentMappingMode === PathMappingMode.SIMPLE ? (
// Simple Mode: Single Editor
<div className="animate-in fade-in duration-200">
<MappingGroupEditor
title={t('mapping.simpleTitle')}
subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder={t('mapping.localPath')}
rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
t={t}
/>
</div>
) : (
// Regex Mode: 2x2 Grid
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title={t('server.local')}
subtitle={t('mapping.regexPre')}
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title={t('server.cloud')}
subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title={t('server.local')}
subtitle={t('mapping.regexPost')}
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title={t('server.cloud')}
subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
</div>
)}
</div>
<div className="flex justify-end items-center gap-2">
<button
onClick={handleResetMapping}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isMappingDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveMappingClick}
disabled={!isMappingDirty}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isMappingDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>{t('mapping.saveRules')}</span>
</button>
</div>
</div>
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveScheduleTab(tab.id)}
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
${activeScheduleTab === tab.id
? 'bg-gray-700 text-plex-orange shadow-sm'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
>
<tab.icon size={12} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="mb-4 min-h-[50px]">
{activeScheduleTab === ScheduleMode.CRON && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableCron')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.CRON ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Content */}
<div className={`space-y-2 transition-opacity duration-200 ${localSchedule.mode !== ScheduleMode.CRON ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex items-center space-x-2">
<span className="text-gray-500 font-mono text-xs">Cron:</span>
<input
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>
)}
{activeScheduleTab === ScheduleMode.DAILY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableDaily')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
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'}`}
>
<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>
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-2">
<input
type="time"
value={localSchedule.dailyTime}
onChange={(e) => handleUpdateSchedule('dailyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.DAILY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.DAILY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
{activeScheduleTab === ScheduleMode.WEEKLY && (
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
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'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.mode === ScheduleMode.WEEKLY ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Middle Row: Full Width Capsules */}
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
{WEEK_DAYS.map((day, index) => {
const isSelected = localSchedule.weeklyDays.includes(index);
return (
<button
key={index}
onClick={() => toggleWeekDay(index)}
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
first:rounded-l-lg last:rounded-r-lg
${isSelected
? 'bg-plex-orange text-gray-900 border-plex-orange z-10'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
}
`}
>
{day}
</button>
)
})}
</div>
{/* Bottom Row: Centered Native Time Input */}
<div className="flex justify-center mt-1">
<input
type="time"
value={localSchedule.weeklyTime}
onChange={(e) => handleUpdateSchedule('weeklyTime', e.target.value)}
disabled={localSchedule.mode !== ScheduleMode.WEEKLY}
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 cursor-not-allowed' : ''}`}
/>
</div>
</div>
)}
</div>
{/* Auto Watch Switch */}
<div className="flex items-center justify-between mb-4 mt-2 px-1">
<div className="flex items-center space-x-2">
<div className="p-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 text-orange-400">
<Eye size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div>
</div>
<button
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
className={`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'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localSchedule.autoWatch ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
<button
onClick={handleResetSchedule}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
${isScheduleActionable
? '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={handleSaveScheduleClick}
disabled={!isScheduleActionable}
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
${isScheduleActionable
? '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 4: Sync Now Button */}
<div className="p-4 bg-gray-950/50 border-t border-white/5 flex-none">
<button
onClick={handleSyncClick}
disabled={isLocked}
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isMappingDirty || isBackupDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`}
>
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>{t('strategies.syncing')}</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>{t('strategies.syncNow')}</span>
</>
)}
</button>
{(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2">
{t('strategies.saveWarning')}
</p>
)}
</div>
</div>
</div>
);
};
export default StrategySelector;
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
plex: {
orange: '#e5a00d',
dark: '#1f2937',
darker: '#111827',
card: '#374151'
}
},
animation: {
'scroll-out-left': 'scroll-out-left 1s linear infinite',
'scroll-out-right': 'scroll-out-right 1s linear infinite',
}
}
}
}
</script>
<style>
/* Custom scrollbar for webkit to match dark theme */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #111827;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Force native date/time pickers to use dark mode scheme */
input[type="time"] {
color-scheme: dark;
}
/*
Symmetrical Diagonal Scroll Animations
*/
@keyframes scroll-out-left {
0% { background-position: right 0 top 0; }
100% { background-position: right 56.57px top 0; }
}
@keyframes scroll-out-right {
0% { background-position: left 0 top 0; }
100% { background-position: left 56.57px top 0; }
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"react-dom": "https://aistudiocdn.com/react-dom@^19.2.0"
}
}
</script>
</head>
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
<div id="root"></div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</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.',
}
};
+5
View File
@@ -0,0 +1,5 @@
{
"name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pms-playlist-sync",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+257
View File
@@ -0,0 +1,257 @@
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';
const SIMULATE_DELAY_MS = 800;
// Mock available libraries on a server
const MOCK_LIBRARIES: PlexLibrary[] = [
{ id: 'lib1', title: 'Music (Flac)', type: 'artist' },
{ id: 'lib2', title: 'MP3 Collection', type: 'artist' },
{ id: 'lib3', title: 'Soundtracks', type: 'artist' },
{ id: 'lib4', title: 'Audiobooks', type: 'artist' }
];
// Helper to simulate network request or call actual API
const fetchPlaylists = async (type: ServerType, signal?: AbortSignal): Promise<Playlist[]> => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (type === ServerType.LOCAL) {
resolve([...MOCK_LOCAL_PLAYLISTS]);
} else {
resolve([...MOCK_CLOUD_PLAYLISTS]);
}
}, SIMULATE_DELAY_MS);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
}
});
};
const fetchServerStatus = async (signal?: AbortSignal): Promise<PlexServerConnection> => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
// 90% chance of success for demo
const isSuccess = Math.random() > 0.1;
if (isSuccess) {
resolve({
isConnected: true,
name: 'Home Media Server',
ip: '192.168.1.105',
port: 32400,
libraryName: 'Music (Flac)'
});
} else {
resolve({
isConnected: false
});
}
}, SIMULATE_DELAY_MS);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
}
});
};
const authenticatePlex = async (settings: PlexConnectionSettings, signal?: AbortSignal): Promise<{ token: string, serverInfo: PlexServerConnection }> => {
return new Promise((resolve, reject) => {
// Determine effective timeout
const timeoutSeconds = settings.timeout || 9;
const timeoutMs = timeoutSeconds * 1000;
// Simulate latency (random between 1s and 2s, or longer to test timeout)
const latency = 1500;
const timer = setTimeout(() => {
// Check if we timed out (simulated)
if (latency > timeoutMs) {
reject(new Error(`Connection timed out after ${settings.timeout}s`));
return;
}
// Simulate validation
if (!settings.address) {
reject(new Error("Server address is required"));
return;
}
// If user provided username/password, mock a token generation
let token = settings.token;
if (!token && settings.username && settings.password) {
token = "MOCK_TOKEN_XYZ_999";
} else if (!token) {
reject(new Error("Token or Username/Password required"));
return;
}
// Success response with libraries
resolve({
token: token,
serverInfo: {
isConnected: true,
name: 'My Plex Server',
ip: settings.address,
port: parseInt(settings.port) || 32400,
libraryName: MOCK_LIBRARIES[0].title, // Default to first library
libraries: MOCK_LIBRARIES
}
});
}, latency);
// Handle User Cancellation
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
}
// Handle Actual Timeout Logic (if latency was indeterminate in real world)
// In this mock, the latency is fixed at 1500, but logic above simulates the check.
// However, if the user set timeout < 1.5s, we should reject sooner.
if (timeoutMs < latency) {
setTimeout(() => {
clearTimeout(timer);
reject(new Error(`Connection timed out after ${settings.timeout}s`));
}, timeoutMs);
}
});
}
const triggerSync = async (strategy: SyncStrategy, pathMapping: PathMappingConfig): Promise<void> => {
return new Promise((resolve) => {
// Simulate a sync process taking 3 seconds
// In a real app, pathMapping would be sent to backend
setTimeout(() => {
resolve();
}, 3000);
});
};
// Basic Cron validation helper
const validateCron = (expression: string): boolean => {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return false;
// A very naive check, real validation is more complex but this fits the mock requirement
return true;
};
export const apiService = {
getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => {
try {
const data = await fetchPlaylists(serverType, signal);
return { data, status: 'success' };
} catch (error: any) {
if (error.name === 'AbortError') {
// Return a specific status or handle gracefully
return { data: [], status: 'error', message: 'Request cancelled' };
}
console.error(`Error fetching ${serverType} playlists:`, error);
return { data: [], status: 'error', message: 'Failed to fetch playlists' };
}
},
getServerStatus: async (signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> => {
try {
const data = await fetchServerStatus(signal);
return { data, status: 'success' };
} catch (error: any) {
if (error.name === 'AbortError') return { data: { isConnected: false }, status: 'error', message: 'Cancelled' };
return {
data: { isConnected: false },
status: 'error',
message: 'Failed to connect to server'
};
}
},
connectToPlex: async (settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
try {
const data = await authenticatePlex(settings, signal);
return { data, status: 'success', message: 'Connected successfully' };
} catch (error: any) {
if (error.name === 'AbortError') {
return {
data: { token: '', serverInfo: { isConnected: false } },
status: 'error',
message: 'Connection cancelled'
};
}
return {
data: { token: '', serverInfo: { isConnected: false } },
status: 'error',
message: error.message || 'Connection failed'
};
}
},
syncPlaylists: async (strategy: SyncStrategy, pathMapping: PathMappingConfig): Promise<ApiResponse<null>> => {
try {
await triggerSync(strategy, pathMapping);
return { data: null, status: 'success', message: 'Sync complete' };
} catch (error) {
return { data: null, status: 'error', message: 'Sync failed' };
}
},
saveScheduleSettings: async (settings: ScheduleSettings): Promise<ApiResponse<null>> => {
// Simulate API call
return new Promise((resolve) => {
setTimeout(() => {
// Validation only applies if the mode is CRON and user provided input
if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() !== '') {
if (!validateCron(settings.cronExpression)) {
resolve({ data: null, status: 'error', message: 'Invalid Cron expression format' });
return;
}
}
resolve({ data: null, status: 'success', message: 'Schedule updated successfully' });
}, 500);
});
},
saveBackupSettings: async (settings: BackupSettings): Promise<ApiResponse<null>> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
}, 500);
});
},
// 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);
});
}
};
+14
View File
@@ -0,0 +1,14 @@
import { Playlist } from '../types';
export const MOCK_LOCAL_PLAYLISTS: Playlist[] = [
{ id: 'l1', title: 'Road Trip 2024', trackCount: 45, lastUpdated: '2023-10-25T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' },
{ id: 'l2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' },
{ id: 'l3', title: '90s Rock', trackCount: 32, lastUpdated: '2023-10-20T09:15:00Z', thumbnail: 'https://picsum.photos/200/200?random=3' },
{ id: 'l4', title: 'Gym Pump', trackCount: 50, lastUpdated: '2023-10-22T18:45:00Z', thumbnail: 'https://picsum.photos/200/200?random=4' },
];
export const MOCK_CLOUD_PLAYLISTS: Playlist[] = [
{ id: 'c1', title: 'Road Trip 2024', trackCount: 42, lastUpdated: '2023-10-24T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' }, // Slightly out of sync
{ id: 'c2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' },
{ id: 'c5', title: 'Chill Vibes', trackCount: 88, lastUpdated: '2023-10-19T20:20:00Z', thumbnail: 'https://picsum.photos/200/200?random=5' },
];
+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;
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+122
View File
@@ -0,0 +1,122 @@
export interface Track {
id: string;
title: string;
artist: string;
duration: number; // in seconds
}
export interface Playlist {
id: string;
title: string;
trackCount: number;
thumbnail?: string;
lastUpdated: string;
tracks?: Track[]; // Optional detailed track list
}
export enum ServerType {
LOCAL = 'LOCAL',
CLOUD = 'CLOUD'
}
export enum SyncStrategy {
LOCAL_OVERWRITE = 'LOCAL_OVERWRITE',
CLOUD_OVERWRITE = 'CLOUD_OVERWRITE',
MERGE_LOCAL = 'MERGE_LOCAL',
MERGE_CLOUD = 'MERGE_CLOUD'
}
export enum SyncState {
IDLE = 'IDLE',
SYNCING = 'SYNCING',
SUCCESS = 'SUCCESS',
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 enum ScheduleMode {
DISABLED = 'DISABLED',
CRON = 'CRON',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY'
}
export interface ScheduleSettings {
mode: ScheduleMode;
cronExpression: string;
dailyTime: string;
weeklyDays: number[]; // 0 = Sunday, 1 = Monday, etc.
weeklyTime: string;
autoWatch: boolean;
}
export interface PlexLibrary {
id: string;
title: string;
type: string;
}
export interface PlexServerConnection {
isConnected: boolean;
name?: string;
ip?: string;
port?: number;
libraryName?: string;
libraries?: PlexLibrary[];
}
export interface PlexConnectionSettings {
protocol: 'http' | 'https';
address: string;
port: string;
token: string;
username?: string;
password?: string;
timeout?: number; // in seconds
}
export interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
export interface LoginCredentials {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
username: string;
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});