Fred808 commited on
Commit
2a09f27
·
verified ·
1 Parent(s): 2ec5a8a

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile (1) +66 -0
  2. index (2).html +660 -0
  3. main.py +173 -0
  4. requirements (1).txt +3 -0
  5. run.sh +7 -0
Dockerfile (1) ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build Stage (for dependencies)
2
+ FROM python:3.11-slim as builder
3
+
4
+ # Install build dependencies for qBittorrent client library
5
+ RUN apt-get update && \
6
+ apt-get install -y --no-install-recommends \
7
+ build-essential \
8
+ libssl-dev \
9
+ libffi-dev \
10
+ python3-dev \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Set up working directory
14
+ WORKDIR /app
15
+
16
+ # Copy only the requirements file to leverage Docker cache
17
+ COPY requirements.txt .
18
+
19
+ # Install Python dependencies
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Stage 2: Final Image
23
+ # Use a base image that includes a modern Linux distribution for qBittorrent
24
+ FROM ubuntu:22.04
25
+
26
+ # Set environment variables
27
+ ENV DEBIAN_FRONTEND=noninteractive
28
+ ENV QB_WEBUI_PORT=8080
29
+ ENV APP_PORT=8000
30
+
31
+ # Install qBittorrent-nox (daemon) and other necessary packages
32
+ RUN apt-get update && \
33
+ apt-get install -y --no-install-recommends \
34
+ qbittorrent-nox \
35
+ python3 \
36
+ python3-pip \
37
+ # Install tini for proper signal handling and process management
38
+ tini \
39
+ && rm -rf /var/lib/apt/lists/*
40
+
41
+ # Create a non-root user for security
42
+ RUN useradd -ms /bin/bash qbuser
43
+
44
+ # Copy the installed Python packages from the builder stage
45
+ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3/dist-packages
46
+ COPY --from=builder /usr/local/bin /usr/local/bin
47
+
48
+ # Set up the application directory and copy files
49
+ WORKDIR /home/qbuser/app
50
+ COPY app/ ./app/
51
+ COPY run.sh .
52
+ RUN chmod +x run.sh && \
53
+ chown -R qbuser:qbuser /home/qbuser/app
54
+
55
+ # Set the non-root user
56
+ USER qbuser
57
+
58
+ # Expose the FastAPI port and the qBittorrent Web UI port
59
+ EXPOSE 8000
60
+ EXPOSE 8080
61
+
62
+ # Use tini as the entrypoint to manage the main process
63
+ ENTRYPOINT ["/usr/bin/tini", "--"]
64
+
65
+ # Run the startup script
66
+ CMD ["./run.sh"]
index (2).html ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>qBittorrent Magnet Link Manager</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ min-height: 100vh;
18
+ padding: 20px;
19
+ }
20
+
21
+ .container {
22
+ max-width: 1200px;
23
+ margin: 0 auto;
24
+ }
25
+
26
+ header {
27
+ text-align: center;
28
+ color: white;
29
+ margin-bottom: 30px;
30
+ }
31
+
32
+ header h1 {
33
+ font-size: 2.5em;
34
+ margin-bottom: 10px;
35
+ }
36
+
37
+ header p {
38
+ font-size: 1.1em;
39
+ opacity: 0.9;
40
+ }
41
+
42
+ .status-bar {
43
+ background: white;
44
+ padding: 15px 20px;
45
+ border-radius: 8px;
46
+ margin-bottom: 20px;
47
+ display: flex;
48
+ justify-content: space-between;
49
+ align-items: center;
50
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
51
+ }
52
+
53
+ .status-bar .status {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 10px;
57
+ }
58
+
59
+ .status-indicator {
60
+ width: 12px;
61
+ height: 12px;
62
+ border-radius: 50%;
63
+ background-color: #4caf50;
64
+ animation: pulse 2s infinite;
65
+ }
66
+
67
+ @keyframes pulse {
68
+ 0%, 100% {
69
+ opacity: 1;
70
+ }
71
+ 50% {
72
+ opacity: 0.5;
73
+ }
74
+ }
75
+
76
+ .input-section {
77
+ background: white;
78
+ padding: 30px;
79
+ border-radius: 8px;
80
+ margin-bottom: 30px;
81
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
82
+ }
83
+
84
+ .input-section h2 {
85
+ margin-bottom: 20px;
86
+ color: #333;
87
+ }
88
+
89
+ .input-group {
90
+ display: flex;
91
+ gap: 10px;
92
+ }
93
+
94
+ input[type="text"] {
95
+ flex: 1;
96
+ padding: 12px 15px;
97
+ border: 2px solid #e0e0e0;
98
+ border-radius: 5px;
99
+ font-size: 1em;
100
+ transition: border-color 0.3s;
101
+ }
102
+
103
+ input[type="text"]:focus {
104
+ outline: none;
105
+ border-color: #667eea;
106
+ }
107
+
108
+ button {
109
+ padding: 12px 30px;
110
+ background-color: #667eea;
111
+ color: white;
112
+ border: none;
113
+ border-radius: 5px;
114
+ font-size: 1em;
115
+ cursor: pointer;
116
+ transition: background-color 0.3s;
117
+ }
118
+
119
+ button:hover {
120
+ background-color: #5568d3;
121
+ }
122
+
123
+ button:active {
124
+ transform: scale(0.98);
125
+ }
126
+
127
+ .torrents-section {
128
+ background: white;
129
+ padding: 30px;
130
+ border-radius: 8px;
131
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
132
+ }
133
+
134
+ .torrents-section h2 {
135
+ margin-bottom: 20px;
136
+ color: #333;
137
+ }
138
+
139
+ .torrent-list {
140
+ display: flex;
141
+ flex-direction: column;
142
+ gap: 15px;
143
+ }
144
+
145
+ .torrent-item {
146
+ background: #f9f9f9;
147
+ padding: 20px;
148
+ border-radius: 8px;
149
+ border-left: 4px solid #667eea;
150
+ transition: transform 0.2s, box-shadow 0.2s;
151
+ }
152
+
153
+ .torrent-item:hover {
154
+ transform: translateY(-2px);
155
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
156
+ }
157
+
158
+ .torrent-header {
159
+ display: flex;
160
+ justify-content: space-between;
161
+ align-items: start;
162
+ margin-bottom: 15px;
163
+ }
164
+
165
+ .torrent-name {
166
+ font-weight: 600;
167
+ color: #333;
168
+ word-break: break-word;
169
+ flex: 1;
170
+ }
171
+
172
+ .torrent-state {
173
+ padding: 5px 10px;
174
+ border-radius: 20px;
175
+ font-size: 0.85em;
176
+ font-weight: 500;
177
+ text-transform: uppercase;
178
+ margin-left: 10px;
179
+ }
180
+
181
+ .state-downloading {
182
+ background-color: #e3f2fd;
183
+ color: #1976d2;
184
+ }
185
+
186
+ .state-completed {
187
+ background-color: #e8f5e9;
188
+ color: #388e3c;
189
+ }
190
+
191
+ .state-paused {
192
+ background-color: #fff3e0;
193
+ color: #f57c00;
194
+ }
195
+
196
+ .state-error {
197
+ background-color: #ffebee;
198
+ color: #c62828;
199
+ }
200
+
201
+ .torrent-progress {
202
+ margin-bottom: 10px;
203
+ }
204
+
205
+ .progress-bar {
206
+ width: 100%;
207
+ height: 8px;
208
+ background-color: #e0e0e0;
209
+ border-radius: 4px;
210
+ overflow: hidden;
211
+ margin-bottom: 5px;
212
+ }
213
+
214
+ .progress-fill {
215
+ height: 100%;
216
+ background: linear-gradient(90deg, #667eea, #764ba2);
217
+ transition: width 0.3s;
218
+ }
219
+
220
+ .progress-text {
221
+ font-size: 0.9em;
222
+ color: #666;
223
+ display: flex;
224
+ justify-content: space-between;
225
+ }
226
+
227
+ .torrent-stats {
228
+ display: grid;
229
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
230
+ gap: 15px;
231
+ margin-bottom: 15px;
232
+ font-size: 0.9em;
233
+ }
234
+
235
+ .stat {
236
+ display: flex;
237
+ flex-direction: column;
238
+ }
239
+
240
+ .stat-label {
241
+ color: #999;
242
+ font-size: 0.85em;
243
+ text-transform: uppercase;
244
+ margin-bottom: 3px;
245
+ }
246
+
247
+ .stat-value {
248
+ color: #333;
249
+ font-weight: 600;
250
+ }
251
+
252
+ .torrent-actions {
253
+ display: flex;
254
+ gap: 10px;
255
+ }
256
+
257
+ .action-btn {
258
+ padding: 8px 15px;
259
+ font-size: 0.9em;
260
+ border: none;
261
+ border-radius: 5px;
262
+ cursor: pointer;
263
+ transition: background-color 0.3s;
264
+ }
265
+
266
+ .pause-btn {
267
+ background-color: #ff9800;
268
+ color: white;
269
+ }
270
+
271
+ .pause-btn:hover {
272
+ background-color: #f57c00;
273
+ }
274
+
275
+ .resume-btn {
276
+ background-color: #4caf50;
277
+ color: white;
278
+ }
279
+
280
+ .resume-btn:hover {
281
+ background-color: #45a049;
282
+ }
283
+
284
+ .delete-btn {
285
+ background-color: #f44336;
286
+ color: white;
287
+ }
288
+
289
+ .delete-btn:hover {
290
+ background-color: #da190b;
291
+ }
292
+
293
+ .empty-state {
294
+ text-align: center;
295
+ padding: 40px 20px;
296
+ color: #999;
297
+ }
298
+
299
+ .empty-state-icon {
300
+ font-size: 3em;
301
+ margin-bottom: 10px;
302
+ }
303
+
304
+ .loading {
305
+ text-align: center;
306
+ padding: 20px;
307
+ color: #667eea;
308
+ }
309
+
310
+ .spinner {
311
+ border: 4px solid #f3f3f3;
312
+ border-top: 4px solid #667eea;
313
+ border-radius: 50%;
314
+ width: 30px;
315
+ height: 30px;
316
+ animation: spin 1s linear infinite;
317
+ margin: 0 auto 10px;
318
+ }
319
+
320
+ @keyframes spin {
321
+ 0% { transform: rotate(0deg); }
322
+ 100% { transform: rotate(360deg); }
323
+ }
324
+
325
+ .alert {
326
+ padding: 15px 20px;
327
+ border-radius: 5px;
328
+ margin-bottom: 20px;
329
+ display: none;
330
+ }
331
+
332
+ .alert.show {
333
+ display: block;
334
+ }
335
+
336
+ .alert-success {
337
+ background-color: #e8f5e9;
338
+ color: #2e7d32;
339
+ border-left: 4px solid #4caf50;
340
+ }
341
+
342
+ .alert-error {
343
+ background-color: #ffebee;
344
+ color: #c62828;
345
+ border-left: 4px solid #f44336;
346
+ }
347
+
348
+ @media (max-width: 768px) {
349
+ header h1 {
350
+ font-size: 1.8em;
351
+ }
352
+
353
+ .input-group {
354
+ flex-direction: column;
355
+ }
356
+
357
+ .torrent-stats {
358
+ grid-template-columns: repeat(2, 1fr);
359
+ }
360
+
361
+ .torrent-actions {
362
+ flex-wrap: wrap;
363
+ }
364
+ }
365
+ </style>
366
+ </head>
367
+ <body>
368
+ <div class="container">
369
+ <header>
370
+ <h1>🚀 qBittorrent Magnet Link Manager</h1>
371
+ <p>Add magnet links and track your downloads in real-time</p>
372
+ </header>
373
+
374
+ <div class="status-bar">
375
+ <div class="status">
376
+ <div class="status-indicator"></div>
377
+ <span id="status-text">Connecting to qBittorrent...</span>
378
+ </div>
379
+ <div id="refresh-info" style="font-size: 0.9em; color: #666;"></div>
380
+ </div>
381
+
382
+ <div id="alert" class="alert"></div>
383
+
384
+ <div class="input-section">
385
+ <h2>Add Magnet Link</h2>
386
+ <div class="input-group">
387
+ <input
388
+ type="text"
389
+ id="magnet-input"
390
+ placeholder="Paste your magnet link here (e.g., magnet:?xt=urn:btih:...)"
391
+ autocomplete="off"
392
+ >
393
+ <button onclick="addTorrent()">Add Torrent</button>
394
+ </div>
395
+ </div>
396
+
397
+ <div class="torrents-section">
398
+ <h2>Active Downloads</h2>
399
+ <div id="torrents-container" class="loading">
400
+ <div class="spinner"></div>
401
+ <p>Loading torrents...</p>
402
+ </div>
403
+ </div>
404
+ </div>
405
+
406
+ <script>
407
+ const API_BASE = '';
408
+ const REFRESH_INTERVAL = 2000; // Refresh every 2 seconds
409
+ let refreshTimer = null;
410
+
411
+ // Format bytes to human-readable format
412
+ function formatBytes(bytes) {
413
+ if (bytes === 0) return '0 B';
414
+ const k = 1024;
415
+ const sizes = ['B', 'KB', 'MB', 'GB'];
416
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
417
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
418
+ }
419
+
420
+ // Format seconds to human-readable time
421
+ function formatTime(seconds) {
422
+ if (seconds < 0 || seconds === Infinity) return '∞';
423
+ const hours = Math.floor(seconds / 3600);
424
+ const minutes = Math.floor((seconds % 3600) / 60);
425
+ const secs = Math.floor(seconds % 60);
426
+
427
+ if (hours > 0) return `${hours}h ${minutes}m`;
428
+ if (minutes > 0) return `${minutes}m ${secs}s`;
429
+ return `${secs}s`;
430
+ }
431
+
432
+ // Show alert message
433
+ function showAlert(message, type = 'success') {
434
+ const alertEl = document.getElementById('alert');
435
+ alertEl.textContent = message;
436
+ alertEl.className = `alert show alert-${type}`;
437
+ setTimeout(() => {
438
+ alertEl.classList.remove('show');
439
+ }, 5000);
440
+ }
441
+
442
+ // Check health and update status
443
+ async function checkHealth() {
444
+ try {
445
+ const response = await fetch(`${API_BASE}/health`);
446
+ if (response.ok) {
447
+ document.getElementById('status-text').textContent = '✓ Connected to qBittorrent';
448
+ } else {
449
+ document.getElementById('status-text').textContent = '✗ Connection error';
450
+ }
451
+ } catch (error) {
452
+ document.getElementById('status-text').textContent = '✗ Connection error';
453
+ }
454
+ }
455
+
456
+ // Fetch and display torrents
457
+ async function fetchTorrents() {
458
+ try {
459
+ const response = await fetch(`${API_BASE}/api/torrents`);
460
+ if (!response.ok) throw new Error('Failed to fetch torrents');
461
+
462
+ const data = await response.json();
463
+ displayTorrents(data.torrents);
464
+ } catch (error) {
465
+ console.error('Error fetching torrents:', error);
466
+ document.getElementById('torrents-container').innerHTML = `
467
+ <div class="empty-state">
468
+ <div class="empty-state-icon">⚠️</div>
469
+ <p>Error loading torrents. Please check your connection.</p>
470
+ </div>
471
+ `;
472
+ }
473
+ }
474
+
475
+ // Display torrents in the UI
476
+ function displayTorrents(torrents) {
477
+ const container = document.getElementById('torrents-container');
478
+
479
+ if (torrents.length === 0) {
480
+ container.innerHTML = `
481
+ <div class="empty-state">
482
+ <div class="empty-state-icon">📭</div>
483
+ <p>No torrents yet. Add a magnet link to get started!</p>
484
+ </div>
485
+ `;
486
+ return;
487
+ }
488
+
489
+ container.innerHTML = '<div class="torrent-list">' + torrents.map(torrent => {
490
+ const stateClass = `state-${torrent.state.toLowerCase()}`;
491
+ const isPaused = torrent.state.toLowerCase() === 'paused';
492
+ const isCompleted = torrent.state.toLowerCase() === 'uploading' || torrent.progress === 100;
493
+
494
+ return `
495
+ <div class="torrent-item">
496
+ <div class="torrent-header">
497
+ <div class="torrent-name">${escapeHtml(torrent.name)}</div>
498
+ <div class="torrent-state ${stateClass}">${torrent.state}</div>
499
+ </div>
500
+
501
+ <div class="torrent-progress">
502
+ <div class="progress-bar">
503
+ <div class="progress-fill" style="width: ${torrent.progress}%"></div>
504
+ </div>
505
+ <div class="progress-text">
506
+ <span>${torrent.progress.toFixed(1)}%</span>
507
+ <span>${formatBytes(torrent.downloaded)} / ${formatBytes(torrent.total_size)}</span>
508
+ </div>
509
+ </div>
510
+
511
+ <div class="torrent-stats">
512
+ <div class="stat">
513
+ <div class="stat-label">Download Speed</div>
514
+ <div class="stat-value">${formatBytes(torrent.download_speed)}/s</div>
515
+ </div>
516
+ <div class="stat">
517
+ <div class="stat-label">Upload Speed</div>
518
+ <div class="stat-value">${formatBytes(torrent.upload_speed)}/s</div>
519
+ </div>
520
+ <div class="stat">
521
+ <div class="stat-label">ETA</div>
522
+ <div class="stat-value">${formatTime(torrent.eta)}</div>
523
+ </div>
524
+ <div class="stat">
525
+ <div class="stat-label">Seeds / Peers</div>
526
+ <div class="stat-value">${torrent.num_seeds} / ${torrent.num_leechs}</div>
527
+ </div>
528
+ </div>
529
+
530
+ <div class="torrent-actions">
531
+ ${isPaused ?
532
+ `<button class="action-btn resume-btn" onclick="resumeTorrent('${torrent.hash}')">Resume</button>` :
533
+ `<button class="action-btn pause-btn" onclick="pauseTorrent('${torrent.hash}')">Pause</button>`
534
+ }
535
+ <button class="action-btn delete-btn" onclick="deleteTorrent('${torrent.hash}')">Delete</button>
536
+ </div>
537
+ </div>
538
+ `;
539
+ }).join('') + '</div>';
540
+ }
541
+
542
+ // Escape HTML to prevent XSS
543
+ function escapeHtml(text) {
544
+ const map = {
545
+ '&': '&amp;',
546
+ '<': '&lt;',
547
+ '>': '&gt;',
548
+ '"': '&quot;',
549
+ "'": '&#039;'
550
+ };
551
+ return text.replace(/[&<>"']/g, m => map[m]);
552
+ }
553
+
554
+ // Add a new torrent
555
+ async function addTorrent() {
556
+ const input = document.getElementById('magnet-input');
557
+ const magnetLink = input.value.trim();
558
+
559
+ if (!magnetLink) {
560
+ showAlert('Please enter a magnet link', 'error');
561
+ return;
562
+ }
563
+
564
+ try {
565
+ const response = await fetch(`${API_BASE}/api/add_torrent`, {
566
+ method: 'POST',
567
+ headers: {
568
+ 'Content-Type': 'application/json',
569
+ },
570
+ body: JSON.stringify({ magnet_link: magnetLink })
571
+ });
572
+
573
+ if (!response.ok) {
574
+ const error = await response.json();
575
+ throw new Error(error.detail || 'Failed to add torrent');
576
+ }
577
+
578
+ showAlert('Torrent added successfully!', 'success');
579
+ input.value = '';
580
+ await fetchTorrents();
581
+ } catch (error) {
582
+ showAlert(`Error: ${error.message}`, 'error');
583
+ }
584
+ }
585
+
586
+ // Pause a torrent
587
+ async function pauseTorrent(hash) {
588
+ try {
589
+ const response = await fetch(`${API_BASE}/api/torrents/${hash}/pause`, {
590
+ method: 'POST'
591
+ });
592
+
593
+ if (!response.ok) throw new Error('Failed to pause torrent');
594
+
595
+ showAlert('Torrent paused', 'success');
596
+ await fetchTorrents();
597
+ } catch (error) {
598
+ showAlert(`Error: ${error.message}`, 'error');
599
+ }
600
+ }
601
+
602
+ // Resume a torrent
603
+ async function resumeTorrent(hash) {
604
+ try {
605
+ const response = await fetch(`${API_BASE}/api/torrents/${hash}/resume`, {
606
+ method: 'POST'
607
+ });
608
+
609
+ if (!response.ok) throw new Error('Failed to resume torrent');
610
+
611
+ showAlert('Torrent resumed', 'success');
612
+ await fetchTorrents();
613
+ } catch (error) {
614
+ showAlert(`Error: ${error.message}`, 'error');
615
+ }
616
+ }
617
+
618
+ // Delete a torrent
619
+ async function deleteTorrent(hash) {
620
+ if (!confirm('Are you sure you want to delete this torrent?')) return;
621
+
622
+ try {
623
+ const response = await fetch(`${API_BASE}/api/torrents/${hash}`, {
624
+ method: 'DELETE'
625
+ });
626
+
627
+ if (!response.ok) throw new Error('Failed to delete torrent');
628
+
629
+ showAlert('Torrent deleted', 'success');
630
+ await fetchTorrents();
631
+ } catch (error) {
632
+ showAlert(`Error: ${error.message}`, 'error');
633
+ }
634
+ }
635
+
636
+ // Allow Enter key to add torrent
637
+ document.getElementById('magnet-input').addEventListener('keypress', function(event) {
638
+ if (event.key === 'Enter') {
639
+ addTorrent();
640
+ }
641
+ });
642
+
643
+ // Initialize
644
+ window.addEventListener('load', async () => {
645
+ await checkHealth();
646
+ await fetchTorrents();
647
+
648
+ // Set up auto-refresh
649
+ refreshTimer = setInterval(async () => {
650
+ await fetchTorrents();
651
+ }, REFRESH_INTERVAL);
652
+ });
653
+
654
+ // Clean up on page unload
655
+ window.addEventListener('beforeunload', () => {
656
+ if (refreshTimer) clearInterval(refreshTimer);
657
+ });
658
+ </script>
659
+ </body>
660
+ </html>
main.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import FastAPI, HTTPException
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.responses import FileResponse
5
+ from pydantic import BaseModel
6
+ from qbittorrent import Client
7
+ from typing import List, Optional
8
+
9
+ # --- Configuration ---
10
+ # The qBittorrent-nox daemon is started on port 8080 inside the container.
11
+ # The default credentials for qbittorrent-nox are admin/adminadmin.
12
+ # In a real-world scenario, these should be securely managed.
13
+ QB_HOST = os.getenv("QB_HOST", "localhost")
14
+ QB_PORT = os.getenv("QB_PORT", "8080")
15
+ QB_USER = os.getenv("QB_USER", "admin")
16
+ QB_PASS = os.getenv("QB_PASS", "adminadmin")
17
+
18
+ # --- FastAPI App Initialization ---
19
+ app = FastAPI(
20
+ title="qBittorrent Magnet Link API",
21
+ description="A simple FastAPI service to add magnet links to a running qBittorrent instance with download tracking.",
22
+ version="2.0.0"
23
+ )
24
+
25
+ # Mount static files (frontend)
26
+ app.mount("/static", StaticFiles(directory="app/static"), name="static")
27
+
28
+ # --- Pydantic Models ---
29
+ class MagnetLink(BaseModel):
30
+ magnet_link: str
31
+
32
+ class StatusResponse(BaseModel):
33
+ status: str
34
+ message: str
35
+
36
+ class TorrentInfo(BaseModel):
37
+ name: str
38
+ hash: str
39
+ state: str
40
+ progress: float
41
+ downloaded: int
42
+ total_size: int
43
+ upload_speed: int
44
+ download_speed: int
45
+ eta: int
46
+ num_seeds: int
47
+ num_leechs: int
48
+
49
+ class TorrentListResponse(BaseModel):
50
+ torrents: List[TorrentInfo]
51
+ total_count: int
52
+
53
+ # --- Utility Function for qBittorrent Client ---
54
+ def get_qb_client():
55
+ """Initializes and logs into the qBittorrent client."""
56
+ try:
57
+ qb = Client(f'http://{QB_HOST}:{QB_PORT}')
58
+ qb.login(QB_USER, QB_PASS)
59
+ return qb
60
+ except Exception as e:
61
+ raise HTTPException(status_code=503, detail=f"Could not connect or log in to qBittorrent: {e}")
62
+
63
+ # --- Endpoints ---
64
+
65
+ @app.get("/", response_class=FileResponse)
66
+ async def serve_frontend():
67
+ """Serves the main HTML frontend."""
68
+ return FileResponse("app/static/index.html")
69
+
70
+ @app.get("/health", response_model=StatusResponse)
71
+ async def health_check():
72
+ """Checks the health of the FastAPI service and qBittorrent connection."""
73
+ try:
74
+ qb = get_qb_client()
75
+ # A simple call to verify connection and authentication
76
+ version = qb.app.version
77
+ return StatusResponse(status="ok", message=f"FastAPI is running and connected to qBittorrent v{version}")
78
+ except HTTPException as e:
79
+ raise e
80
+ except Exception as e:
81
+ raise HTTPException(status_code=500, detail=f"Internal server error during health check: {e}")
82
+
83
+ @app.post("/api/add_torrent", response_model=StatusResponse)
84
+ async def add_torrent(link: MagnetLink):
85
+ """Adds a magnet link to the qBittorrent download queue."""
86
+ qb = get_qb_client()
87
+
88
+ try:
89
+ # The add_torrent method handles both magnet links and .torrent files
90
+ qb.torrents_add(urls=link.magnet_link)
91
+ return StatusResponse(
92
+ status="success",
93
+ message=f"Successfully added magnet link to qBittorrent"
94
+ )
95
+ except Exception as e:
96
+ # Log the error and return a user-friendly message
97
+ print(f"Error adding torrent: {e}")
98
+ raise HTTPException(status_code=500, detail=f"Failed to add torrent: {e}")
99
+
100
+ @app.get("/api/torrents", response_model=TorrentListResponse)
101
+ async def get_torrents():
102
+ """Fetches the list of all torrents with their current status."""
103
+ qb = get_qb_client()
104
+
105
+ try:
106
+ torrents = qb.torrents()
107
+
108
+ torrent_list = []
109
+ for torrent in torrents:
110
+ torrent_info = TorrentInfo(
111
+ name=torrent.get('name', 'Unknown'),
112
+ hash=torrent.get('hash', ''),
113
+ state=torrent.get('state', 'unknown'),
114
+ progress=torrent.get('progress', 0) * 100, # Convert to percentage
115
+ downloaded=torrent.get('downloaded', 0),
116
+ total_size=torrent.get('total_size', 0),
117
+ upload_speed=torrent.get('upspeed', 0),
118
+ download_speed=torrent.get('dlspeed', 0),
119
+ eta=torrent.get('eta', 0),
120
+ num_seeds=torrent.get('num_seeds', 0),
121
+ num_leechs=torrent.get('num_leechs', 0),
122
+ )
123
+ torrent_list.append(torrent_info)
124
+
125
+ return TorrentListResponse(torrents=torrent_list, total_count=len(torrent_list))
126
+ except Exception as e:
127
+ print(f"Error fetching torrents: {e}")
128
+ raise HTTPException(status_code=500, detail=f"Failed to fetch torrents: {e}")
129
+
130
+ @app.delete("/api/torrents/{torrent_hash}")
131
+ async def delete_torrent(torrent_hash: str, delete_files: bool = False):
132
+ """Deletes a torrent from the qBittorrent instance."""
133
+ qb = get_qb_client()
134
+
135
+ try:
136
+ qb.torrents_delete(torrent_hashes=torrent_hash, delete_files=delete_files)
137
+ return StatusResponse(
138
+ status="success",
139
+ message=f"Successfully deleted torrent"
140
+ )
141
+ except Exception as e:
142
+ print(f"Error deleting torrent: {e}")
143
+ raise HTTPException(status_code=500, detail=f"Failed to delete torrent: {e}")
144
+
145
+ @app.post("/api/torrents/{torrent_hash}/pause")
146
+ async def pause_torrent(torrent_hash: str):
147
+ """Pauses a torrent."""
148
+ qb = get_qb_client()
149
+
150
+ try:
151
+ qb.torrents_pause(torrent_hashes=torrent_hash)
152
+ return StatusResponse(
153
+ status="success",
154
+ message=f"Successfully paused torrent"
155
+ )
156
+ except Exception as e:
157
+ print(f"Error pausing torrent: {e}")
158
+ raise HTTPException(status_code=500, detail=f"Failed to pause torrent: {e}")
159
+
160
+ @app.post("/api/torrents/{torrent_hash}/resume")
161
+ async def resume_torrent(torrent_hash: str):
162
+ """Resumes a torrent."""
163
+ qb = get_qb_client()
164
+
165
+ try:
166
+ qb.torrents_resume(torrent_hashes=torrent_hash)
167
+ return StatusResponse(
168
+ status="success",
169
+ message=f"Successfully resumed torrent"
170
+ )
171
+ except Exception as e:
172
+ print(f"Error resuming torrent: {e}")
173
+ raise HTTPException(status_code=500, detail=f"Failed to resume torrent: {e}")
requirements (1).txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ python-qbittorrent
run.sh ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Start the qBittorrent daemon in the background
4
+ qbittorrent-nox --webui-port=$QB_WEBUI_PORT &
5
+
6
+ # Start the FastAPI application
7
+ uvicorn app.main:app --host 0.0.0.0 --port $APP_PORT