├── .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 | [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://docs.docker.com) 5 | [![Userscript](https://img.shields.io/badge/Tampermonkey-Supported-yellow.svg)](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 | [![Chrome](https://img.shields.io/badge/Chrome-Install-green)](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo) 55 | [![Firefox](https://img.shields.io/badge/Firefox-Install-green)](https://addons.mozilla.org/firefox/addon/tampermonkey/) 56 | 57 | - [Violentmonkey](https://violentmonkey.github.io/) 58 | [![Chrome](https://img.shields.io/badge/Chrome-Install-green)](https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag) 59 | [![Firefox](https://img.shields.io/badge/Firefox-Install-green)](https://addons.mozilla.org/firefox/addon/violentmonkey/) 60 | 61 | ### 2. Install the Userscript 62 | [![Install Script](https://img.shields.io/badge/Install_Userscript-2ecc71)](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 | ![UI Demo](https://via.placeholder.com/800x500.png/007bff/fff?text=Interface+Preview) 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 | [![Video Thumbnail](https://img.youtube.com/vi/F77i2ZAnb_w/mqdefault.jpg)](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 | ![Bitcoin QR Code](https://blockchain.info/qr?data=1NizzCiosWryLMv51jp118MSjsN7FZQxjC&size=200) 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 |
202 |

Real-Debrid Tasks (${CONFIG.instanceName})

203 |
204 | 205 | 206 | 207 | 208 |
209 | 210 | 211 | 212 |
213 |
214 | 215 |
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 | --------------------------------------------------------------------------------