| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>qBittorrent Magnet Link Manager</title> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| padding: 20px; |
| } |
| |
| .container { |
| max-width: 1200px; |
| margin: 0 auto; |
| } |
| |
| header { |
| text-align: center; |
| color: white; |
| margin-bottom: 30px; |
| } |
| |
| header h1 { |
| font-size: 2.5em; |
| margin-bottom: 10px; |
| } |
| |
| header p { |
| font-size: 1.1em; |
| opacity: 0.9; |
| } |
| |
| .status-bar { |
| background: white; |
| padding: 15px 20px; |
| border-radius: 8px; |
| margin-bottom: 20px; |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .status-bar .status { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .status-indicator { |
| width: 12px; |
| height: 12px; |
| border-radius: 50%; |
| background-color: #4caf50; |
| animation: pulse 2s infinite; |
| } |
| |
| @keyframes pulse { |
| 0%, 100% { |
| opacity: 1; |
| } |
| 50% { |
| opacity: 0.5; |
| } |
| } |
| |
| .input-section { |
| background: white; |
| padding: 30px; |
| border-radius: 8px; |
| margin-bottom: 30px; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .input-section h2 { |
| margin-bottom: 20px; |
| color: #333; |
| } |
| |
| .input-group { |
| display: flex; |
| gap: 10px; |
| } |
| |
| input[type="text"] { |
| flex: 1; |
| padding: 12px 15px; |
| border: 2px solid #e0e0e0; |
| border-radius: 5px; |
| font-size: 1em; |
| transition: border-color 0.3s; |
| } |
| |
| input[type="text"]:focus { |
| outline: none; |
| border-color: #667eea; |
| } |
| |
| button { |
| padding: 12px 30px; |
| background-color: #667eea; |
| color: white; |
| border: none; |
| border-radius: 5px; |
| font-size: 1em; |
| cursor: pointer; |
| transition: background-color 0.3s; |
| } |
| |
| button:hover { |
| background-color: #5568d3; |
| } |
| |
| button:active { |
| transform: scale(0.98); |
| } |
| |
| .torrents-section { |
| background: white; |
| padding: 30px; |
| border-radius: 8px; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .torrents-section h2 { |
| margin-bottom: 20px; |
| color: #333; |
| } |
| |
| .torrent-list { |
| display: flex; |
| flex-direction: column; |
| gap: 15px; |
| } |
| |
| .torrent-item { |
| background: #f9f9f9; |
| padding: 20px; |
| border-radius: 8px; |
| border-left: 4px solid #667eea; |
| transition: transform 0.2s, box-shadow 0.2s; |
| } |
| |
| .torrent-item:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
| } |
| |
| .torrent-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: start; |
| margin-bottom: 15px; |
| } |
| |
| .torrent-name { |
| font-weight: 600; |
| color: #333; |
| word-break: break-word; |
| flex: 1; |
| } |
| |
| .torrent-state { |
| padding: 5px 10px; |
| border-radius: 20px; |
| font-size: 0.85em; |
| font-weight: 500; |
| text-transform: uppercase; |
| margin-left: 10px; |
| } |
| |
| .state-downloading { |
| background-color: #e3f2fd; |
| color: #1976d2; |
| } |
| |
| .state-completed { |
| background-color: #e8f5e9; |
| color: #388e3c; |
| } |
| |
| .state-paused { |
| background-color: #fff3e0; |
| color: #f57c00; |
| } |
| |
| .state-error { |
| background-color: #ffebee; |
| color: #c62828; |
| } |
| |
| .torrent-progress { |
| margin-bottom: 10px; |
| } |
| |
| .progress-bar { |
| width: 100%; |
| height: 8px; |
| background-color: #e0e0e0; |
| border-radius: 4px; |
| overflow: hidden; |
| margin-bottom: 5px; |
| } |
| |
| .progress-fill { |
| height: 100%; |
| background: linear-gradient(90deg, #667eea, #764ba2); |
| transition: width 0.3s; |
| } |
| |
| .progress-text { |
| font-size: 0.9em; |
| color: #666; |
| display: flex; |
| justify-content: space-between; |
| } |
| |
| .torrent-stats { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
| gap: 15px; |
| margin-bottom: 15px; |
| font-size: 0.9em; |
| } |
| |
| .stat { |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .stat-label { |
| color: #999; |
| font-size: 0.85em; |
| text-transform: uppercase; |
| margin-bottom: 3px; |
| } |
| |
| .stat-value { |
| color: #333; |
| font-weight: 600; |
| } |
| |
| .torrent-actions { |
| display: flex; |
| gap: 10px; |
| } |
| |
| .action-btn { |
| padding: 8px 15px; |
| font-size: 0.9em; |
| border: none; |
| border-radius: 5px; |
| cursor: pointer; |
| transition: background-color 0.3s; |
| } |
| |
| .pause-btn { |
| background-color: #ff9800; |
| color: white; |
| } |
| |
| .pause-btn:hover { |
| background-color: #f57c00; |
| } |
| |
| .resume-btn { |
| background-color: #4caf50; |
| color: white; |
| } |
| |
| .resume-btn:hover { |
| background-color: #45a049; |
| } |
| |
| .delete-btn { |
| background-color: #f44336; |
| color: white; |
| } |
| |
| .delete-btn:hover { |
| background-color: #da190b; |
| } |
| |
| .empty-state { |
| text-align: center; |
| padding: 40px 20px; |
| color: #999; |
| } |
| |
| .empty-state-icon { |
| font-size: 3em; |
| margin-bottom: 10px; |
| } |
| |
| .loading { |
| text-align: center; |
| padding: 20px; |
| color: #667eea; |
| } |
| |
| .spinner { |
| border: 4px solid #f3f3f3; |
| border-top: 4px solid #667eea; |
| border-radius: 50%; |
| width: 30px; |
| height: 30px; |
| animation: spin 1s linear infinite; |
| margin: 0 auto 10px; |
| } |
| |
| @keyframes spin { |
| 0% { transform: rotate(0deg); } |
| 100% { transform: rotate(360deg); } |
| } |
| |
| .alert { |
| padding: 15px 20px; |
| border-radius: 5px; |
| margin-bottom: 20px; |
| display: none; |
| } |
| |
| .alert.show { |
| display: block; |
| } |
| |
| .alert-success { |
| background-color: #e8f5e9; |
| color: #2e7d32; |
| border-left: 4px solid #4caf50; |
| } |
| |
| .alert-error { |
| background-color: #ffebee; |
| color: #c62828; |
| border-left: 4px solid #f44336; |
| } |
| |
| @media (max-width: 768px) { |
| header h1 { |
| font-size: 1.8em; |
| } |
| |
| .input-group { |
| flex-direction: column; |
| } |
| |
| .torrent-stats { |
| grid-template-columns: repeat(2, 1fr); |
| } |
| |
| .torrent-actions { |
| flex-wrap: wrap; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <header> |
| <h1>🚀 qBittorrent Magnet Link Manager</h1> |
| <p>Add magnet links and track your downloads in real-time</p> |
| </header> |
|
|
| <div class="status-bar"> |
| <div class="status"> |
| <div class="status-indicator"></div> |
| <span id="status-text">Connecting to qBittorrent...</span> |
| </div> |
| <div id="refresh-info" style="font-size: 0.9em; color: #666;"></div> |
| </div> |
|
|
| <div id="alert" class="alert"></div> |
|
|
| <div class="input-section"> |
| <h2>Add Magnet Link</h2> |
| <div class="input-group"> |
| <input |
| type="text" |
| id="magnet-input" |
| placeholder="Paste your magnet link here (e.g., magnet:?xt=urn:btih:...)" |
| autocomplete="off" |
| > |
| <button onclick="addTorrent()">Add Torrent</button> |
| </div> |
| </div> |
|
|
| <div class="torrents-section"> |
| <h2>Active Downloads</h2> |
| <div id="torrents-container" class="loading"> |
| <div class="spinner"></div> |
| <p>Loading torrents...</p> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const API_BASE = ''; |
| const REFRESH_INTERVAL = 2000; |
| let refreshTimer = null; |
| |
| |
| function formatBytes(bytes) { |
| if (bytes === 0) return '0 B'; |
| const k = 1024; |
| const sizes = ['B', 'KB', 'MB', 'GB']; |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; |
| } |
| |
| |
| function formatTime(seconds) { |
| if (seconds < 0 || seconds === Infinity) return '∞'; |
| const hours = Math.floor(seconds / 3600); |
| const minutes = Math.floor((seconds % 3600) / 60); |
| const secs = Math.floor(seconds % 60); |
| |
| if (hours > 0) return `${hours}h ${minutes}m`; |
| if (minutes > 0) return `${minutes}m ${secs}s`; |
| return `${secs}s`; |
| } |
| |
| |
| function showAlert(message, type = 'success') { |
| const alertEl = document.getElementById('alert'); |
| alertEl.textContent = message; |
| alertEl.className = `alert show alert-${type}`; |
| setTimeout(() => { |
| alertEl.classList.remove('show'); |
| }, 5000); |
| } |
| |
| |
| async function checkHealth() { |
| try { |
| const response = await fetch(`${API_BASE}/health`); |
| if (response.ok) { |
| document.getElementById('status-text').textContent = '✓ Connected to qBittorrent'; |
| } else { |
| document.getElementById('status-text').textContent = '✗ Connection error'; |
| } |
| } catch (error) { |
| document.getElementById('status-text').textContent = '✗ Connection error'; |
| } |
| } |
| |
| |
| async function fetchTorrents() { |
| try { |
| const response = await fetch(`${API_BASE}/api/torrents`); |
| if (!response.ok) throw new Error('Failed to fetch torrents'); |
| |
| const data = await response.json(); |
| displayTorrents(data.torrents); |
| } catch (error) { |
| console.error('Error fetching torrents:', error); |
| document.getElementById('torrents-container').innerHTML = ` |
| <div class="empty-state"> |
| <div class="empty-state-icon">⚠️</div> |
| <p>Error loading torrents. Please check your connection.</p> |
| </div> |
| `; |
| } |
| } |
| |
| |
| function displayTorrents(torrents) { |
| const container = document.getElementById('torrents-container'); |
| |
| if (torrents.length === 0) { |
| container.innerHTML = ` |
| <div class="empty-state"> |
| <div class="empty-state-icon">📭</div> |
| <p>No torrents yet. Add a magnet link to get started!</p> |
| </div> |
| `; |
| return; |
| } |
| |
| container.innerHTML = '<div class="torrent-list">' + torrents.map(torrent => { |
| const stateClass = `state-${torrent.state.toLowerCase()}`; |
| const isPaused = torrent.state.toLowerCase() === 'paused'; |
| const isCompleted = torrent.state.toLowerCase() === 'uploading' || torrent.progress === 100; |
| |
| return ` |
| <div class="torrent-item"> |
| <div class="torrent-header"> |
| <div class="torrent-name">${escapeHtml(torrent.name)}</div> |
| <div class="torrent-state ${stateClass}">${torrent.state}</div> |
| </div> |
| |
| <div class="torrent-progress"> |
| <div class="progress-bar"> |
| <div class="progress-fill" style="width: ${torrent.progress}%"></div> |
| </div> |
| <div class="progress-text"> |
| <span>${torrent.progress.toFixed(1)}%</span> |
| <span>${formatBytes(torrent.downloaded)} / ${formatBytes(torrent.total_size)}</span> |
| </div> |
| </div> |
| |
| <div class="torrent-stats"> |
| <div class="stat"> |
| <div class="stat-label">Download Speed</div> |
| <div class="stat-value">${formatBytes(torrent.download_speed)}/s</div> |
| </div> |
| <div class="stat"> |
| <div class="stat-label">Upload Speed</div> |
| <div class="stat-value">${formatBytes(torrent.upload_speed)}/s</div> |
| </div> |
| <div class="stat"> |
| <div class="stat-label">ETA</div> |
| <div class="stat-value">${formatTime(torrent.eta)}</div> |
| </div> |
| <div class="stat"> |
| <div class="stat-label">Seeds / Peers</div> |
| <div class="stat-value">${torrent.num_seeds} / ${torrent.num_leechs}</div> |
| </div> |
| </div> |
| |
| <div class="torrent-actions"> |
| ${isPaused ? |
| `<button class="action-btn resume-btn" onclick="resumeTorrent('${torrent.hash}')">Resume</button>` : |
| `<button class="action-btn pause-btn" onclick="pauseTorrent('${torrent.hash}')">Pause</button>` |
| } |
| <button class="action-btn delete-btn" onclick="deleteTorrent('${torrent.hash}')">Delete</button> |
| </div> |
| </div> |
| `; |
| }).join('') + '</div>'; |
| } |
| |
| |
| function escapeHtml(text) { |
| const map = { |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| "'": ''' |
| }; |
| return text.replace(/[&<>"']/g, m => map[m]); |
| } |
| |
| |
| async function addTorrent() { |
| const input = document.getElementById('magnet-input'); |
| const magnetLink = input.value.trim(); |
| |
| if (!magnetLink) { |
| showAlert('Please enter a magnet link', 'error'); |
| return; |
| } |
| |
| try { |
| const response = await fetch(`${API_BASE}/api/add_torrent`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| }, |
| body: JSON.stringify({ magnet_link: magnetLink }) |
| }); |
| |
| if (!response.ok) { |
| const error = await response.json(); |
| throw new Error(error.detail || 'Failed to add torrent'); |
| } |
| |
| showAlert('Torrent added successfully!', 'success'); |
| input.value = ''; |
| await fetchTorrents(); |
| } catch (error) { |
| showAlert(`Error: ${error.message}`, 'error'); |
| } |
| } |
| |
| |
| async function pauseTorrent(hash) { |
| try { |
| const response = await fetch(`${API_BASE}/api/torrents/${hash}/pause`, { |
| method: 'POST' |
| }); |
| |
| if (!response.ok) throw new Error('Failed to pause torrent'); |
| |
| showAlert('Torrent paused', 'success'); |
| await fetchTorrents(); |
| } catch (error) { |
| showAlert(`Error: ${error.message}`, 'error'); |
| } |
| } |
| |
| |
| async function resumeTorrent(hash) { |
| try { |
| const response = await fetch(`${API_BASE}/api/torrents/${hash}/resume`, { |
| method: 'POST' |
| }); |
| |
| if (!response.ok) throw new Error('Failed to resume torrent'); |
| |
| showAlert('Torrent resumed', 'success'); |
| await fetchTorrents(); |
| } catch (error) { |
| showAlert(`Error: ${error.message}`, 'error'); |
| } |
| } |
| |
| |
| async function deleteTorrent(hash) { |
| if (!confirm('Are you sure you want to delete this torrent?')) return; |
| |
| try { |
| const response = await fetch(`${API_BASE}/api/torrents/${hash}`, { |
| method: 'DELETE' |
| }); |
| |
| if (!response.ok) throw new Error('Failed to delete torrent'); |
| |
| showAlert('Torrent deleted', 'success'); |
| await fetchTorrents(); |
| } catch (error) { |
| showAlert(`Error: ${error.message}`, 'error'); |
| } |
| } |
| |
| |
| document.getElementById('magnet-input').addEventListener('keypress', function(event) { |
| if (event.key === 'Enter') { |
| addTorrent(); |
| } |
| }); |
| |
| |
| window.addEventListener('load', async () => { |
| await checkHealth(); |
| await fetchTorrents(); |
| |
| |
| refreshTimer = setInterval(async () => { |
| await fetchTorrents(); |
| }, REFRESH_INTERVAL); |
| }); |
| |
| |
| window.addEventListener('beforeunload', () => { |
| if (refreshTimer) clearInterval(refreshTimer); |
| }); |
| </script> |
| </body> |
| </html> |
|
|