├── .env.example
├── Dockerfile
├── README.md
├── app
├── entrypoint.sh
└── rd_symlink_backend.py
├── docker-compose.yml
├── rd_symlink.user.js
└── requirements.txt
/.env.example:
--------------------------------------------------------------------------------
1 | # === REQUIRED ===
2 | RD_API_KEY=your_realdebrid_api_key
3 |
4 | # === Paths ===
5 | UID=1000
6 | GID=1000
7 | RCLONE_MOUNT_PATH=/mnt/data/media/remote/realdebrid/__all__ # Critical: Mount point for rclone remote
8 | SYMLINK_BASE_PATH=/mnt/data/symlinks # Media libraryia server.
9 | FINAL_LIBRARY_PATH=/mnt/data/library # Media libraryia server.
10 | DOWNLOAD_COMPLETE_PATH=/mnt/data/library/complete # Final library sub folder.
11 |
12 | # === Network ===
13 | PORT=5002 # WebUI port (change for multiple instances)
14 | CONTAINER_NAME=rd_symlink_manager # Container identifier
15 |
16 | # === Media Server ===
17 | MEDIA_SERVER=plex # Supported: plex/emby
18 | PLEX_TOKEN=your_plex_token
19 | PLEX_LIBRARY_NAME=Movies # Library name in plex for scanning
20 | PLEX_SERVER_IP=192.168.1.100
21 | SCAN_DELAY=300 #Plex scan delay second for rename tools
22 | # Emby Configuration (commented out below for using emby)
23 | EMBY_LIBRARY_NAME=
24 | EMBY_API_KEY= # Your Emby API key
25 | EMBY_SERVER_IP=192.168.1.100 # Emby IP
26 |
27 | # === Downloads ===only trigger when ENABLE_DOWNLOADS=true
28 | ENABLE_DOWNLOADS=false # true = Direct downloads | false = Symlink-only mode
29 | MOVE_TO_FINAL_LIBRARY=true # true = auto-move, false = external renamer
30 | DELETE_AFTER_COPY=false # Delete files after copying (not recommended)
31 | MAX_CONCURRENT_TASKS=3 # Parallel download limit (only when ENABLE_DOWNLOADS=true)
32 |
33 | # === Advanced ===
34 | REMOVE_WORDS=hhd800.com@,-PPV,[BT-btt.com] # Words to strip from filenames
35 | LOG_LEVEL=INFO
36 | LOGS_PATH=./logs
37 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-slim
2 |
3 | WORKDIR /app
4 |
5 | # Install system dependencies
6 | RUN apt-get update && apt-get install -y --no-install-recommends \
7 | gcc python3-dev curl && \
8 | apt-get clean && \
9 | rm -rf /var/lib/apt/lists/*
10 |
11 | # Copy and install Python dependencies
12 | COPY requirements.txt .
13 | RUN pip install --no-cache-dir -r requirements.txt # Install from requirements.txt
14 |
15 | # Make /app writable
16 | RUN chmod 777 /app
17 |
18 | COPY ./app/ .
19 |
20 | EXPOSE ${PORT:-5005}
21 |
22 | CMD ["python", "rd_symlink_backend.py"]
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Real-Debrid Symlink + RD Unrestrict Downloader
2 | **All-in-One Solution for (Movies|Anime|Shows) - Direct Media Server Integration with Multi-Path Support**
3 |
4 | [](https://docs.docker.com)
5 | [](https://www.tampermonkey.net/)
6 |
7 | ## Features
8 |
9 | ### 1. Frontend Userscript (Tampermonkey/Violentmonkey)
10 | 🎮 **Floating Control Center**
11 | - 🖱️ Real-Debrid Icon with Status Indicators (Instant color changes for RD connectivity/symlink status)
12 | - 🎥 Auto-Video Detection: Smart file size analysis for optimal caching
13 | - 🚀 One-Click Actions:
14 | - `Cache Only`: Direct Real-Debrid cloud caching
15 | - `Cache + Symlink`: Full pipeline (Cache → Clean filenames → Symlink → Media Server Scan)
16 | - 📌 Persistent Tracking: Visual indicators for previously handled content
17 |
18 | ### 2. Task Manager+ Dashboard
19 | 📊 **Centralized Download Control**
20 | - 🕹️ Live Monitoring:
21 | - Real-time download speeds
22 | - Symlink creation status
23 | - Error tracking with auto-retry
24 | - 🔄 Smart Queue Management:
25 | - Concurrent task throttling (`MAX_CONCURRENT_TASKS`)
26 | - Status filters: Downloading | Symlinking | Completed
27 | - 💾 Data Portability:
28 | - Export/import task history
29 | - Cross-browser session persistence
30 | - 🧹 Maintenance Tools:
31 | - Bulk task removal
32 | - Cloud+Local cleanup (RD deletion + symlink removal)
33 |
34 | ### 3. Backend Engine
35 | ⚙️ **Automated Processing Core**
36 | - 🔄 Dual Mode Operation:
37 | - `Symlink Mode`: Instant media server-ready links
38 | - `Download Mode`: Full-file downloads from RD unrestrict links
39 | - 🧼 Content Sanitization:
40 | - Automated bad word removal (`REMOVE_WORDS` list)
41 | - Filename pattern standardization
42 | - 🎬 Media Server Integration:
43 | - Multi-path symlink support
44 | - Instant Plex/Emby/Jellyfin library scans
45 | - 📈 Performance Features:
46 | - Multi-instance support (different ports)
47 | - Docker-ready configuration
48 |
49 | ## 📥 Userscript Installation Guide
50 |
51 | ### 1. Browser Extension Setup
52 | **Required Extensions** (Choose One):
53 | - [Tampermonkey](https://www.tampermonkey.net/)
54 | [](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo)
55 | [](https://addons.mozilla.org/firefox/addon/tampermonkey/)
56 |
57 | - [Violentmonkey](https://violentmonkey.github.io/)
58 | [](https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag)
59 | [](https://addons.mozilla.org/firefox/addon/violentmonkey/)
60 |
61 | ### 2. Install the Userscript
62 | [](https://github.com/ericvlog/rd_symlink_manager/raw/main/rd_symlink.user.js)
63 |
64 | 1. Click the green "Install Userscript" button above
65 | 2. Confirm installation in your userscript manager
66 | 3. **First-Time Configuration**:
67 |
68 | ```javascript
69 | // ==UserScript==
70 | // @name RD Symlink Manager
71 | // @namespace http://tampermonkey.net/
72 | // @version 1.1.0
73 | // @description Real-Debrid integration with direct downloads and symlink management
74 | // @match *://*/*
75 | // @grant GM_xmlhttpRequest
76 | // @grant GM_setClipboard
77 | // @connect api.real-debrid.com
78 | // @connect localhost
79 | // ==/UserScript==
80 |
81 | const config = {
82 | instanceName: "Main", // Unique identifier for multiple instances
83 | rdApiKey: 'YOUR_API_KEY_HERE', // Get from Real-Debrid settings
84 | backendUrl: 'http://localhost:5002', // Local server address
85 | videoExtensions: ['mp4', 'mkv', 'avi'], // Supported file formats
86 | minFileSize: 50 * 1024 * 1024 // 50MB minimum file size filter
87 | };
88 | ```
89 |
90 | ### 3. Verification & First Use
91 | ✅ **Successful Installation Indicators**:
92 | 1. Visit any torrent/magnet link site (e.g., 1337x, RARBG clone)
93 | 2. Look for buttons next to magnet links
94 | 3. Click the floating control panel:
RD
95 |
96 | 
97 |
98 | ## 🚀 Quick Start
99 |
100 | ```bash
101 | git clone https://github.com/ericvlog/rd_symlink_manager.git
102 | cd rd_symlink_manager
103 | cp .env.example .env # Configure with your API tokens/paths
104 | # Create directories from your .env paths
105 | mkdir -p \
106 | "${SYMLINK_BASE_PATH}" \
107 | "${DOWNLOAD_INCOMPLETE_PATH}" \
108 | "${DOWNLOAD_COMPLETE_PATH}"
109 |
110 | # Set permissions once
111 | sudo chown -R 1000:1000 \
112 | "${SYMLINK_BASE_PATH}" \
113 | "${DOWNLOAD_INCOMPLETE_PATH}" \
114 | "${DOWNLOAD_COMPLETE_PATH}"
115 |
116 | sudo chmod -R 2775 \
117 | "${SYMLINK_BASE_PATH}" \
118 | "${DOWNLOAD_INCOMPLETE_PATH}" \
119 | "${DOWNLOAD_COMPLETE_PATH}"
120 | docker compose up -d --build
121 | ```
122 | ## 📥 Installation Guide
123 | **Watch the step-by-step tutorial:**
124 | [](https://youtu.be/F77i2ZAnb_w)
125 | [Full Video Link](https://youtu.be/F77i2ZAnb_w)
126 |
127 | ## ⚙️ Requirements
128 |
129 | - **Essential**:
130 | - Real-Debrid Premium Account ([API Key](https://real-debrid.com/apitoken))
131 | - Mounted Cloud Storage ([Zurg](https://github.com/dexter21767/zurg) + Rclone)
132 |
133 | - **Media Stack**:
134 | - Plex/Emby/Jellyfin (Optional but recommended)
135 | - Linux filesystem (ext4/XFS recommended for symlinks)
136 |
137 | - **Browser Environment**:
138 | - Chrome/Edge with Tampermonkey
139 | - Violentmonkey extension
140 |
141 | ## 🔑 Key Benefits
142 |
143 | - 🕒 **One-Click Automation** - From torrent to streaming in 3 clicks
144 | - 🔄 **Zero Reprocessing** - Smart tracking of handled content
145 | - 🛡️ **Failure Resilience**:
146 | - Download resume support
147 | - Symlink error auto-retry
148 | - 🎞️ **Instant Gratification**:
149 | - Media server-ready files
150 | - Clean metadata formatting
151 | - 📡 **Hybrid Operation**:
152 | - Mix symlinks and direct downloads
153 | - Multiple media library paths
154 |
155 | ## Support
156 | **Optimized for** Linux (Debian/Ubuntu) + Chrome/Edge
157 | Report issues: [GitHub Issues](https://github.com/ericvlog/rd_symlink_manager/issues)
158 |
159 | ## ☕ Support Development
160 | If this project helps you, consider supporting
161 | **Bitcoin (BTC):**
162 | `1NizzCiosWryLMv51jp118MSjsN7FZQxjC`
163 | 
164 |
--------------------------------------------------------------------------------
/app/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # No permission management - direct passthrough
3 | exec "$@"
4 |
--------------------------------------------------------------------------------
/app/rd_symlink_backend.py:
--------------------------------------------------------------------------------
1 | from werkzeug.middleware.proxy_fix import ProxyFix
2 | from flask import Flask, request, jsonify
3 | from flask_cors import CORS
4 | import os
5 | import json
6 | import logging
7 | import sys
8 | import urllib.parse
9 | import requests
10 | import re
11 | import time
12 | import threading
13 | import uuid
14 | import shutil
15 | from pathlib import Path
16 | from collections import deque
17 |
18 | logging.basicConfig(
19 | format='%(asctime)s %(levelname)s [%(module)s] %(message)s',
20 | datefmt='%Y-%m-%d %H:%M:%S',
21 | level=logging.INFO
22 | )
23 |
24 | app = Flask(__name__)
25 | CORS(app)
26 | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
27 |
28 | class TaskWorker(threading.Thread):
29 | def __init__(self):
30 | super().__init__(daemon=True)
31 | self.running = True
32 | def run(self):
33 | while self.running:
34 | try:
35 | task_id, raw_data, torrent_id = None, None, None
36 | with queue_lock:
37 | if request_queue:
38 | task_id, raw_data, torrent_id = request_queue.popleft()
39 | active_tasks[task_id] = torrent_id
40 | if raw_data:
41 | with task_semaphore:
42 | try:
43 | start_time = time.time()
44 | with app.test_request_context(method="POST", data=raw_data, headers={"Content-Type": "application/json"}):
45 | data = request.get_json()
46 | response = process_symlink_creation(data, task_id)
47 | if response[1] != 200:
48 | logging.error(f"Task {task_id} failed: {response[0].get_json()}")
49 | except Exception as e:
50 | logging.error(f"Task {task_id} processing failed: {str(e)}")
51 | with download_lock:
52 | if task_id in download_statuses:
53 | download_statuses[task_id]['status'] = 'failed'
54 | download_statuses[task_id]['error'] = str(e)
55 | finally:
56 | with queue_lock:
57 | if task_id in active_tasks:
58 | del active_tasks[task_id]
59 | logging.info(f"Task {task_id} completed in {time.time()-start_time:.1f}s")
60 | else:
61 | time.sleep(1)
62 | except Exception as e:
63 | logging.error(f"Queue worker error: {str(e)}")
64 | time.sleep(5)
65 |
66 | RD_API_KEY = os.getenv("RD_API_KEY")
67 | MEDIA_SERVER = os.getenv("MEDIA_SERVER", "plex").lower()
68 | ENABLE_DOWNLOADS = os.getenv("ENABLE_DOWNLOADS", "false").lower() == "true"
69 | MOVE_TO_FINAL_LIBRARY = os.getenv("MOVE_TO_FINAL_LIBRARY", "true").lower() == "true"
70 | SYMLINK_BASE_PATH = Path(os.getenv("SYMLINK_BASE_PATH", "/symlinks"))
71 | DOWNLOAD_COMPLETE_PATH = Path(os.getenv("DOWNLOAD_COMPLETE_PATH", "/dl_complete"))
72 | FINAL_LIBRARY_PATH = Path(os.getenv("FINAL_LIBRARY_PATH", "/library"))
73 | RCLONE_MOUNT_PATH = Path(os.getenv("RCLONE_MOUNT_PATH", "/mnt/data/media/remote/realdebrid/__all__"))
74 | PLEX_TOKEN = os.getenv("PLEX_TOKEN")
75 | PLEX_LIBRARY_NAME = os.getenv("PLEX_LIBRARY_NAME")
76 | PLEX_SERVER_IP = os.getenv("PLEX_SERVER_IP")
77 | EMBY_SERVER_IP = os.getenv("EMBY_SERVER_IP")
78 | EMBY_API_KEY = os.getenv("EMBY_API_KEY")
79 | EMBY_LIBRARY_NAME = os.getenv("EMBY_LIBRARY_NAME")
80 | MAX_CONCURRENT_TASKS = int(os.getenv("MAX_CONCURRENT_TASKS", "3"))
81 | DELETE_AFTER_COPY = os.getenv("DELETE_AFTER_COPY", "false").lower() == "true"
82 | REMOVE_WORDS = [w.strip() for w in os.getenv("REMOVE_WORDS", "").split(",") if w.strip()]
83 | SCAN_DELAY = int(os.getenv("SCAN_DELAY", "60"))
84 |
85 | plex_section_id = None
86 | plex_initialized = False
87 | task_semaphore = threading.BoundedSemaphore(MAX_CONCURRENT_TASKS)
88 | queue_lock = threading.Lock()
89 | request_queue = deque()
90 | active_tasks = {}
91 | download_statuses = {}
92 | download_lock = threading.Lock()
93 |
94 | @app.route('/rd-proxy', methods=['POST'])
95 | def rd_proxy():
96 | try:
97 | data = request.get_json()
98 | endpoint = data.get('endpoint', '')
99 | method = data.get('method', 'GET').upper()
100 | payload = data.get('data', None)
101 |
102 | if not RD_API_KEY:
103 | app.logger.error("RD_API_KEY missing in environment")
104 | return jsonify({"error": "Server configuration error"}), 500
105 |
106 | if not endpoint.startswith('/'):
107 | return jsonify({"error": f"Invalid endpoint format: {endpoint}"}), 400
108 |
109 | response = requests.request(
110 | method,
111 | f"https://api.real-debrid.com/rest/1.0{endpoint}",
112 | headers={
113 | "Authorization": f"Bearer {RD_API_KEY}",
114 | "Cache-Control": "no-store, max-age=0"
115 | },
116 | data=payload,
117 | timeout=15
118 | )
119 |
120 | try:
121 | response.raise_for_status()
122 |
123 | if response.status_code in [200, 202, 204] and not response.text.strip():
124 | app.logger.info(f"Handled empty success response ({response.status_code})")
125 | resp = jsonify({"status": "success"})
126 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
127 | return resp, 200
128 |
129 | try:
130 | resp = jsonify(response.json())
131 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
132 | return resp, response.status_code
133 | except json.JSONDecodeError:
134 | if response.status_code in [200, 202, 204]:
135 | app.logger.info(f"Empty success response ({response.status_code})")
136 | resp = jsonify({"status": "success"})
137 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
138 | return resp, 200
139 | app.logger.error(f"Invalid JSON response | Status: {response.status_code} | Content: {response.text[:200]}")
140 | resp = jsonify({
141 | "source": "Real-Debrid API",
142 | "status": response.status_code,
143 | "message": response.text
144 | })
145 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
146 | return resp, response.status_code
147 | except requests.HTTPError as e:
148 | error_data = {
149 | "source": "Real-Debrid API",
150 | "status": e.response.status_code,
151 | "message": e.response.text
152 | }
153 | resp = jsonify(error_data)
154 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
155 | return resp, e.response.status_code
156 |
157 | except Exception as e:
158 | error_details = {
159 | "exception_type": type(e).__name__,
160 | "message": str(e),
161 | "request_data": data,
162 | "response_content": getattr(e, 'response', {}).text if hasattr(e, 'response') else None
163 | }
164 | if hasattr(e, 'response') and e.response.status_code in [200, 202, 204]:
165 | app.logger.info(f"Handled proxy error for success code: {json.dumps(error_details, indent=2)}")
166 | resp = jsonify({"status": "success"})
167 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
168 | return resp, 200
169 | app.logger.error(f"Proxy Error Details:\n{json.dumps(error_details, indent=2)}")
170 | resp = jsonify({
171 | "error": "Proxy processing failed",
172 | "details": str(e)
173 | })
174 | resp.headers['Cache-Control'] = 'no-store, max-age=0'
175 | return resp, 500
176 |
177 | def get_restricted_links(torrent_id):
178 | response = requests.get(f"https://api.real-debrid.com/rest/1.0/torrents/info/{torrent_id}",
179 | headers={"Authorization": f"Bearer {RD_API_KEY}"},
180 | timeout=15)
181 | response.raise_for_status()
182 | return response.json().get("links", [])
183 |
184 | def unrestrict_link(restricted_link):
185 | response = requests.post("https://api.real-debrid.com/rest/1.0/unrestrict/link",
186 | headers={"Authorization": f"Bearer {RD_API_KEY}"},
187 | data={"link": restricted_link},
188 | timeout=15)
189 | response.raise_for_status()
190 | return response.json()["download"]
191 |
192 | def clean_filename(original_name):
193 | cleaned = original_name
194 | for pattern in REMOVE_WORDS:
195 | cleaned = re.sub(rf"{re.escape(pattern)}", "", cleaned, flags=re.IGNORECASE)
196 | name_part, ext_part = os.path.splitext(cleaned)
197 | name_part = re.sub(r"_(\d+)(?=\.\w+$|$)", r"-cd\1", name_part)
198 | name_part = re.sub(r"[\W_]+", "-", name_part).strip("-")
199 | return f"{name_part or 'file'}"
200 |
201 | def log_download_speed(task_id, torrent_id, dest_path):
202 | temp_path = None
203 | try:
204 | with download_lock:
205 | download_statuses[task_id] = {
206 | "status": "starting",
207 | "progress": 0.0,
208 | "speed": 0.0,
209 | "error": None,
210 | "dest_path": str(dest_path),
211 | "filename": Path(dest_path).name
212 | }
213 |
214 | dest_dir = dest_path.parent
215 | dest_dir.mkdir(parents=True, exist_ok=True)
216 |
217 | if os.getenv('DOWNLOAD_UID') and os.getenv('DOWNLOAD_GID'):
218 | os.chown(dest_dir, int(os.getenv('DOWNLOAD_UID')), int(os.getenv('DOWNLOAD_GID')))
219 | os.chmod(dest_dir, 0o775)
220 |
221 | test_file = dest_dir / "permission_test.tmp"
222 | try:
223 | with open(test_file, "w") as f:
224 | f.write("permission_test")
225 | except Exception as e:
226 | logging.warning(f"Permission test failed: {str(e)}")
227 | finally:
228 | if test_file.exists():
229 | test_file.unlink()
230 |
231 | torrent_info = requests.get(f"https://api.real-debrid.com/rest/1.0/torrents/info/{torrent_id}",
232 | headers={"Authorization": f"Bearer {RD_API_KEY}"},
233 | timeout=15).json()
234 | base_name = clean_filename(os.path.splitext(torrent_info["filename"])[0])
235 | dest_dir = DOWNLOAD_COMPLETE_PATH / base_name
236 | dest_dir.mkdir(parents=True, exist_ok=True)
237 | final_path = dest_dir / dest_path.name
238 |
239 | if final_path.exists():
240 | logging.info(f"Skipping existing file: {final_path}")
241 | with download_lock:
242 | download_statuses[task_id].update({
243 | "status": "completed",
244 | "progress": 100.0,
245 | "speed": 0
246 | })
247 | return
248 |
249 | temp_path = dest_dir / f"{dest_path.name}.tmp"
250 | restricted_links = get_restricted_links(torrent_id)
251 | if not restricted_links:
252 | raise Exception("No downloadable links found")
253 |
254 | download_url = unrestrict_link(restricted_links[0])
255 | logging.info(f"Download initialized\n|-> Source: {download_url}\n|-> Temp: {temp_path}\n|-> Final: {dest_path}")
256 |
257 | with requests.get(download_url, stream=True, timeout=(10, 300)) as r:
258 | r.raise_for_status()
259 | total_size = int(r.headers.get("content-length", 0))
260 | bytes_copied = 0
261 | start_time = time.time()
262 | last_log = start_time
263 |
264 | with open(temp_path, "wb") as f:
265 | for chunk in r.iter_content(chunk_size=10*1024*1024):
266 | if chunk:
267 | f.write(chunk)
268 | bytes_copied += len(chunk)
269 | elapsed = time.time() - start_time
270 | speed = bytes_copied / elapsed if elapsed > 0 else 0
271 |
272 | with download_lock:
273 | download_statuses[task_id].update({
274 | "progress": bytes_copied / total_size if total_size > 0 else 0,
275 | "speed": speed,
276 | "status": "downloading"
277 | })
278 |
279 | if time.time() - last_log >= 3:
280 | logging.info(f"[Downloading] {dest_path.name} | Progress: {bytes_copied/total_size:.1%} | Speed: {speed/1024/1024:.2f} MB/s")
281 | last_log = time.time()
282 |
283 | f.flush()
284 | os.fsync(f.fileno())
285 |
286 | max_retries = 3
287 | retry_delay = 1
288 |
289 | for attempt in range(max_retries):
290 | try:
291 | if temp_path.exists():
292 | temp_path.rename(final_path)
293 | logging.info(f"Download completed: {final_path}")
294 | break
295 | elif final_path.exists():
296 | logging.warning(f"File already exists: {final_path}")
297 | break
298 | else:
299 | if attempt == max_retries - 1:
300 | raise FileNotFoundError(f"Missing both temp and final files: {temp_path}")
301 | time.sleep(retry_delay)
302 | except FileNotFoundError as e:
303 | if attempt == max_retries - 1:
304 | raise
305 | logging.warning(f"Retrying rename: {e}")
306 | time.sleep(retry_delay)
307 | except PermissionError as e:
308 | logging.error(f"Permission denied: {str(e)}")
309 | raise
310 |
311 | if not final_path.exists():
312 | raise FileNotFoundError(f"Final file missing: {final_path}")
313 |
314 | if MOVE_TO_FINAL_LIBRARY:
315 | final_lib_path = FINAL_LIBRARY_PATH / dest_path.relative_to(DOWNLOAD_COMPLETE_PATH)
316 | final_lib_path.parent.mkdir(parents=True, exist_ok=True)
317 | shutil.move(final_path, final_lib_path)
318 | logging.info(f"Moved to final library: {final_lib_path}")
319 | try:
320 | if not any(dest_dir.iterdir()):
321 | dest_dir.rmdir()
322 | logging.info(f"Cleaned empty directory: {dest_dir}")
323 | except Exception as e:
324 | logging.error(f"Directory cleanup failed: {str(e)}")
325 | else:
326 | logging.info(f"File retained in downloads: {final_path}")
327 |
328 | time.sleep(SCAN_DELAY)
329 | trigger_media_scan(FINAL_LIBRARY_PATH)
330 |
331 | with download_lock:
332 | download_statuses[task_id]["status"] = "completed"
333 |
334 | except Exception as e:
335 | logging.error(f"Download failed: {str(e)}", exc_info=True)
336 | with download_lock:
337 | if task_id in download_statuses:
338 | download_statuses[task_id].update({
339 | "status": "failed",
340 | "error": str(e)
341 | })
342 | if temp_path and temp_path.exists():
343 | temp_path.unlink()
344 | raise
345 |
346 | def get_plex_section_id():
347 | global plex_section_id, plex_initialized
348 | if plex_initialized:
349 | return plex_section_id
350 | try:
351 | response = requests.get(f"http://{PLEX_SERVER_IP}:32400/library/sections",
352 | headers={"Accept": "application/json"},
353 | params={"X-Plex-Token": PLEX_TOKEN},
354 | timeout=10)
355 | response.raise_for_status()
356 | for directory in response.json()["MediaContainer"]["Directory"]:
357 | if directory["title"] == PLEX_LIBRARY_NAME:
358 | plex_section_id = str(directory["key"])
359 | plex_initialized = True
360 | logging.info(f"Plex section resolved: {plex_section_id}")
361 | return plex_section_id
362 | logging.error("Plex library missing")
363 | return None
364 | except Exception as e:
365 | logging.error(f"Plex error: {str(e)}")
366 | return None
367 |
368 | def trigger_plex_scan(path):
369 | try:
370 | section_id = get_plex_section_id()
371 | if not section_id:
372 | return False
373 |
374 | if not ENABLE_DOWNLOADS:
375 | try:
376 | base_path = SYMLINK_BASE_PATH
377 | rel_path = path.relative_to(base_path)
378 | encoded_path = "/".join([urllib.parse.quote(p.name) for p in rel_path.parents[::-1]][:-1])
379 | params = {"path": encoded_path, "X-Plex-Token": PLEX_TOKEN}
380 | scan_type = "partial"
381 | except ValueError:
382 | logging.error(f"Path {path} not in symlink base {base_path}")
383 | params = {"X-Plex-Token": PLEX_TOKEN}
384 | scan_type = "full"
385 | else:
386 | params = {"X-Plex-Token": PLEX_TOKEN}
387 | scan_type = "full"
388 |
389 | response = requests.get(
390 | f"http://{PLEX_SERVER_IP}:32400/library/sections/{section_id}/refresh",
391 | params=params,
392 | timeout=15
393 | )
394 | logging.info(f"Plex {scan_type} scan triggered")
395 | return response.status_code == 200
396 | except Exception as e:
397 | logging.error(f"Plex scan error: {str(e)}")
398 | return False
399 |
400 | def trigger_emby_scan(path):
401 | try:
402 | libs_response = requests.get(f"http://{EMBY_SERVER_IP}/emby/Library/VirtualFolders?api_key={EMBY_API_KEY}",
403 | timeout=10)
404 | if libs_response.status_code != 200:
405 | return False
406 |
407 | library_id = None
408 | for lib in libs_response.json():
409 | if lib['Name'] == EMBY_LIBRARY_NAME:
410 | library_id = lib['ItemId']
411 | break
412 | if not library_id:
413 | return False
414 |
415 | scan_response = requests.post(f"http://{EMBY_SERVER_IP}/emby/Library/Refresh?api_key={EMBY_API_KEY}",
416 | timeout=15)
417 | return scan_response.status_code in [200, 204]
418 | except Exception as e:
419 | logging.error(f"Emby scan error: {str(e)}")
420 | return False
421 |
422 | def trigger_media_scan(path):
423 | try:
424 | if MEDIA_SERVER == "plex":
425 | return trigger_plex_scan(path)
426 | elif MEDIA_SERVER == "emby":
427 | return trigger_emby_scan(path)
428 | return False
429 | except Exception as e:
430 | logging.error(f"Media scan failed: {str(e)}")
431 | return False
432 |
433 | def process_symlink_creation(data, task_id):
434 | with download_lock:
435 | if task_id not in download_statuses:
436 | download_statuses[task_id] = {
437 | "status": "queued",
438 | "progress": 0,
439 | "speed": 0,
440 | "error": None
441 | }
442 |
443 | try:
444 | torrent_id = data['torrent_id']
445 | torrent_info = requests.get(f"https://api.real-debrid.com/rest/1.0/torrents/info/{torrent_id}",
446 | headers={"Authorization": f"Bearer {RD_API_KEY}"},
447 | timeout=15).json()
448 |
449 | if not torrent_info.get("files") or not torrent_info.get("filename"):
450 | return jsonify({"error": "Invalid torrent"}), 400
451 |
452 | selected_files = [f for f in torrent_info["files"] if f.get("selected") == 1]
453 | if not selected_files:
454 | return jsonify({"error": "No files selected"}), 400
455 |
456 | created_paths = []
457 | base_dir = DOWNLOAD_COMPLETE_PATH if ENABLE_DOWNLOADS else SYMLINK_BASE_PATH
458 | base_name = clean_filename(os.path.splitext(torrent_info["filename"])[0])
459 | dest_dir = base_dir / base_name
460 | dest_dir.mkdir(parents=True, exist_ok=True)
461 |
462 | for file in selected_files:
463 | try:
464 | file_path = Path(file["path"].lstrip("/"))
465 | dest_path = dest_dir / f"{clean_filename(file_path.stem)}{file_path.suffix.lower()}"
466 |
467 | if ENABLE_DOWNLOADS:
468 | log_download_speed(task_id, torrent_id, dest_path)
469 | else:
470 | src_path = RCLONE_MOUNT_PATH / torrent_info["filename"] / file_path
471 | if not dest_path.exists():
472 | dest_path.symlink_to(src_path)
473 | logging.info(f"Symlink created: {dest_path} → {src_path}")
474 | trigger_media_scan(dest_path)
475 |
476 | created_paths.append(str(dest_path))
477 | except Exception as e:
478 | logging.error(f"File error: {str(e)}")
479 |
480 | return jsonify({
481 | "status": "processed" if ENABLE_DOWNLOADS else "symlink_created",
482 | "created_paths": created_paths,
483 | "task_id": task_id
484 | }), 200
485 | except requests.RequestException as e:
486 | return jsonify({"error": "API failure"}), 502
487 | except Exception as e:
488 | return jsonify({"error": str(e)}), 500
489 |
490 | @app.route("/symlink", methods=["POST"])
491 | def create_symlink():
492 | data = request.get_json()
493 | torrent_id = data.get("torrent_id")
494 |
495 | with queue_lock:
496 | current_torrent_ids = set(active_tasks.values())
497 | current_torrent_ids.update(task[2] for task in request_queue)
498 | if torrent_id in current_torrent_ids:
499 | return jsonify({"error": "Task already in progress"}), 409
500 |
501 | if task_semaphore.acquire(blocking=False):
502 | try:
503 | task_id = str(uuid.uuid4())
504 | with queue_lock:
505 | active_tasks[task_id] = torrent_id
506 | return process_symlink_creation(data, task_id)
507 | finally:
508 | task_semaphore.release()
509 | with queue_lock:
510 | if task_id in active_tasks:
511 | del active_tasks[task_id]
512 | else:
513 | task_id = str(uuid.uuid4())
514 | with queue_lock:
515 | request_queue.append((task_id, request.get_data(), torrent_id))
516 | active_tasks[task_id] = torrent_id
517 | return jsonify({"status": "queued", "task_id": task_id, "position": len(request_queue)}), 429
518 |
519 | @app.route("/task-status/")
520 | def get_task_status(task_id):
521 | with download_lock:
522 | status_data = download_statuses.get(task_id, {})
523 |
524 | compatible_status = {
525 | "starting": "processing",
526 | "downloading": "processing",
527 | "completed": "processed",
528 | "failed": "error"
529 | }.get(status_data.get("status"), "unknown")
530 |
531 | return jsonify({
532 | "status": compatible_status,
533 | "progress": status_data.get("progress", 0),
534 | "speed_mbps": status_data.get("speed", 0)/1024/1024,
535 | "filename": status_data.get("filename", ""),
536 | })
537 |
538 | @app.route("/health")
539 | def health_check():
540 | return jsonify({
541 | "status": "healthy",
542 | "queue_size": len(request_queue),
543 | "active_tasks": len(active_tasks),
544 | "concurrency_limit": MAX_CONCURRENT_TASKS,
545 | "workers_alive": sum(1 for t in workers if t.is_alive())
546 | }), 200
547 |
548 | if __name__ == "__main__":
549 | workers = [TaskWorker() for _ in range(MAX_CONCURRENT_TASKS * 2)]
550 | for w in workers:
551 | w.start()
552 | app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5002")), threaded=True)
553 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | rd-symlink-backend:
5 | build: .
6 | container_name: ${CONTAINER_NAME}
7 | user: "${UID}:${GID}"
8 | env_file: .env
9 | ports:
10 | - "${PORT}:${PORT}"
11 | volumes:
12 | - ${RCLONE_MOUNT_PATH}:${RCLONE_MOUNT_PATH}:ro
13 | - ${SYMLINK_BASE_PATH}:${SYMLINK_BASE_PATH}
14 | - ${FINAL_LIBRARY_PATH}:${FINAL_LIBRARY_PATH}
15 | - ${DOWNLOAD_COMPLETE_PATH}:${DOWNLOAD_COMPLETE_PATH}
16 | - ${LOGS_PATH}:/app/logs
17 | restart: unless-stopped
18 | logging:
19 | driver: "json-file"
20 | options:
21 | max-size: "10m"
22 | max-file: "5"
23 |
--------------------------------------------------------------------------------
/rd_symlink.user.js:
--------------------------------------------------------------------------------
1 | // ==UserScript==
2 | // @name RD Symlink Manager
3 | // @namespace http://tampermonkey.net/
4 | // @version 3.0
5 | // @description Universal Real-Debrid client with symlink support
6 | // @author Your Name
7 | // @match *://*/*
8 | // @grant GM_xmlhttpRequest
9 | // @grant GM_setClipboard
10 | // @grant GM_addStyle
11 | // @grant GM_getValue
12 | // @grant GM_setValue
13 | // @grant GM_registerMenuCommand
14 | // @grant unsafeWindow
15 | // @connect *
16 | // ==/UserScript==
17 |
18 | (function() {
19 | 'use strict';
20 | const uw = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
21 |
22 | const CONFIG = {
23 | backendUrl: GM_getValue('backendUrl', ''),
24 | instanceName: GM_getValue('instanceName', 'default'),
25 | videoExtensions: new Set(['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v']),
26 | minFileSize: 50 * 1024 * 1024,
27 | retryDelay: 30000,
28 | maxRetries: 15,
29 | maxHistoryItems: 200,
30 | syncInterval: 1000
31 | };
32 |
33 | async function rdProxy(endpoint, method = 'GET', data = null) {
34 | try {
35 | const response = await new Promise((resolve, reject) => {
36 | GM_xmlhttpRequest({
37 | method: 'POST',
38 | url: `${CONFIG.backendUrl}/rd-proxy`,
39 | headers: {'Content-Type': 'application/json'},
40 | data: JSON.stringify({ endpoint, method, data }),
41 | onload: (res) => {
42 | try {
43 | if (!res.responseText.trim()) throw new Error(`Empty response (${res.status})`);
44 | const data = JSON.parse(res.responseText);
45 | if (res.status >= 200 && res.status < 300) resolve(data);
46 | else {
47 | const errorDetails = [
48 | data.source && `Source: ${data.source}`,
49 | data.status && `Status: ${data.status}`,
50 | data.message || data.error
51 | ].filter(Boolean).join(' | ');
52 | reject(errorDetails);
53 | }
54 | } catch(e) {
55 | reject(`Invalid server response: ${res.responseText.slice(0, 100)}`);
56 | }
57 | },
58 | onerror: (err) => reject(`Connection failed: ${err.statusText}`)
59 | });
60 | });
61 | return response;
62 | } catch (error) {
63 | throw new Error(`Real-Debrid Error: ${error}`);
64 | }
65 | }
66 |
67 | GM_registerMenuCommand('⚙️ Configure RD Manager', () => {
68 | const url = prompt('Enter backend URL (http(s)://your-domain.com):', CONFIG.backendUrl);
69 | if (url) GM_setValue('backendUrl', url);
70 | const name = prompt('Instance name (optional):', CONFIG.instanceName);
71 | if (name) GM_setValue('instanceName', name);
72 | location.reload();
73 | });
74 |
75 | const instanceKey = CONFIG.instanceName.toLowerCase().replace(/[^a-z0-9]/g, '_');
76 | const storageKeys = {
77 | tasks: `rd_tasks_${instanceKey}`,
78 | activeTasks: `active_tasks_${instanceKey}`,
79 | clickedHashes: `clickedHashes_${instanceKey}`,
80 | currentFilter: `current_filter_${instanceKey}`
81 | };
82 |
83 | let tasks = JSON.parse(GM_getValue(storageKeys.tasks, '[]')).filter(t => t && t.magnet);
84 | let activeTasks = JSON.parse(GM_getValue(storageKeys.activeTasks, '{}'));
85 | let currentFilter = GM_getValue(storageKeys.currentFilter, 'all');
86 | let clickedHashes = JSON.parse(GM_getValue(storageKeys.clickedHashes, '{}'));
87 | let updateButtonStatesTimeout;
88 |
89 | function saveActiveTasks() {
90 | tasks = tasks.filter(t => t && t.magnet && getMagnetHash(t.magnet));
91 | Object.keys(activeTasks).forEach(k => { if (!activeTasks[k].magnet) delete activeTasks[k]; });
92 | GM_setValue(storageKeys.activeTasks, JSON.stringify(activeTasks));
93 | GM_setValue(storageKeys.tasks, JSON.stringify(tasks));
94 | GM_setValue(storageKeys.clickedHashes, JSON.stringify(clickedHashes));
95 | }
96 |
97 | setInterval(() => checkForStorageUpdates(), CONFIG.syncInterval);
98 | setInterval(saveActiveTasks, 30000);
99 | uw.addEventListener('beforeunload', saveActiveTasks);
100 |
101 | function checkForStorageUpdates() {
102 | const storedTasks = JSON.parse(GM_getValue(storageKeys.tasks, '[]'));
103 | const storedActiveTasks = JSON.parse(GM_getValue(storageKeys.activeTasks, '{}'));
104 | const storedClickedHashes = JSON.parse(GM_getValue(storageKeys.clickedHashes, '{}'));
105 | let changed = false;
106 |
107 | if (JSON.stringify(storedTasks) !== JSON.stringify(tasks)) { tasks = storedTasks; changed = true; }
108 | if (JSON.stringify(storedActiveTasks) !== JSON.stringify(activeTasks)) {
109 | activeTasks = storedActiveTasks;
110 | changed = true;
111 | Object.values(activeTasks).forEach(task => {
112 | task._isProcessing = false;
113 | if (['pending', 'processing', 'downloading', 'symlinking'].includes(task.status)) {
114 | if (!task._isProcessing) {
115 | task._isProcessing = true;
116 | startProcessing(task.magnet, null, task.mode, task.id)
117 | .catch(() => updateTask(task.id, { status: 'failed' }));
118 | }
119 | }
120 | if (task.status === 'downloading') {
121 | rdProxy(`/torrents/info/${task.rdTorrentId}`)
122 | .then(freshData => {
123 | updateTask(task.id, {
124 | progress: 35 + (freshData.progress * 0.65),
125 | statusText: `Resumed: ${freshData.progress}%`
126 | });
127 | });
128 | }
129 | });
130 | }
131 | if (JSON.stringify(storedClickedHashes) !== JSON.stringify(clickedHashes)) { clickedHashes = storedClickedHashes; changed = true; }
132 | if (changed) { updateTaskManager(); updateButtonStates(); }
133 | }
134 |
135 | function getMagnetHash(magnetUrl) {
136 | const hashMatch = magnetUrl.match(/xt=urn:btih:([^&]+)/i);
137 | return hashMatch ? hashMatch[1].toUpperCase() : null;
138 | }
139 |
140 | function getMagnetName(magnetUrl) {
141 | try {
142 | const nameMatch = magnetUrl.match(/dn=([^&]+)/i);
143 | return nameMatch ? decodeURIComponent(nameMatch[1].replace(/\+/g, ' '))
144 | : `Torrent-${getMagnetHash(magnetUrl)?.substring(0, 8) || 'Unknown'}`;
145 | } catch { return 'Unknown Magnet'; }
146 | }
147 |
148 | GM_addStyle(`
149 | .rd-magnet-button { display: inline-block; margin-left: 8px; padding: 4px 10px; background: #2ecc71; color: white; border-radius: 4px;
150 | font: bold 12px sans-serif; cursor: pointer; border: none; transition: all 0.3s ease; }
151 | .rd-magnet-button:disabled { cursor: not-allowed; opacity: 0.7; }
152 | .rd-magnet-button:hover:not(:disabled) { opacity: 0.85; }
153 | #rd-task-manager { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 600px; max-height: 80vh;
154 | background: white; border-radius: 8px; box-shadow: 0 5px 30px rgba(0,0,0,0.3); z-index: 999999; display: none; font-family: sans-serif; }
155 | #rd-task-manager.active { display: block; }
156 | .rd-task-manager-header { padding: 15px; background: #3498db; color: white; display: flex; flex-direction: column; gap: 10px; }
157 | .rd-task-manager-close { background: none; border: none; color: white; font-size: 20px; cursor: pointer; position: absolute;
158 | top: 10px; right: 10px; }
159 | .rd-task-list { padding: 15px; max-height: 60vh; overflow-y: auto; }
160 | .rd-task-item { padding: 10px; margin: 5px 0; background: #f8f9fa; border-radius: 4px; display: flex; align-items: center;
161 | justify-content: space-between; }
162 | .rd-task-info { flex: 1; overflow: hidden; margin-right: 10px; }
163 | .rd-task-name { font-weight: bold; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
164 | .rd-task-progress { height: 4px; background: #ddd; border-radius: 2px; margin: 5px 0; overflow: hidden; }
165 | .rd-task-progress-bar { height: 100%; background: #2ecc71; transition: width 0.3s ease; }
166 | .rd-task-status { padding: 2px 8px; border-radius: 3px; font-size: 12px; min-width: 70px; text-align: center; }
167 | .rd-task-pending { background: #f39c12; }
168 | .rd-task-processing { background: #3498db; }
169 | .rd-task-downloading { background: #2980b9; }
170 | .rd-task-symlinking { background: #9b59b6; }
171 | .rd-task-completed { background: #2ecc71; }
172 | .rd-task-failed { background: #e74c3c; }
173 | .rd-task-done { background: #95a5a6; }
174 | .rd-task-actions { display: flex; gap: 5px; }
175 | .rd-task-button { padding: 3px 8px; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
176 | .rd-task-retry { background: #3498db !important; color: white !important; }
177 | .rd-task-delete-rd { background: #95a5a6 !important; color: white !important; }
178 | #rd-task-manager-toggle { position: fixed; bottom: 20px; right: 20px; width: 40px; height: 40px; background: #3498db; color: white;
179 | border-radius: 50%; display: flex; justify-content: center; align-items: center; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2);
180 | z-index: 99999; }
181 | .rd-filter-controls { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
182 | .rd-filter-btn { padding: 4px 8px; border: 1px solid #3498db; border-radius: 4px; background: white; color: #3498db; cursor: pointer;
183 | font-size: 12px; }
184 | .rd-filter-btn.active { background: #3498db; color: white; }
185 | .rd-clear-buttons { display: flex; gap: 8px; margin-left: auto; }
186 | .rd-clear-history { background: #f1c40f !important; border-color: #f39c12 !important; }
187 | .rd-clear-tracking { background: #e74c3c !important; border-color: #c0392b !important; }
188 | .rd-export-import { background: #2ecc71 !important; border-color: #27ae60 !important; color: white !important; }
189 | .rd-clear-history:hover { background: #f39c12 !important; }
190 | .rd-clear-tracking:hover { background: #c0392b !important; }
191 | .rd-export-import:hover { background: #27ae60 !important; }
192 | #rd-status-message { position: fixed; bottom: 20px; left: 20px; padding: 10px 15px; background: #3498db; color: white; border-radius: 5px;
193 | z-index: 99999; font-family: sans-serif; box-shadow: 0 2px 10px rgba(0,0,0,0.2); max-width: 80vw; }
194 | `);
195 |
196 | function createTaskManager() {
197 | if (!uw.document.getElementById('rd-task-manager')) {
198 | const manager = uw.document.createElement('div');
199 | manager.id = 'rd-task-manager';
200 | manager.innerHTML = `
201 |
216 |
217 | `;
218 | uw.document.body.appendChild(manager);
219 | manager.querySelector('.rd-task-manager-close').addEventListener('click', () => manager.classList.remove('active'));
220 | manager.querySelectorAll('.rd-filter-btn').forEach(btn => {
221 | btn.addEventListener('click', () => {
222 | manager.querySelectorAll('.rd-filter-btn').forEach(b => b.classList.remove('active'));
223 | btn.classList.add('active');
224 | currentFilter = btn.dataset.filter;
225 | GM_setValue(storageKeys.currentFilter, currentFilter);
226 | updateTaskManager();
227 | });
228 | });
229 | manager.querySelector('.rd-clear-history').addEventListener('click', () => {
230 | if (confirm('Clear history but keep tracking?')) {
231 | tasks = tasks.filter(t => !['completed', 'failed', 'done'].includes(t.status));
232 | saveActiveTasks();
233 | updateTaskManager();
234 | showStatus("History cleared", '#2ecc71', 3000);
235 | }
236 | });
237 | manager.querySelector('.rd-clear-tracking').addEventListener('click', () => {
238 | if (confirm('Clear ALL history and tracking?')) {
239 | tasks = [];
240 | clickedHashes = {};
241 | activeTasks = {};
242 | GM_setValue(storageKeys.tasks, '[]');
243 | GM_setValue(storageKeys.clickedHashes, '{}');
244 | GM_setValue(storageKeys.activeTasks, '{}');
245 | uw.document.querySelectorAll('.rd-magnet-button').forEach(btn => {
246 | btn.textContent = 'RD';
247 | btn.style.background = '#2ecc71';
248 | btn.disabled = false;
249 | });
250 | updateTaskManager();
251 | showStatus("Full reset complete", '#e74c3c', 3000);
252 | }
253 | });
254 | manager.querySelector('.rd-export-import').addEventListener('click', handleExportImport);
255 | }
256 | }
257 |
258 | function handleExportImport(e) {
259 | if (e.shiftKey) {
260 | const input = uw.document.createElement('input');
261 | input.type = 'file';
262 | input.accept = '.json';
263 | input.onchange = (e) => {
264 | const file = e.target.files[0];
265 | const reader = new FileReader();
266 | reader.onload = () => {
267 | try {
268 | const data = JSON.parse(reader.result);
269 | tasks = data.tasks || [];
270 | clickedHashes = data.clickedHashes || {};
271 | activeTasks = data.activeTasks || {};
272 | GM_setValue(storageKeys.tasks, JSON.stringify(tasks));
273 | GM_setValue(storageKeys.clickedHashes, JSON.stringify(clickedHashes));
274 | GM_setValue(storageKeys.activeTasks, JSON.stringify(activeTasks));
275 | updateTaskManager();
276 | showStatus('Data imported!', '#2ecc71', 3000);
277 | } catch { showStatus('Invalid backup file', '#e74c3c', 5000); }
278 | };
279 | reader.readAsText(file);
280 | };
281 | input.click();
282 | } else {
283 | const data = {
284 | tasks: JSON.parse(GM_getValue(storageKeys.tasks)),
285 | clickedHashes: JSON.parse(GM_getValue(storageKeys.clickedHashes)),
286 | activeTasks: JSON.parse(GM_getValue(storageKeys.activeTasks))
287 | };
288 | const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
289 | const url = URL.createObjectURL(blob);
290 | const a = uw.document.createElement('a');
291 | a.href = url;
292 | a.download = `rd-${instanceKey}-backup-${Date.now()}.json`;
293 | a.click();
294 | URL.revokeObjectURL(url);
295 | }
296 | }
297 |
298 | function createTaskManagerToggle() {
299 | if (!uw.document.getElementById('rd-task-manager-toggle')) {
300 | const toggle = uw.document.createElement('div');
301 | toggle.id = 'rd-task-manager-toggle';
302 | toggle.textContent = 'RD';
303 | toggle.addEventListener('click', () => {
304 | const manager = uw.document.getElementById('rd-task-manager');
305 | manager.classList.toggle('active');
306 | updateTaskManager();
307 | });
308 | uw.document.body.appendChild(toggle);
309 | }
310 | }
311 |
312 | function updateTaskManager() {
313 | const manager = uw.document.getElementById('rd-task-manager');
314 | if (!manager) return;
315 | const list = manager.querySelector('.rd-task-list');
316 | list.innerHTML = '';
317 | let filteredTasks = [...Object.values(activeTasks), ...tasks];
318 | switch(currentFilter) {
319 | case 'active': filteredTasks = filteredTasks.filter(t => ['pending', 'processing', 'downloading', 'symlinking'].includes(t.status)); break;
320 | case 'symlinking': filteredTasks = filteredTasks.filter(t => t.status === 'symlinking'); break;
321 | case 'completed': filteredTasks = filteredTasks.filter(t => ['completed', 'done'].includes(t.status)); break;
322 | }
323 | filteredTasks = filteredTasks.sort((a, b) => b.timestamp - a.timestamp).slice(0, currentFilter === 'all' ? CONFIG.maxHistoryItems : 100);
324 | filteredTasks.forEach(task => {
325 | const taskEl = uw.document.createElement('div');
326 | taskEl.className = 'rd-task-item';
327 | const statusClass = `rd-task-${task.status === 'symlinking' ? 'symlinking' : task.status}`;
328 | const displayName = task.name || getMagnetName(task.magnet);
329 | taskEl.innerHTML = `
330 |
331 |
${displayName.substring(0, 50)}
332 |
333 |
${task.statusText || ''}
334 |
335 | ${task.status.charAt(0).toUpperCase() + task.status.slice(1)}
336 |
337 | ${['failed', 'cancelled'].includes(task.status) ? `` : ''}
338 |
339 | ${task.rdTorrentId ? `` : ''}
340 |
341 | `;
342 | list.appendChild(taskEl);
343 | });
344 | list.querySelectorAll('.rd-task-retry').forEach(btn => btn.addEventListener('click', () => retryTask(btn.dataset.id)));
345 | list.querySelectorAll('.rd-task-remove, .rd-task-delete-rd').forEach(btn => btn.addEventListener('click', () => removeTask(btn.dataset.id)));
346 | if (filteredTasks.length === 0) list.innerHTML = 'No tasks found
';
347 | }
348 |
349 | function retryTask(taskId) {
350 | const task = tasks.find(t => t.id === taskId);
351 | if (task) {
352 | tasks = tasks.filter(t => t.id !== taskId);
353 | const newTask = {
354 | ...task,
355 | id: `task_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
356 | status: 'pending',
357 | statusText: 'Retrying...',
358 | progress: 0,
359 | retries: task.retries + 1,
360 | timestamp: Date.now()
361 | };
362 | activeTasks[newTask.id] = newTask;
363 | clickedHashes[newTask.hash] = true;
364 | GM_setValue(storageKeys.clickedHashes, JSON.stringify(clickedHashes));
365 | startProcessing(newTask.magnet, null, newTask.mode, newTask.id);
366 | updateTaskManager();
367 | }
368 | }
369 |
370 | function removeTask(taskId) {
371 | if (activeTasks[taskId]) {
372 | const task = activeTasks[taskId];
373 | if (task.rdTorrentId && event.target.classList.contains('rd-task-delete-rd')) {
374 | rdProxy(`/torrents/delete/${task.rdTorrentId}`, 'DELETE')
375 | .catch(() => showStatus(`RD Delete Failed`, '#e74c3c', 5000));
376 | }
377 | task.status = 'cancelled';
378 | task.statusText = 'Cancelled by user';
379 | tasks.push(task);
380 | delete activeTasks[taskId];
381 | delete clickedHashes[task.hash];
382 | GM_setValue(storageKeys.clickedHashes, JSON.stringify(clickedHashes));
383 | }
384 | tasks = tasks.filter(t => t.id !== taskId);
385 | saveActiveTasks();
386 | updateTaskManager();
387 | }
388 |
389 | async function startProcessing(magnetUrl, button, mode, taskId = null) {
390 | const hash = getMagnetHash(magnetUrl);
391 | if (!taskId) taskId = addTask(magnetUrl, mode, button?.closest('tr')?.querySelector('td')?.textContent?.trim() || '');
392 | try {
393 | let task = activeTasks[taskId];
394 | let rdTorrentId = task?.rdTorrentId;
395 | let torrentInfo = task?.torrentInfo;
396 | let videoFiles = task?.videoFiles;
397 | const existingCompleted = [...tasks, ...Object.values(activeTasks)].find(t => t.hash === hash && ['completed', 'done'].includes(t.status) && t.mode === mode);
398 | if (existingCompleted) {
399 | if (mode === 'symlink' && existingCompleted.result?.path) {
400 | GM_setClipboard(existingCompleted.result.path);
401 | completeTask(taskId, true, existingCompleted.result);
402 | showStatus('Existing symlink copied!', '#2ecc71', 3000);
403 | return;
404 | }
405 | throw new Error('This magnet has already been processed');
406 | }
407 | if (!rdTorrentId) {
408 | updateTask(taskId, { status: 'processing', statusText: 'Adding magnet...', progress: 0 });
409 | const { id } = await rdProxy('/torrents/addMagnet', 'POST', `magnet=${encodeURIComponent(magnetUrl)}`);
410 | rdTorrentId = id;
411 | updateTask(taskId, { rdTorrentId, status: 'processing', statusText: 'Analyzing files...', progress: 10 });
412 | }
413 | if (!torrentInfo || !videoFiles) {
414 | while (activeTasks[taskId]) {
415 | torrentInfo = await rdProxy(`/torrents/info/${rdTorrentId}`);
416 | updateTask(taskId, { torrentInfo });
417 | updateTask(taskId, { status: 'processing', statusText: `Processing files (${torrentInfo.status})`, progress: Math.min(30, task.progress + 2) });
418 | if (torrentInfo.status === 'waiting_files_selection') break;
419 | await new Promise(r => setTimeout(r, 3000));
420 | }
421 | videoFiles = torrentInfo.files.filter(f => {
422 | const path = f.path || '';
423 | const fileName = path.split(/[\\/]/).pop() || '';
424 | const ext = fileName.split('.').pop()?.toLowerCase() || '';
425 | return CONFIG.videoExtensions.has(ext) && f.bytes >= CONFIG.minFileSize && !fileName.toLowerCase().includes('sample');
426 | }).sort((a, b) => b.bytes - a.bytes);
427 | if (videoFiles.length === 0) throw new Error("No supported video files found");
428 | updateTask(taskId, { videoFiles });
429 | if (!torrentInfo.files_selected) {
430 | await rdProxy(`/torrents/selectFiles/${rdTorrentId}`, 'POST', `files=${videoFiles.map(f => f.id).join(',')}`);
431 | updateTask(taskId, { status: 'downloading', statusText: 'Starting download...', progress: 35 });
432 | }
433 | }
434 | let lastProgress = task?.progress || 0;
435 | while (activeTasks[taskId]) {
436 | const downloadStatus = await rdProxy(`/torrents/info/${rdTorrentId}`);
437 | const currentProgress = Math.round(downloadStatus.progress);
438 | const isDownloadComplete = ['downloaded', 'seeding'].includes(downloadStatus.status);
439 | const visualProgress = isDownloadComplete ? 100 : 35 + (currentProgress * 0.65);
440 |
441 | updateTask(taskId, {
442 | downloadStatus,
443 | status: 'downloading',
444 | progress: Math.min(visualProgress, 100),
445 | statusText: isDownloadComplete ? 'Verifying download'
446 | : `Downloading: ${currentProgress}% (${formatSpeed(downloadStatus.speed)})`
447 | });
448 |
449 | if (isDownloadComplete) {
450 | updateTask(taskId, { progress: 100, statusText: 'Download verified' });
451 | break;
452 | }
453 | await new Promise(r => setTimeout(r, 3000));
454 | }
455 | if (mode === 'symlink') {
456 | if (task.result?.path) {
457 | GM_setClipboard(task.result.path);
458 | completeTask(taskId, true, task.result);
459 | return;
460 | }
461 | updateTask(taskId, { status: 'symlinking', statusText: 'Finalizing symlink...' });
462 | const fullPath = videoFiles[0].path || '';
463 | const fileName = fullPath.split(/[\\/]/).pop();
464 | const [baseName] = fileName.match(/(.*?)(\.[^.]*)?$/) || [fileName];
465 | const cleanDirName = baseName.replace(/[<>:"/\\|?*]/g, '_').substring(0, 200);
466 | try {
467 | const symlinkResult = await backendAPI('/symlink', {
468 | hash,
469 | filename: cleanDirName,
470 | torrent_id: rdTorrentId,
471 | file_size: videoFiles[0].bytes,
472 | file_index: videoFiles[0].id
473 | });
474 | const symlinkPath = symlinkResult.path || `${symlinkResult.directory}/${fileName}`;
475 | if (!symlinkPath) throw new Error('Symlink path not received');
476 | GM_setClipboard(symlinkPath);
477 | completeTask(taskId, true, { path: symlinkPath });
478 | showStatus('Symlink ready! Copied to clipboard.', '#2ecc71', 3000);
479 | } catch (error) {
480 | if (error.path) {
481 | GM_setClipboard(error.path);
482 | completeTask(taskId, true, { path: error.path });
483 | showStatus('Symlink already exists! Copied to clipboard.', '#2ecc71', 3000);
484 | } else throw error;
485 | }
486 | } else completeTask(taskId, true);
487 | } catch (error) {
488 | const message = error.message.replace('Real-Debrid Error: ', '');
489 | completeTask(taskId, false, { error: message });
490 | if (button) {
491 | button.textContent = '✗ Failed';
492 | button.style.background = '#e74c3c';
493 | }
494 | showStatus(`Failed: ${message}`, '#e74c3c', 5000);
495 | }
496 | }
497 |
498 | function formatSpeed(bytesPerSecond) {
499 | if (!bytesPerSecond) return '0 B/s';
500 | const speeds = ['B/s', 'KB/s', 'MB/s'];
501 | let speed = bytesPerSecond;
502 | let unitIndex = 0;
503 | while (speed >= 1024 && unitIndex < speeds.length - 1) { speed /= 1024; unitIndex++; }
504 | return `${speed.toFixed(unitIndex === 0 ? 0 : 1)} ${speeds[unitIndex]}`;
505 | }
506 |
507 | function addTask(magnet, mode, filename = '') {
508 | const hash = getMagnetHash(magnet);
509 | const existing = Object.values(activeTasks).find(t => t.hash === hash && t.mode === mode);
510 | if (existing) return existing.id;
511 | const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
512 | activeTasks[taskId] = { id: taskId, hash, magnet, name: getMagnetName(magnet), mode, filename, status: 'pending', progress: 0, timestamp: Date.now(), retries: 0 };
513 | clickedHashes[hash] = true;
514 | GM_setValue(storageKeys.clickedHashes, JSON.stringify(clickedHashes));
515 | saveActiveTasks();
516 | return taskId;
517 | }
518 |
519 | function updateTask(taskId, updates) {
520 | if (activeTasks[taskId]) {
521 | activeTasks[taskId] = { ...activeTasks[taskId], ...updates };
522 | saveActiveTasks();
523 | updateTaskManager();
524 | debouncedUpdateButtonStates();
525 | }
526 | }
527 |
528 | function completeTask(taskId, success, result = null) {
529 | if (activeTasks[taskId]) {
530 | const task = activeTasks[taskId];
531 | task.status = success ? 'completed' : 'failed';
532 | task.progress = success ? 100 : 0;
533 | task.timestamp = Date.now();
534 | task.statusText = success ? 'Completed successfully' : 'Failed';
535 | if (result) task.result = result;
536 | tasks.push(task);
537 | delete activeTasks[taskId];
538 | saveActiveTasks();
539 | updateTaskManager();
540 | debouncedUpdateButtonStates();
541 | }
542 | }
543 |
544 | function debouncedUpdateButtonStates() {
545 | clearTimeout(updateButtonStatesTimeout);
546 | updateButtonStatesTimeout = setTimeout(updateButtonStates, 100);
547 | }
548 |
549 | function updateButtonStates() {
550 | uw.document.querySelectorAll('.rd-magnet-button').forEach(btn => {
551 | const magnet = btn.getAttribute('data-magnet');
552 | if (!magnet) return;
553 | const hash = getMagnetHash(magnet);
554 | const task = [...Object.values(activeTasks), ...tasks].find(t => t.hash === hash);
555 | if (task) {
556 | btn.textContent = {
557 | pending: '⏳ Pending',
558 | processing: '⏳ Processing',
559 | downloading: '⏳ Downloading',
560 | symlinking: '⏳ Symlinking',
561 | completed: '✓ Completed',
562 | done: '✓ Done',
563 | failed: '✗ Failed',
564 | cancelled: '✗ Cancelled'
565 | }[task.status] || 'RD';
566 | btn.style.background = {
567 | pending: '#f39c12',
568 | processing: '#3498db',
569 | downloading: '#2980b9',
570 | symlinking: '#9b59b6',
571 | completed: '#2ecc71',
572 | done: '#95a5a6',
573 | failed: '#e74c3c',
574 | cancelled: '#e74c3c'
575 | }[task.status] || '#2ecc71';
576 | btn.disabled = ['completed', 'done', 'failed', 'cancelled'].includes(task.status);
577 | } else {
578 | btn.textContent = clickedHashes[hash] ? '✓ Processed' : 'RD';
579 | btn.style.background = clickedHashes[hash] ? '#95a5a6' : '#2ecc71';
580 | btn.disabled = !!clickedHashes[hash];
581 | }
582 | });
583 | }
584 |
585 | function markAsDone(magnetUrl) {
586 | const hash = getMagnetHash(magnetUrl);
587 | if (!hash) return;
588 | const existingTask = tasks.find(t => t.hash === hash) || Object.values(activeTasks).find(t => t.hash === hash);
589 | if (existingTask) { existingTask.status = 'done'; existingTask.progress = 100; }
590 | else tasks.push({ id: `done_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, hash, magnet: magnetUrl, name: getMagnetName(magnetUrl), status: 'done', progress: 100, timestamp: Date.now(), retries: 0 });
591 | clickedHashes[hash] = true;
592 | GM_setValue(storageKeys.clickedHashes, JSON.stringify(clickedHashes));
593 | saveActiveTasks();
594 | updateButtonStates();
595 | showStatus("Marked as done!", '#2ecc71', 2000);
596 | }
597 |
598 | function initUI() {
599 | createTaskManager();
600 | createTaskManagerToggle();
601 | updateTaskManager();
602 | setTimeout(() => {
603 | // Force process all active tasks on initial load
604 | Object.values(activeTasks).forEach(task => {
605 | if (['pending', 'processing', 'downloading', 'symlinking'].includes(task.status)) {
606 | task._isProcessing = false;
607 | startProcessing(task.magnet, null, task.mode, task.id)
608 | .catch(() => updateTask(task.id, { status: 'failed' }));
609 | }
610 | });
611 | updateTaskManager();
612 | }, 2000);
613 | const processMagnetLinks = () => {
614 | uw.document.querySelectorAll('a[href^="magnet:"]').forEach(link => {
615 | if (!link.nextElementSibling?.classList?.contains('rd-magnet-button')) {
616 | const btn = uw.document.createElement('button');
617 | btn.className = 'rd-magnet-button';
618 | btn.setAttribute('data-magnet', link.href);
619 | btn.addEventListener('click', handleButtonClick);
620 | link.insertAdjacentElement('afterend', btn);
621 | }
622 | });
623 | debouncedUpdateButtonStates();
624 | };
625 | processMagnetLinks();
626 | const observer = new uw.MutationObserver(mutations => {
627 | mutations.forEach(mutation => {
628 | if (mutation.addedNodes.length) processMagnetLinks();
629 | });
630 | });
631 | observer.observe(uw.document.body, { childList: true, subtree: true });
632 | checkBackendHealth().catch(() => {});
633 | }
634 |
635 | function handleButtonClick(e) {
636 | const button = e.currentTarget;
637 | const magnetLink = button.getAttribute('data-magnet');
638 | if (!magnetLink) return;
639 | const hash = getMagnetHash(magnetLink);
640 | const existingTask = tasks.find(t => t.hash === hash && ['completed', 'done'].includes(t.status));
641 | if (existingTask) { showStatus('Already available!', '#2ecc71', 3000); return; }
642 | const menu = uw.document.createElement('div');
643 | menu.style.cssText = `position: absolute; background: white; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 99999;`;
644 | ['Cache Only', 'Cache + Symlink', 'Mark as Done'].forEach((text, index) => {
645 | const item = uw.document.createElement('div');
646 | item.textContent = text;
647 | item.style.cssText = `padding: 8px 12px; cursor: pointer; background: ${index === 2 ? '#f8f9fa' : 'white'}; border-bottom: ${index < 2 ? '1px solid #eee' : 'none'}; white-space: nowrap;`;
648 | item.onmouseenter = () => item.style.background = '#f0f0f0';
649 | item.onmouseleave = () => item.style.background = index === 2 ? '#f8f9fa' : 'white';
650 | item.onclick = () => { menu.remove(); handleMenuChoice(index, magnetLink, button); };
651 | menu.appendChild(item);
652 | });
653 | const rect = button.getBoundingClientRect();
654 | menu.style.top = `${rect.bottom + uw.scrollY}px`;
655 | menu.style.left = `${rect.left + uw.scrollX}px`;
656 | uw.document.body.appendChild(menu);
657 | const closeMenu = (e) => {
658 | if (!menu.contains(e.target) && e.target !== button) { menu.remove(); uw.document.removeEventListener('click', closeMenu); }
659 | };
660 | setTimeout(() => uw.document.addEventListener('click', closeMenu), 0);
661 | }
662 |
663 | function handleMenuChoice(index, magnetLink, button) {
664 | switch(index) {
665 | case 0: addTask(magnetLink, 'cache'); startProcessing(magnetLink, button, 'cache'); break;
666 | case 1: addTask(magnetLink, 'symlink'); startProcessing(magnetLink, button, 'symlink'); break;
667 | case 2: markAsDone(magnetLink); break;
668 | }
669 | debouncedUpdateButtonStates();
670 | }
671 |
672 | function showStatus(message, color = '#3498db', timeout = 0) {
673 | const existing = uw.document.getElementById('rd-status-message');
674 | if (existing) existing.remove();
675 | const msg = uw.document.createElement('div');
676 | msg.id = 'rd-status-message';
677 | msg.textContent = message;
678 | msg.style.cssText = `position: fixed; bottom: 20px; left: 20px; padding: 10px 15px; background: ${color}; color: white; border-radius: 5px; z-index: 99999; font-family: sans-serif; box-shadow: 0 2px 10px rgba(0,0,0,0.2); max-width: 80vw;`;
679 | uw.document.body.appendChild(msg);
680 | if (timeout > 0) setTimeout(() => msg.remove(), timeout);
681 | }
682 |
683 | async function backendAPI(endpoint, data = {}, retries = CONFIG.maxRetries) {
684 | try {
685 | return await new Promise((resolve, reject) => {
686 | GM_xmlhttpRequest({
687 | method: 'POST',
688 | url: `${CONFIG.backendUrl}${endpoint}`,
689 | headers: { 'Content-Type': 'application/json' },
690 | data: JSON.stringify(data),
691 | timeout: 20000,
692 | onload: (res) => {
693 | try {
694 | const response = JSON.parse(res.responseText);
695 | if (res.status === 200) {
696 | if (response.error) {
697 | if (response.error.includes('Symlink already exists')) resolve({ path: response.path || response.directory });
698 | else reject(response.error);
699 | } else resolve(response);
700 | } else reject(response.error || res.statusText);
701 | } catch { reject(`Invalid JSON: ${res.responseText.slice(0, 100)}`); }
702 | },
703 | onerror: (err) => reject(err.statusText || 'Backend connection failed')
704 | });
705 | });
706 | } catch (error) {
707 | if (retries > 0) {
708 | await new Promise(r => setTimeout(r, CONFIG.retryDelay));
709 | return backendAPI(endpoint, data, retries - 1);
710 | }
711 | if (typeof error === 'string' && error.includes('Symlink already exists')) {
712 | const pathMatch = error.match(/at (.+)/);
713 | if (pathMatch) return { path: pathMatch[1] };
714 | }
715 | throw error;
716 | }
717 | }
718 |
719 | async function checkBackendHealth() {
720 | try {
721 | await new Promise((resolve, reject) => {
722 | GM_xmlhttpRequest({
723 | method: 'GET',
724 | url: `${CONFIG.backendUrl}/health`,
725 | timeout: 5000,
726 | onload: (res) => res.status === 200 ? resolve() : reject(),
727 | onerror: reject
728 | });
729 | });
730 | return true;
731 | } catch { return false; }
732 | }
733 |
734 | if (uw.document.readyState === 'loading') {
735 | uw.document.addEventListener('DOMContentLoaded', initUI);
736 | } else {
737 | initUI();
738 | }
739 | })();
740 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | waitress==3.0.0
2 | flask==3.0.2
3 | requests==2.31.0
4 | bencode.py==1.2.0
5 | flask-cors==4.0.0
6 |
--------------------------------------------------------------------------------