|
|
<!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> |
|
|
|