Spaces:
Sleeping
Sleeping
Upload 19 files
Browse files- Dockerfile +23 -0
- README.md +36 -8
- app.py +5 -0
- requirements.txt +6 -0
- static/index.html +379 -0
- torrent2drive/__init__.py +1 -0
- torrent2drive/app.py +52 -0
- torrent2drive/config.py +34 -0
- torrent2drive/constants.py +14 -0
- torrent2drive/routes/__init__.py +15 -0
- torrent2drive/routes/control.py +87 -0
- torrent2drive/routes/files.py +99 -0
- torrent2drive/routes/pages.py +17 -0
- torrent2drive/routes/torrent.py +115 -0
- torrent2drive/services/__init__.py +1 -0
- torrent2drive/services/pipeline.py +83 -0
- torrent2drive/session.py +128 -0
- torrent2drive/torrent_engine.py +113 -0
- torrent2drive/utils.py +47 -0
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# System deps for libtorrent
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
python3-libtorrent \
|
| 8 |
+
libboost-python-dev \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Create writable dirs
|
| 17 |
+
RUN mkdir -p /app/_users /app/static
|
| 18 |
+
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
|
| 21 |
+
ENV PORT=7860
|
| 22 |
+
|
| 23 |
+
CMD ["python", "-m", "uvicorn", "torrent2drive.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,13 +1,41 @@
|
|
| 1 |
---
|
| 2 |
title: Torrent2Space
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
sdk_version: 6.14.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Torrent2Space
|
| 3 |
+
emoji: 🌊
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Torrent2Space 🌊
|
| 11 |
+
|
| 12 |
+
A **Seedr-style** torrent client that runs entirely on HuggingFace Space disk — no Google Drive, no external storage.
|
| 13 |
+
|
| 14 |
+
## How it works
|
| 15 |
+
|
| 16 |
+
1. Paste a **magnet link** or upload a **.torrent file**
|
| 17 |
+
2. Select which files you want
|
| 18 |
+
3. Hit **Start Download** — the torrent downloads to the Space's local disk
|
| 19 |
+
4. When done, **download** the files directly to your computer from "My Files"
|
| 20 |
+
|
| 21 |
+
## API Endpoints
|
| 22 |
+
|
| 23 |
+
| Method | Endpoint | Description |
|
| 24 |
+
|--------|----------|-------------|
|
| 25 |
+
| `POST` | `/api/load_magnet` | Load a magnet link |
|
| 26 |
+
| `POST` | `/api/load_torrent` | Load a .torrent file (base64) |
|
| 27 |
+
| `POST` | `/api/start` | Start downloading selected files |
|
| 28 |
+
| `POST` | `/api/pause` | Pause the torrent |
|
| 29 |
+
| `POST` | `/api/resume` | Resume the torrent |
|
| 30 |
+
| `POST` | `/api/stop` | Stop and clear |
|
| 31 |
+
| `GET` | `/api/status` | Get download status & progress |
|
| 32 |
+
| `GET` | `/api/files` | List all downloaded files |
|
| 33 |
+
| `GET` | `/api/download/{path}` | Download a file |
|
| 34 |
+
| `DELETE` | `/api/files/{path}` | Delete a specific file |
|
| 35 |
+
| `DELETE` | `/api/files` | Delete all files |
|
| 36 |
+
|
| 37 |
+
## Notes
|
| 38 |
+
|
| 39 |
+
- Files are stored on the Space disk for the duration of your session
|
| 40 |
+
- Sessions are cleaned up after 6 hours of inactivity
|
| 41 |
+
- No Google account needed
|
app.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry point."""
|
| 2 |
+
from torrent2drive.app import app, main
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.29.0
|
| 3 |
+
python-multipart>=0.0.9
|
| 4 |
+
starlette>=0.37.0
|
| 5 |
+
itsdangerous>=2.1.2
|
| 6 |
+
libtorrent>=2.0.0
|
static/index.html
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>Torrent2Space</title>
|
| 7 |
+
<style>
|
| 8 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 9 |
+
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}
|
| 10 |
+
header{background:#1a1f2e;border-bottom:1px solid #2d3748;padding:16px 24px;display:flex;align-items:center;gap:12px}
|
| 11 |
+
header h1{font-size:1.3rem;font-weight:700;color:#7c3aed}
|
| 12 |
+
header span{font-size:.8rem;color:#64748b;background:#1e293b;padding:2px 8px;border-radius:999px}
|
| 13 |
+
.container{max-width:900px;margin:32px auto;padding:0 16px}
|
| 14 |
+
|
| 15 |
+
/* Add torrent card */
|
| 16 |
+
.card{background:#1a1f2e;border:1px solid #2d3748;border-radius:12px;padding:24px;margin-bottom:20px}
|
| 17 |
+
.card h2{font-size:.95rem;font-weight:600;color:#94a3b8;margin-bottom:16px;text-transform:uppercase;letter-spacing:.05em}
|
| 18 |
+
|
| 19 |
+
.input-row{display:flex;gap:8px}
|
| 20 |
+
.input-row input{flex:1;background:#0f1117;border:1px solid #374151;border-radius:8px;padding:10px 14px;color:#e2e8f0;font-size:.95rem;outline:none;transition:border .2s}
|
| 21 |
+
.input-row input:focus{border-color:#7c3aed}
|
| 22 |
+
.btn{padding:10px 20px;border:none;border-radius:8px;font-size:.9rem;font-weight:600;cursor:pointer;transition:all .2s}
|
| 23 |
+
.btn-primary{background:#7c3aed;color:#fff}
|
| 24 |
+
.btn-primary:hover{background:#6d28d9}
|
| 25 |
+
.btn-primary:disabled{background:#374151;color:#64748b;cursor:not-allowed}
|
| 26 |
+
.btn-danger{background:#dc2626;color:#fff}
|
| 27 |
+
.btn-danger:hover{background:#b91c1c}
|
| 28 |
+
.btn-sm{padding:6px 12px;font-size:.8rem}
|
| 29 |
+
.btn-ghost{background:transparent;border:1px solid #374151;color:#94a3b8}
|
| 30 |
+
.btn-ghost:hover{border-color:#7c3aed;color:#7c3aed}
|
| 31 |
+
|
| 32 |
+
.or-divider{text-align:center;color:#4b5563;font-size:.85rem;margin:12px 0;position:relative}
|
| 33 |
+
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:45%;height:1px;background:#2d3748}
|
| 34 |
+
.or-divider::before{left:0}.or-divider::after{right:0}
|
| 35 |
+
|
| 36 |
+
.file-upload-area{border:2px dashed #374151;border-radius:8px;padding:20px;text-align:center;cursor:pointer;transition:all .2s;color:#64748b}
|
| 37 |
+
.file-upload-area:hover,.file-upload-area.drag{border-color:#7c3aed;color:#a78bfa}
|
| 38 |
+
.file-upload-area input{display:none}
|
| 39 |
+
|
| 40 |
+
/* File selector */
|
| 41 |
+
#file-list-section{display:none}
|
| 42 |
+
.torrent-info{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:gap}
|
| 43 |
+
.torrent-name{font-weight:600;color:#e2e8f0;font-size:1rem}
|
| 44 |
+
.torrent-meta{font-size:.8rem;color:#64748b;margin-top:2px}
|
| 45 |
+
.file-table{width:100%;border-collapse:collapse}
|
| 46 |
+
.file-table th{text-align:left;font-size:.75rem;color:#64748b;font-weight:600;padding:8px 12px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #2d3748}
|
| 47 |
+
.file-table td{padding:10px 12px;border-bottom:1px solid #1e293b;font-size:.875rem}
|
| 48 |
+
.file-table tr:last-child td{border-bottom:none}
|
| 49 |
+
.file-table tr:hover td{background:#1e293b}
|
| 50 |
+
.file-check{width:16px;height:16px;accent-color:#7c3aed;cursor:pointer}
|
| 51 |
+
.file-name{color:#cbd5e1}
|
| 52 |
+
.file-size{color:#64748b;text-align:right;white-space:nowrap}
|
| 53 |
+
.select-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
|
| 54 |
+
.select-row label{font-size:.8rem;color:#64748b}
|
| 55 |
+
|
| 56 |
+
/* Progress */
|
| 57 |
+
#progress-section{display:none}
|
| 58 |
+
.progress-wrap{background:#0f1117;border-radius:999px;height:8px;overflow:hidden;margin:12px 0}
|
| 59 |
+
.progress-bar{height:100%;background:linear-gradient(90deg,#7c3aed,#a855f7);border-radius:999px;transition:width .5s}
|
| 60 |
+
.progress-stats{display:flex;justify-content:space-between;font-size:.8rem;color:#64748b}
|
| 61 |
+
.status-badge{display:inline-flex;align-items:center;gap:6px;font-size:.8rem;font-weight:600;padding:4px 10px;border-radius:999px}
|
| 62 |
+
.badge-downloading{background:#1e3a5f;color:#60a5fa}
|
| 63 |
+
.badge-done{background:#14532d;color:#4ade80}
|
| 64 |
+
.badge-idle{background:#1e293b;color:#64748b}
|
| 65 |
+
.badge-processing{background:#3b1f5e;color:#c084fc}
|
| 66 |
+
.dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 1.2s infinite}
|
| 67 |
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
| 68 |
+
|
| 69 |
+
.ctrl-row{display:flex;gap:8px;margin-top:12px}
|
| 70 |
+
|
| 71 |
+
/* My Files */
|
| 72 |
+
#my-files-section .empty-state{text-align:center;padding:40px;color:#4b5563}
|
| 73 |
+
.dl-table{width:100%;border-collapse:collapse}
|
| 74 |
+
.dl-table th{text-align:left;font-size:.75rem;color:#64748b;padding:8px 12px;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #2d3748}
|
| 75 |
+
.dl-table td{padding:10px 12px;border-bottom:1px solid #1e293b;font-size:.875rem;vertical-align:middle}
|
| 76 |
+
.dl-table tr:last-child td{border-bottom:none}
|
| 77 |
+
.dl-table tr:hover td{background:#1e293b}
|
| 78 |
+
.dl-filename{color:#cbd5e1;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 79 |
+
.dl-size{color:#64748b;text-align:right}
|
| 80 |
+
.dl-actions{display:flex;gap:6px;justify-content:flex-end}
|
| 81 |
+
.refresh-btn{background:none;border:none;color:#64748b;cursor:pointer;font-size:1rem;padding:4px;transition:color .2s}
|
| 82 |
+
.refresh-btn:hover{color:#7c3aed}
|
| 83 |
+
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
| 84 |
+
|
| 85 |
+
.alert{padding:12px 16px;border-radius:8px;font-size:.875rem;margin-bottom:16px}
|
| 86 |
+
.alert-error{background:#450a0a;border:1px solid #dc2626;color:#fca5a5}
|
| 87 |
+
.alert-success{background:#052e16;border:1px solid #16a34a;color:#86efac}
|
| 88 |
+
</style>
|
| 89 |
+
</head>
|
| 90 |
+
<body>
|
| 91 |
+
<header>
|
| 92 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#7c3aed" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
| 93 |
+
<h1>Torrent2Space</h1>
|
| 94 |
+
<span>Seedr-style on HF Space</span>
|
| 95 |
+
</header>
|
| 96 |
+
|
| 97 |
+
<div class="container">
|
| 98 |
+
|
| 99 |
+
<!-- Add Torrent -->
|
| 100 |
+
<div class="card">
|
| 101 |
+
<h2>Add Torrent</h2>
|
| 102 |
+
<div id="alert-box"></div>
|
| 103 |
+
<div class="input-row">
|
| 104 |
+
<input id="magnet-input" type="text" placeholder="Paste magnet link (magnet:?xt=urn:btih:...)" />
|
| 105 |
+
<button class="btn btn-primary" onclick="loadMagnet()">Load</button>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="or-divider">or</div>
|
| 108 |
+
<div class="file-upload-area" id="drop-zone" onclick="document.getElementById('torrent-file').click()"
|
| 109 |
+
ondragover="event.preventDefault();this.classList.add('drag')"
|
| 110 |
+
ondragleave="this.classList.remove('drag')"
|
| 111 |
+
ondrop="handleDrop(event)">
|
| 112 |
+
<input type="file" id="torrent-file" accept=".torrent" onchange="loadTorrentFile(this)"/>
|
| 113 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-bottom:8px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
| 114 |
+
<div>Drop .torrent file here or click to browse</div>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<!-- File selector -->
|
| 119 |
+
<div class="card" id="file-list-section">
|
| 120 |
+
<div class="torrent-info">
|
| 121 |
+
<div>
|
| 122 |
+
<div class="torrent-name" id="torrent-name"></div>
|
| 123 |
+
<div class="torrent-meta" id="torrent-meta"></div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div class="select-row">
|
| 127 |
+
<button class="btn btn-ghost btn-sm" onclick="selectAll(true)">Select All</button>
|
| 128 |
+
<button class="btn btn-ghost btn-sm" onclick="selectAll(false)">Deselect All</button>
|
| 129 |
+
<label id="selected-label"></label>
|
| 130 |
+
</div>
|
| 131 |
+
<table class="file-table">
|
| 132 |
+
<thead><tr><th style="width:30px"></th><th>Name</th><th style="text-align:right">Size</th></tr></thead>
|
| 133 |
+
<tbody id="file-tbody"></tbody>
|
| 134 |
+
</table>
|
| 135 |
+
<div style="margin-top:16px;display:flex;gap:8px">
|
| 136 |
+
<button class="btn btn-primary" id="start-btn" onclick="startDownload()">⬇ Start Download</button>
|
| 137 |
+
<button class="btn btn-ghost" onclick="resetUI()">Cancel</button>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<!-- Progress -->
|
| 142 |
+
<div class="card" id="progress-section">
|
| 143 |
+
<div class="section-header">
|
| 144 |
+
<h2>Download Progress</h2>
|
| 145 |
+
<span class="status-badge badge-idle" id="status-badge"><span class="dot"></span><span id="badge-text">Idle</span></span>
|
| 146 |
+
</div>
|
| 147 |
+
<div class="progress-wrap"><div class="progress-bar" id="progress-bar" style="width:0%"></div></div>
|
| 148 |
+
<div class="progress-stats">
|
| 149 |
+
<span id="stat-data">—</span>
|
| 150 |
+
<span id="stat-pct">0%</span>
|
| 151 |
+
<span id="stat-speed">—</span>
|
| 152 |
+
<span id="stat-eta">ETA: —</span>
|
| 153 |
+
<span id="stat-peers">—</span>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="ctrl-row">
|
| 156 |
+
<button class="btn btn-ghost btn-sm" onclick="apiCall('/api/pause')">⏸ Pause</button>
|
| 157 |
+
<button class="btn btn-ghost btn-sm" onclick="apiCall('/api/resume')">▶ Resume</button>
|
| 158 |
+
<button class="btn btn-danger btn-sm" onclick="stopAndReset()">✕ Stop</button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<!-- My Files -->
|
| 163 |
+
<div class="card" id="my-files-section">
|
| 164 |
+
<div class="section-header">
|
| 165 |
+
<h2>My Files</h2>
|
| 166 |
+
<button class="refresh-btn" onclick="loadFiles()" title="Refresh">↻</button>
|
| 167 |
+
</div>
|
| 168 |
+
<div id="files-container"><div class="empty-state">No files yet. Start a download above.</div></div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<script>
|
| 174 |
+
let pollTimer = null;
|
| 175 |
+
let torrentFiles = [];
|
| 176 |
+
|
| 177 |
+
// ─── Alert ──────────────────────────────────────────────────────────────────
|
| 178 |
+
function showAlert(msg, type='error'){
|
| 179 |
+
const box = document.getElementById('alert-box');
|
| 180 |
+
box.innerHTML = `<div class="alert alert-${type}">${msg}</div>`;
|
| 181 |
+
setTimeout(()=>box.innerHTML='', 5000);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// ─── Load Magnet ─────────────────────────────────────────────────────────────
|
| 185 |
+
async function loadMagnet(){
|
| 186 |
+
const magnet = document.getElementById('magnet-input').value.trim();
|
| 187 |
+
if(!magnet) return showAlert('Paste a magnet link first');
|
| 188 |
+
showAlert('Loading metadata… this may take a minute.','success');
|
| 189 |
+
const res = await fetch('/api/load_magnet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({magnet})});
|
| 190 |
+
const data = await res.json();
|
| 191 |
+
if(data.error) return showAlert(data.error);
|
| 192 |
+
renderFileSelector(data);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// ─── Load .torrent ────────────────────────────────────────────────────────────
|
| 196 |
+
async function loadTorrentFile(input){
|
| 197 |
+
const file = input.files[0];
|
| 198 |
+
if(!file) return;
|
| 199 |
+
const reader = new FileReader();
|
| 200 |
+
reader.onload = async e => {
|
| 201 |
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(e.target.result)));
|
| 202 |
+
const res = await fetch('/api/load_torrent',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({data:b64})});
|
| 203 |
+
const data = await res.json();
|
| 204 |
+
if(data.error) return showAlert(data.error);
|
| 205 |
+
renderFileSelector(data);
|
| 206 |
+
};
|
| 207 |
+
reader.readAsArrayBuffer(file);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function handleDrop(e){
|
| 211 |
+
e.preventDefault();
|
| 212 |
+
document.getElementById('drop-zone').classList.remove('drag');
|
| 213 |
+
const file = e.dataTransfer.files[0];
|
| 214 |
+
if(file && file.name.endsWith('.torrent')){
|
| 215 |
+
const fakeInput = {files:[file]};
|
| 216 |
+
loadTorrentFile(fakeInput);
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// ─── File Selector ────────────────────────────────────────────────────────────
|
| 221 |
+
function renderFileSelector(data){
|
| 222 |
+
torrentFiles = data.files;
|
| 223 |
+
document.getElementById('torrent-name').textContent = data.name;
|
| 224 |
+
document.getElementById('torrent-meta').textContent = `${data.num_files} file(s) · ${data.total_size}`;
|
| 225 |
+
const tbody = document.getElementById('file-tbody');
|
| 226 |
+
tbody.innerHTML = '';
|
| 227 |
+
data.files.forEach(f=>{
|
| 228 |
+
const tr = document.createElement('tr');
|
| 229 |
+
tr.innerHTML = `
|
| 230 |
+
<td><input type="checkbox" class="file-check" checked data-idx="${f.index}" onchange="updateSelectedLabel()"/></td>
|
| 231 |
+
<td class="file-name">${f.name}</td>
|
| 232 |
+
<td class="file-size">${f.size}</td>
|
| 233 |
+
`;
|
| 234 |
+
tbody.appendChild(tr);
|
| 235 |
+
});
|
| 236 |
+
updateSelectedLabel();
|
| 237 |
+
document.getElementById('file-list-section').style.display='block';
|
| 238 |
+
document.getElementById('alert-box').innerHTML='';
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function selectAll(v){
|
| 242 |
+
document.querySelectorAll('.file-check').forEach(c=>c.checked=v);
|
| 243 |
+
updateSelectedLabel();
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function updateSelectedLabel(){
|
| 247 |
+
const checks = document.querySelectorAll('.file-check');
|
| 248 |
+
const sel = [...checks].filter(c=>c.checked).length;
|
| 249 |
+
document.getElementById('selected-label').textContent = `${sel} of ${checks.length} selected`;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// ─── Start Download ───────────────────────────────────────────────────────────
|
| 253 |
+
async function startDownload(){
|
| 254 |
+
const selected = [...document.querySelectorAll('.file-check:checked')].map(c=>parseInt(c.dataset.idx));
|
| 255 |
+
if(!selected.length) return showAlert('Select at least one file');
|
| 256 |
+
const res = await fetch('/api/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({selected})});
|
| 257 |
+
const data = await res.json();
|
| 258 |
+
if(data.error) return showAlert(data.error);
|
| 259 |
+
document.getElementById('file-list-section').style.display='none';
|
| 260 |
+
document.getElementById('progress-section').style.display='block';
|
| 261 |
+
startPolling();
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// ─── Status Polling ───────────────────────────────────────────────────────────
|
| 265 |
+
function startPolling(){
|
| 266 |
+
if(pollTimer) clearInterval(pollTimer);
|
| 267 |
+
pollTimer = setInterval(pollStatus, 2000);
|
| 268 |
+
pollStatus();
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
async function pollStatus(){
|
| 272 |
+
try{
|
| 273 |
+
const res = await fetch('/api/status');
|
| 274 |
+
const d = await res.json();
|
| 275 |
+
updateProgress(d);
|
| 276 |
+
if(d.state==='done'){
|
| 277 |
+
clearInterval(pollTimer);
|
| 278 |
+
pollTimer=null;
|
| 279 |
+
loadFiles();
|
| 280 |
+
}
|
| 281 |
+
}catch(e){console.error(e)}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function updateProgress(d){
|
| 285 |
+
const pct = d.ovr_pct || d.cur_pct || 0;
|
| 286 |
+
document.getElementById('progress-bar').style.width = pct+'%';
|
| 287 |
+
document.getElementById('stat-pct').textContent = pct.toFixed(1)+'%';
|
| 288 |
+
document.getElementById('stat-data').textContent = d.data || '—';
|
| 289 |
+
document.getElementById('stat-speed').textContent = d.speed || '—';
|
| 290 |
+
document.getElementById('stat-eta').textContent = 'ETA: '+(d.eta||'—');
|
| 291 |
+
document.getElementById('stat-peers').textContent = d.peers || '—';
|
| 292 |
+
|
| 293 |
+
const badge = document.getElementById('status-badge');
|
| 294 |
+
const badgeText = document.getElementById('badge-text');
|
| 295 |
+
badge.className = 'status-badge ';
|
| 296 |
+
if(d.state==='downloading'){badge.className+='badge-downloading';badgeText.textContent=d.torrent_state||'Downloading';}
|
| 297 |
+
else if(d.state==='done'){badge.className+='badge-done';badgeText.textContent='Complete ✓';}
|
| 298 |
+
else if(d.state==='processing'){badge.className+='badge-processing';badgeText.textContent='Processing';}
|
| 299 |
+
else{badge.className+='badge-idle';badgeText.textContent='Idle';}
|
| 300 |
+
|
| 301 |
+
if(d.transfer_status) document.getElementById('stat-data').textContent = d.transfer_status;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// ─── My Files ─────────────────────────────────────────────────────────────────
|
| 305 |
+
async function loadFiles(){
|
| 306 |
+
const res = await fetch('/api/files');
|
| 307 |
+
const data = await res.json();
|
| 308 |
+
const container = document.getElementById('files-container');
|
| 309 |
+
if(!data.files || data.files.length===0){
|
| 310 |
+
container.innerHTML='<div class="empty-state">No files yet. Start a download above.</div>';
|
| 311 |
+
return;
|
| 312 |
+
}
|
| 313 |
+
let html = `<div style="font-size:.8rem;color:#64748b;margin-bottom:12px">${data.count} file(s) · ${data.total_size}</div>`;
|
| 314 |
+
html += '<table class="dl-table"><thead><tr><th>Name</th><th style="text-align:right">Size</th><th></th></tr></thead><tbody>';
|
| 315 |
+
data.files.forEach(f=>{
|
| 316 |
+
const encoded = encodeURIComponent(f.path).replace(/%2F/g,'/');
|
| 317 |
+
html += `<tr>
|
| 318 |
+
<td><div class="dl-filename" title="${f.path}">${f.name}</div></td>
|
| 319 |
+
<td class="dl-size">${f.size}</td>
|
| 320 |
+
<td class="dl-actions">
|
| 321 |
+
<a href="/api/download/${encoded}" download="${f.name}" class="btn btn-primary btn-sm">⬇ Download</a>
|
| 322 |
+
<button class="btn btn-ghost btn-sm" onclick="deleteFile('${encoded}','${f.name}')">🗑</button>
|
| 323 |
+
</td>
|
| 324 |
+
</tr>`;
|
| 325 |
+
});
|
| 326 |
+
html += '</tbody></table>';
|
| 327 |
+
if(data.count>0){
|
| 328 |
+
html+=`<div style="margin-top:12px;text-align:right"><button class="btn btn-danger btn-sm" onclick="deleteAll()">Delete All Files</button></div>`;
|
| 329 |
+
}
|
| 330 |
+
container.innerHTML=html;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
async function deleteFile(path, name){
|
| 334 |
+
if(!confirm(`Delete "${name}"?`)) return;
|
| 335 |
+
await fetch('/api/files/'+path,{method:'DELETE'});
|
| 336 |
+
loadFiles();
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
async function deleteAll(){
|
| 340 |
+
if(!confirm('Delete ALL downloaded files?')) return;
|
| 341 |
+
await fetch('/api/files',{method:'DELETE'});
|
| 342 |
+
loadFiles();
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// ─── Control ──────────────────────────────────────────────────────────────────
|
| 346 |
+
async function apiCall(endpoint){
|
| 347 |
+
await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
async function stopAndReset(){
|
| 351 |
+
await apiCall('/api/stop');
|
| 352 |
+
clearInterval(pollTimer);
|
| 353 |
+
pollTimer=null;
|
| 354 |
+
resetUI();
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function resetUI(){
|
| 358 |
+
document.getElementById('file-list-section').style.display='none';
|
| 359 |
+
document.getElementById('progress-section').style.display='none';
|
| 360 |
+
document.getElementById('magnet-input').value='';
|
| 361 |
+
document.getElementById('torrent-file').value='';
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// ─── Init ─────────────────────────────────────────────────────────────────────
|
| 365 |
+
loadFiles();
|
| 366 |
+
// Resume polling if download was already running
|
| 367 |
+
(async()=>{
|
| 368 |
+
const res = await fetch('/api/status');
|
| 369 |
+
const d = await res.json();
|
| 370 |
+
if(d.state==='downloading'){
|
| 371 |
+
document.getElementById('progress-section').style.display='block';
|
| 372 |
+
startPolling();
|
| 373 |
+
} else if(d.state==='done'){
|
| 374 |
+
loadFiles();
|
| 375 |
+
}
|
| 376 |
+
})();
|
| 377 |
+
</script>
|
| 378 |
+
</body>
|
| 379 |
+
</html>
|
torrent2drive/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Torrent2Space — Seedr-style torrent client on HF Space disk."""
|
torrent2drive/app.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application factory."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import uvicorn
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 10 |
+
|
| 11 |
+
from torrent2drive.config import STATIC_DIR
|
| 12 |
+
from torrent2drive.routes import register_routes
|
| 13 |
+
from torrent2drive.session import start_gc_loop
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_app() -> FastAPI:
|
| 17 |
+
application = FastAPI(title="Torrent2Space")
|
| 18 |
+
application.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origins=["*"],
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
application.add_middleware(
|
| 25 |
+
SessionMiddleware,
|
| 26 |
+
secret_key=os.environ.get("SESSION_SECRET", "torrent2space-change-me"),
|
| 27 |
+
same_site="lax",
|
| 28 |
+
)
|
| 29 |
+
register_routes(application)
|
| 30 |
+
assets_dir = os.path.join(STATIC_DIR, "assets")
|
| 31 |
+
if os.path.isdir(assets_dir):
|
| 32 |
+
application.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
| 33 |
+
if os.path.isdir(STATIC_DIR):
|
| 34 |
+
application.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 35 |
+
return application
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
app = create_app()
|
| 39 |
+
start_gc_loop()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def main():
|
| 43 |
+
uvicorn.run(
|
| 44 |
+
"torrent2drive.app:app",
|
| 45 |
+
host="0.0.0.0",
|
| 46 |
+
port=int(os.environ.get("PORT", "7860")),
|
| 47 |
+
reload=os.environ.get("T2D_RELOAD", "").lower() in ("1", "true", "yes"),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
torrent2drive/config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application paths."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 5 |
+
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _default_users_dir() -> str:
|
| 9 |
+
"""Pick a writable session/temp root (HF Docker runs as non-root)."""
|
| 10 |
+
if os.environ.get("T2D_USERS_DIR"):
|
| 11 |
+
return os.path.abspath(os.environ["T2D_USERS_DIR"])
|
| 12 |
+
legacy = os.path.join(BASE_DIR, "_users")
|
| 13 |
+
try:
|
| 14 |
+
os.makedirs(legacy, exist_ok=True)
|
| 15 |
+
if os.access(legacy, os.W_OK):
|
| 16 |
+
return legacy
|
| 17 |
+
except OSError:
|
| 18 |
+
pass
|
| 19 |
+
home = os.environ.get("HOME") or os.path.expanduser("~")
|
| 20 |
+
if home:
|
| 21 |
+
path = os.path.join(home, ".torrent2drive", "users")
|
| 22 |
+
os.makedirs(path, exist_ok=True)
|
| 23 |
+
return path
|
| 24 |
+
os.makedirs(legacy, exist_ok=True)
|
| 25 |
+
return legacy
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
BASE_TEMP = _default_users_dir()
|
| 29 |
+
|
| 30 |
+
if not os.path.isdir(STATIC_DIR):
|
| 31 |
+
try:
|
| 32 |
+
os.makedirs(STATIC_DIR, exist_ok=True)
|
| 33 |
+
except OSError:
|
| 34 |
+
pass
|
torrent2drive/constants.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared constants."""
|
| 2 |
+
|
| 3 |
+
STATE_STR = [
|
| 4 |
+
"Queued (checking)",
|
| 5 |
+
"Checking Files",
|
| 6 |
+
"Downloading Metadata",
|
| 7 |
+
"Downloading",
|
| 8 |
+
"Finished",
|
| 9 |
+
"Seeding",
|
| 10 |
+
"Allocating",
|
| 11 |
+
"Checking Resume Data",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
DEFAULT_ENC: dict = {}
|
torrent2drive/routes/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Route registration."""
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
|
| 5 |
+
from torrent2drive.routes.torrent import router as torrent_router
|
| 6 |
+
from torrent2drive.routes.control import router as control_router
|
| 7 |
+
from torrent2drive.routes.files import router as files_router
|
| 8 |
+
from torrent2drive.routes.pages import router as pages_router
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def register_routes(app: FastAPI):
|
| 12 |
+
app.include_router(torrent_router)
|
| 13 |
+
app.include_router(control_router)
|
| 14 |
+
app.include_router(files_router)
|
| 15 |
+
app.include_router(pages_router)
|
torrent2drive/routes/control.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Status API."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Request
|
| 6 |
+
|
| 7 |
+
from torrent2drive.constants import STATE_STR
|
| 8 |
+
from torrent2drive.session import get_or_create_session
|
| 9 |
+
from torrent2drive.utils import fmt_eta, fmt_size
|
| 10 |
+
|
| 11 |
+
router = APIRouter(tags=["control"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/api/status")
|
| 15 |
+
async def api_status(request: Request):
|
| 16 |
+
us = get_or_create_session(request)
|
| 17 |
+
|
| 18 |
+
base = {
|
| 19 |
+
"state": "idle",
|
| 20 |
+
"phase": "idle",
|
| 21 |
+
"cur_pct": 0,
|
| 22 |
+
"ovr_pct": 0,
|
| 23 |
+
"data": "—",
|
| 24 |
+
"speed": "—",
|
| 25 |
+
"peers": "—",
|
| 26 |
+
"eta": "—",
|
| 27 |
+
"ff_running": us.ff_running,
|
| 28 |
+
"pipeline_cur": us.pipeline_current,
|
| 29 |
+
"pipeline_total": us.pipeline_total,
|
| 30 |
+
"transfer_status": us.transfer_status,
|
| 31 |
+
"pipeline_error": us.pipeline_error,
|
| 32 |
+
"is_completed": us.is_completed,
|
| 33 |
+
"files_ready": us.pipeline_done,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if us.is_completed:
|
| 37 |
+
base.update({
|
| 38 |
+
"state": "done",
|
| 39 |
+
"phase": "done",
|
| 40 |
+
"cur_pct": 100,
|
| 41 |
+
"ovr_pct": 100,
|
| 42 |
+
})
|
| 43 |
+
return base
|
| 44 |
+
|
| 45 |
+
if not us.handle or not us.handle.is_valid():
|
| 46 |
+
return base
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
s = us.handle.status()
|
| 50 |
+
pct = s.progress * 100
|
| 51 |
+
dl = fmt_size(s.download_rate) + "/s"
|
| 52 |
+
dn = fmt_size(s.total_wanted_done)
|
| 53 |
+
tot = fmt_size(s.total_wanted)
|
| 54 |
+
eta = (
|
| 55 |
+
fmt_eta((s.total_wanted - s.total_wanted_done) / s.download_rate)
|
| 56 |
+
if s.download_rate > 0
|
| 57 |
+
else "—"
|
| 58 |
+
)
|
| 59 |
+
peers = f"{s.num_peers} ({s.num_seeds} seeds)"
|
| 60 |
+
|
| 61 |
+
if us.download_started and (s.is_finished or s.is_seeding) and not us.download_finished:
|
| 62 |
+
us.download_finished = True
|
| 63 |
+
# Trigger finalize in background
|
| 64 |
+
from torrent2drive.services.pipeline import finalize_in_background
|
| 65 |
+
finalize_in_background(us)
|
| 66 |
+
|
| 67 |
+
state_nm = (
|
| 68 |
+
STATE_STR[int(s.state)]
|
| 69 |
+
if int(s.state) < len(STATE_STR)
|
| 70 |
+
else "Downloading"
|
| 71 |
+
)
|
| 72 |
+
base.update({
|
| 73 |
+
"state": "downloading",
|
| 74 |
+
"phase": "torrent",
|
| 75 |
+
"cur_pct": pct,
|
| 76 |
+
"ovr_pct": pct,
|
| 77 |
+
"data": f"{dn} / {tot}",
|
| 78 |
+
"speed": dl,
|
| 79 |
+
"peers": peers,
|
| 80 |
+
"eta": eta,
|
| 81 |
+
"torrent_state": state_nm,
|
| 82 |
+
})
|
| 83 |
+
return base
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"[Status] {e}")
|
| 87 |
+
return base
|
torrent2drive/routes/files.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File management API — list, download, and delete files on Space disk."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import mimetypes
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Request, HTTPException
|
| 7 |
+
from fastapi.responses import FileResponse
|
| 8 |
+
|
| 9 |
+
from torrent2drive.session import get_or_create_session
|
| 10 |
+
from torrent2drive.utils import fmt_size
|
| 11 |
+
|
| 12 |
+
router = APIRouter(tags=["files"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _list_dir(path: str, base: str) -> list[dict]:
|
| 16 |
+
"""Recursively list all files under path, relative to base."""
|
| 17 |
+
result = []
|
| 18 |
+
for root, dirs, files in os.walk(path):
|
| 19 |
+
dirs.sort()
|
| 20 |
+
for fname in sorted(files):
|
| 21 |
+
full = os.path.join(root, fname)
|
| 22 |
+
rel = os.path.relpath(full, base)
|
| 23 |
+
size = os.path.getsize(full)
|
| 24 |
+
result.append({
|
| 25 |
+
"name": fname,
|
| 26 |
+
"path": rel,
|
| 27 |
+
"size": fmt_size(size),
|
| 28 |
+
"size_bytes": size,
|
| 29 |
+
})
|
| 30 |
+
return result
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.get("/api/files")
|
| 34 |
+
async def api_list_files(request: Request):
|
| 35 |
+
"""List all downloaded files in the user's session directory."""
|
| 36 |
+
us = get_or_create_session(request)
|
| 37 |
+
files = _list_dir(us.dl_dir, us.dl_dir)
|
| 38 |
+
total_bytes = sum(f["size_bytes"] for f in files)
|
| 39 |
+
return {
|
| 40 |
+
"files": files,
|
| 41 |
+
"total_size": fmt_size(total_bytes),
|
| 42 |
+
"total_bytes": total_bytes,
|
| 43 |
+
"count": len(files),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/api/download/{file_path:path}")
|
| 48 |
+
async def api_download_file(file_path: str, request: Request):
|
| 49 |
+
"""Stream a file from the user's session directory."""
|
| 50 |
+
us = get_or_create_session(request)
|
| 51 |
+
|
| 52 |
+
# Security: resolve and ensure the path stays within dl_dir
|
| 53 |
+
target = os.path.realpath(os.path.join(us.dl_dir, file_path))
|
| 54 |
+
dl_dir_real = os.path.realpath(us.dl_dir)
|
| 55 |
+
if not target.startswith(dl_dir_real + os.sep) and target != dl_dir_real:
|
| 56 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 57 |
+
|
| 58 |
+
if not os.path.isfile(target):
|
| 59 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 60 |
+
|
| 61 |
+
fname = os.path.basename(target)
|
| 62 |
+
mime, _ = mimetypes.guess_type(target)
|
| 63 |
+
mime = mime or "application/octet-stream"
|
| 64 |
+
|
| 65 |
+
return FileResponse(
|
| 66 |
+
path=target,
|
| 67 |
+
filename=fname,
|
| 68 |
+
media_type=mime,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.delete("/api/files/{file_path:path}")
|
| 73 |
+
async def api_delete_file(file_path: str, request: Request):
|
| 74 |
+
"""Delete a specific downloaded file."""
|
| 75 |
+
us = get_or_create_session(request)
|
| 76 |
+
|
| 77 |
+
target = os.path.realpath(os.path.join(us.dl_dir, file_path))
|
| 78 |
+
dl_dir_real = os.path.realpath(us.dl_dir)
|
| 79 |
+
if not target.startswith(dl_dir_real + os.sep):
|
| 80 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 81 |
+
|
| 82 |
+
if not os.path.isfile(target):
|
| 83 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 84 |
+
|
| 85 |
+
os.remove(target)
|
| 86 |
+
return {"ok": True, "deleted": file_path}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.delete("/api/files")
|
| 90 |
+
async def api_delete_all_files(request: Request):
|
| 91 |
+
"""Delete all downloaded files for this session."""
|
| 92 |
+
import shutil
|
| 93 |
+
us = get_or_create_session(request)
|
| 94 |
+
shutil.rmtree(us.dl_dir, ignore_errors=True)
|
| 95 |
+
os.makedirs(us.dl_dir, exist_ok=True)
|
| 96 |
+
us.is_completed = False
|
| 97 |
+
us.transfer_status = ""
|
| 98 |
+
us.pipeline_done = []
|
| 99 |
+
return {"ok": True}
|
torrent2drive/routes/pages.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTML page routes."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
+
|
| 7 |
+
from torrent2drive.config import STATIC_DIR
|
| 8 |
+
|
| 9 |
+
router = APIRouter(tags=["pages"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/")
|
| 13 |
+
async def index():
|
| 14 |
+
path = os.path.join(STATIC_DIR, "index.html")
|
| 15 |
+
if os.path.exists(path):
|
| 16 |
+
return FileResponse(path)
|
| 17 |
+
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
torrent2drive/routes/torrent.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Torrent load and control API."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import threading
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Request
|
| 7 |
+
|
| 8 |
+
from torrent2drive.session import get_or_create_session
|
| 9 |
+
from torrent2drive.torrent_engine import load_torrent, remove_handle
|
| 10 |
+
from torrent2drive.utils import fmt_size
|
| 11 |
+
|
| 12 |
+
router = APIRouter(tags=["torrent"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.post("/api/load_magnet")
|
| 16 |
+
async def api_load_magnet(body: dict, request: Request):
|
| 17 |
+
us = get_or_create_session(request)
|
| 18 |
+
files, err = load_torrent(us, magnet=body.get("magnet", ""))
|
| 19 |
+
if err:
|
| 20 |
+
return {"error": err}
|
| 21 |
+
return {
|
| 22 |
+
"name": us.ti.name(),
|
| 23 |
+
"total_size": fmt_size(us.ti.total_size()),
|
| 24 |
+
"num_files": us.ti.files().num_files(),
|
| 25 |
+
"files": files,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/api/load_torrent")
|
| 30 |
+
async def api_load_torrent(body: dict, request: Request):
|
| 31 |
+
us = get_or_create_session(request)
|
| 32 |
+
try:
|
| 33 |
+
raw = base64.b64decode(body.get("data", ""))
|
| 34 |
+
except Exception:
|
| 35 |
+
return {"error": "Bad base64"}
|
| 36 |
+
files, err = load_torrent(us, torrent_bytes=raw)
|
| 37 |
+
if err:
|
| 38 |
+
return {"error": err}
|
| 39 |
+
return {
|
| 40 |
+
"name": us.ti.name(),
|
| 41 |
+
"total_size": fmt_size(us.ti.total_size()),
|
| 42 |
+
"num_files": us.ti.files().num_files(),
|
| 43 |
+
"files": files,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("/api/start")
|
| 48 |
+
async def api_start(body: dict, request: Request):
|
| 49 |
+
us = get_or_create_session(request)
|
| 50 |
+
|
| 51 |
+
if not us.ti:
|
| 52 |
+
return {"error": "No torrent loaded"}
|
| 53 |
+
if not us.handle or not us.handle.is_valid():
|
| 54 |
+
return {"error": "Invalid handle"}
|
| 55 |
+
|
| 56 |
+
selected = body.get("selected", [])
|
| 57 |
+
if not selected:
|
| 58 |
+
return {"error": "No files selected"}
|
| 59 |
+
|
| 60 |
+
us.stream_watch_only = False
|
| 61 |
+
us.stream_watch_active = False
|
| 62 |
+
us.stream_watch_index = -1
|
| 63 |
+
us.download_finished = False
|
| 64 |
+
us.selected_file_indices = [int(i) for i in selected]
|
| 65 |
+
|
| 66 |
+
pri = [0] * us.ti.files().num_files()
|
| 67 |
+
for idx in us.selected_file_indices:
|
| 68 |
+
pri[idx] = 4
|
| 69 |
+
us.handle.prioritize_files(pri)
|
| 70 |
+
us.handle.resume()
|
| 71 |
+
us.download_started = True
|
| 72 |
+
|
| 73 |
+
return {"ok": True, "count": len(us.selected_file_indices)}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post("/api/pause")
|
| 77 |
+
async def api_pause(request: Request):
|
| 78 |
+
us = get_or_create_session(request)
|
| 79 |
+
try:
|
| 80 |
+
if us.handle and us.handle.is_valid():
|
| 81 |
+
us.handle.pause()
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
return {"ok": True}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.post("/api/resume")
|
| 88 |
+
async def api_resume(request: Request):
|
| 89 |
+
us = get_or_create_session(request)
|
| 90 |
+
try:
|
| 91 |
+
if us.handle and us.handle.is_valid():
|
| 92 |
+
us.handle.resume()
|
| 93 |
+
except Exception:
|
| 94 |
+
pass
|
| 95 |
+
return {"ok": True}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.post("/api/stop")
|
| 99 |
+
async def api_stop(request: Request):
|
| 100 |
+
us = get_or_create_session(request)
|
| 101 |
+
us.download_started = False
|
| 102 |
+
us.download_finished = False
|
| 103 |
+
us.is_transferring = False
|
| 104 |
+
us.is_completed = False
|
| 105 |
+
us.ff_running = False
|
| 106 |
+
try:
|
| 107 |
+
remove_handle(us.handle)
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
| 110 |
+
us.handle = None
|
| 111 |
+
us.ti = None
|
| 112 |
+
us.stream_watch_only = False
|
| 113 |
+
us.stream_watch_active = False
|
| 114 |
+
us.stream_watch_index = -1
|
| 115 |
+
return {"ok": True}
|
torrent2drive/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# services package
|
torrent2drive/services/pipeline.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download pipeline: torrent → disk (Seedr-style, no cloud upload)."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import threading
|
| 5 |
+
|
| 6 |
+
from torrent2drive.session import UserSession
|
| 7 |
+
from torrent2drive.torrent_engine import remove_handle
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def reset_pipeline(us: UserSession):
|
| 11 |
+
us.pipeline_total = 0
|
| 12 |
+
us.pipeline_current = 0
|
| 13 |
+
us.pipeline_current_name = ""
|
| 14 |
+
us.pipeline_failed = []
|
| 15 |
+
us.pipeline_skipped = []
|
| 16 |
+
us.pipeline_done = []
|
| 17 |
+
us.pipeline_overall_pct = 0.0
|
| 18 |
+
us.pipeline_error = ""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def finalize_download(us: UserSession):
|
| 22 |
+
"""Called when torrent finishes downloading — mark complete, release handle."""
|
| 23 |
+
with us.lock:
|
| 24 |
+
if us.is_transferring:
|
| 25 |
+
return
|
| 26 |
+
us.is_transferring = True
|
| 27 |
+
us.is_completed = False
|
| 28 |
+
reset_pipeline(us)
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
if not us.ti:
|
| 32 |
+
us.transfer_status = "No torrent info."
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
files = []
|
| 36 |
+
for idx in us.selected_file_indices:
|
| 37 |
+
p = os.path.join(us.dl_dir, us.ti.files().file_path(idx))
|
| 38 |
+
if os.path.exists(p):
|
| 39 |
+
files.append(p)
|
| 40 |
+
|
| 41 |
+
us.pipeline_total = len(files)
|
| 42 |
+
if us.pipeline_total == 0:
|
| 43 |
+
msg = "No files found on disk."
|
| 44 |
+
us.transfer_status = msg
|
| 45 |
+
us.pipeline_error = msg
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
# Release the torrent handle so libtorrent stops seeding
|
| 49 |
+
if us.handle:
|
| 50 |
+
try:
|
| 51 |
+
remove_handle(us.handle)
|
| 52 |
+
except Exception:
|
| 53 |
+
pass
|
| 54 |
+
us.handle = None
|
| 55 |
+
|
| 56 |
+
# Mark every file as done (they're already on disk)
|
| 57 |
+
for i, p in enumerate(files, 1):
|
| 58 |
+
us.pipeline_current = i
|
| 59 |
+
name = os.path.basename(p)
|
| 60 |
+
us.pipeline_current_name = name
|
| 61 |
+
us.pipeline_overall_pct = (i / us.pipeline_total) * 100
|
| 62 |
+
us.pipeline_done.append(name)
|
| 63 |
+
print(f"[Pipeline] ready on disk: {name}")
|
| 64 |
+
|
| 65 |
+
us.pipeline_overall_pct = 100.0
|
| 66 |
+
ok = len(us.pipeline_done)
|
| 67 |
+
us.transfer_status = f"Done — {ok} file(s) ready for download"
|
| 68 |
+
us.is_completed = True
|
| 69 |
+
print(f"[Pipeline] finished: {us.transfer_status}")
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
msg = f"Pipeline error: {e}"
|
| 73 |
+
us.transfer_status = msg
|
| 74 |
+
us.pipeline_error = msg
|
| 75 |
+
print(f"[Pipeline] {msg}")
|
| 76 |
+
finally:
|
| 77 |
+
us.ff_running = False
|
| 78 |
+
us.download_started = False
|
| 79 |
+
us.is_transferring = False
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def finalize_in_background(us: UserSession):
|
| 83 |
+
threading.Thread(target=finalize_download, args=(us,), daemon=True).start()
|
torrent2drive/session.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-browser user sessions and idle cleanup."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import secrets
|
| 5 |
+
import shutil
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from fastapi import Request
|
| 10 |
+
|
| 11 |
+
from torrent2drive.config import BASE_TEMP
|
| 12 |
+
from torrent2drive.constants import DEFAULT_ENC
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class UserSession:
|
| 16 |
+
"""Isolated state for a single browser session / user."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, sid: str):
|
| 19 |
+
self.sid = sid
|
| 20 |
+
self.lock = threading.Lock()
|
| 21 |
+
self.last_seen = time.time()
|
| 22 |
+
|
| 23 |
+
self.dl_dir = os.path.join(BASE_TEMP, sid, "downloads")
|
| 24 |
+
os.makedirs(self.dl_dir, exist_ok=True)
|
| 25 |
+
|
| 26 |
+
self.handle = None
|
| 27 |
+
self.ti = None
|
| 28 |
+
self.selected_file_indices = []
|
| 29 |
+
|
| 30 |
+
self.download_started = False
|
| 31 |
+
self.download_finished = False
|
| 32 |
+
self.is_transferring = False
|
| 33 |
+
self.is_completed = False
|
| 34 |
+
self.transfer_status = ""
|
| 35 |
+
self.use_ffmpeg = False
|
| 36 |
+
|
| 37 |
+
self.ff_progress = 0.0
|
| 38 |
+
self.ff_eta = ""
|
| 39 |
+
self.ff_speed = ""
|
| 40 |
+
self.ff_size_done = ""
|
| 41 |
+
self.ff_file_label = ""
|
| 42 |
+
self.ff_running = False
|
| 43 |
+
self.ff_stage = ""
|
| 44 |
+
self.ff_duration_s = 0.0
|
| 45 |
+
self.ff_time_done_s = 0.0
|
| 46 |
+
|
| 47 |
+
self.pipeline_total = 0
|
| 48 |
+
self.pipeline_current = 0
|
| 49 |
+
self.pipeline_current_name = ""
|
| 50 |
+
self.pipeline_failed: list[str] = []
|
| 51 |
+
self.pipeline_skipped: list[str] = []
|
| 52 |
+
self.pipeline_done: list[str] = []
|
| 53 |
+
self.pipeline_overall_pct = 0.0
|
| 54 |
+
self.pipeline_error = ""
|
| 55 |
+
|
| 56 |
+
self.enc = dict(DEFAULT_ENC)
|
| 57 |
+
|
| 58 |
+
self.stream_watch_only = False
|
| 59 |
+
self.stream_watch_active = False
|
| 60 |
+
self.stream_watch_index = -1
|
| 61 |
+
|
| 62 |
+
def touch(self):
|
| 63 |
+
self.last_seen = time.time()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
_sessions: dict[str, UserSession] = {}
|
| 67 |
+
_sessions_lock = threading.Lock()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_or_create_session(request: Request) -> UserSession:
|
| 71 |
+
sid = request.session.get("sid")
|
| 72 |
+
if not sid:
|
| 73 |
+
sid = secrets.token_urlsafe(16)
|
| 74 |
+
request.session["sid"] = sid
|
| 75 |
+
with _sessions_lock:
|
| 76 |
+
us = _sessions.get(sid)
|
| 77 |
+
if us is None:
|
| 78 |
+
us = UserSession(sid)
|
| 79 |
+
_sessions[sid] = us
|
| 80 |
+
us.touch()
|
| 81 |
+
return us
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_session_by_sid(sid: str) -> UserSession | None:
|
| 85 |
+
with _sessions_lock:
|
| 86 |
+
return _sessions.get(sid)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _gc_sessions(idle_seconds=3600 * 6):
|
| 90 |
+
now = time.time()
|
| 91 |
+
drop = []
|
| 92 |
+
with _sessions_lock:
|
| 93 |
+
for sid, us in _sessions.items():
|
| 94 |
+
if (
|
| 95 |
+
us.is_transferring
|
| 96 |
+
or us.ff_running
|
| 97 |
+
or us.download_started
|
| 98 |
+
or us.stream_watch_active
|
| 99 |
+
):
|
| 100 |
+
continue
|
| 101 |
+
if now - us.last_seen > idle_seconds:
|
| 102 |
+
drop.append(sid)
|
| 103 |
+
for sid in drop:
|
| 104 |
+
us = _sessions.pop(sid, None)
|
| 105 |
+
if not us:
|
| 106 |
+
continue
|
| 107 |
+
try:
|
| 108 |
+
from torrent2drive.torrent_engine import remove_handle
|
| 109 |
+
remove_handle(us.handle)
|
| 110 |
+
except Exception:
|
| 111 |
+
pass
|
| 112 |
+
try:
|
| 113 |
+
shutil.rmtree(os.path.join(BASE_TEMP, sid), ignore_errors=True)
|
| 114 |
+
except Exception:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _gc_loop():
|
| 119 |
+
while True:
|
| 120 |
+
try:
|
| 121 |
+
_gc_sessions()
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"[GC] {e}")
|
| 124 |
+
time.sleep(900)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def start_gc_loop():
|
| 128 |
+
threading.Thread(target=_gc_loop, daemon=True).start()
|
torrent2drive/torrent_engine.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""libtorrent wrapper — load magnets / .torrent files into a session."""
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
import libtorrent as lt # type: ignore
|
| 8 |
+
LT_AVAILABLE = True
|
| 9 |
+
except ImportError:
|
| 10 |
+
lt = None
|
| 11 |
+
LT_AVAILABLE = False
|
| 12 |
+
|
| 13 |
+
from torrent2drive.utils import fmt_size
|
| 14 |
+
|
| 15 |
+
_lt_session = None
|
| 16 |
+
_lt_lock = threading.Lock()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _get_lt_session():
|
| 20 |
+
global _lt_session
|
| 21 |
+
with _lt_lock:
|
| 22 |
+
if _lt_session is None:
|
| 23 |
+
if not LT_AVAILABLE:
|
| 24 |
+
raise RuntimeError("libtorrent is not installed")
|
| 25 |
+
settings = {
|
| 26 |
+
"enable_dht": True,
|
| 27 |
+
"enable_lsd": True,
|
| 28 |
+
"enable_upnp": True,
|
| 29 |
+
"enable_natpmp": True,
|
| 30 |
+
}
|
| 31 |
+
_lt_session = lt.session(settings)
|
| 32 |
+
return _lt_session
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_torrent(us, *, magnet: str = "", torrent_bytes: bytes = b""):
|
| 36 |
+
"""Load a magnet link or .torrent bytes into the session.
|
| 37 |
+
|
| 38 |
+
Returns (files_list, error_string). On success error_string is "".
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
sess = _get_lt_session()
|
| 42 |
+
except RuntimeError as e:
|
| 43 |
+
return [], str(e)
|
| 44 |
+
|
| 45 |
+
params = lt.add_torrent_params()
|
| 46 |
+
params.save_path = us.dl_dir
|
| 47 |
+
|
| 48 |
+
if magnet:
|
| 49 |
+
try:
|
| 50 |
+
params = lt.parse_magnet_uri(magnet)
|
| 51 |
+
params.save_path = us.dl_dir
|
| 52 |
+
except Exception as e:
|
| 53 |
+
return [], f"Bad magnet: {e}"
|
| 54 |
+
elif torrent_bytes:
|
| 55 |
+
try:
|
| 56 |
+
info = lt.torrent_info(lt.bdecode(torrent_bytes))
|
| 57 |
+
params.ti = info
|
| 58 |
+
except Exception as e:
|
| 59 |
+
return [], f"Bad torrent file: {e}"
|
| 60 |
+
else:
|
| 61 |
+
return [], "No magnet or torrent provided"
|
| 62 |
+
|
| 63 |
+
# Remove old handle if exists
|
| 64 |
+
if us.handle and us.handle.is_valid():
|
| 65 |
+
try:
|
| 66 |
+
sess.remove_torrent(us.handle)
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
handle = sess.add_torrent(params)
|
| 71 |
+
|
| 72 |
+
# Wait for metadata (magnet links need DHT)
|
| 73 |
+
deadline = time.time() + 60
|
| 74 |
+
while not handle.has_metadata():
|
| 75 |
+
if time.time() > deadline:
|
| 76 |
+
sess.remove_torrent(handle)
|
| 77 |
+
return [], "Metadata timeout — check your magnet link or try again"
|
| 78 |
+
time.sleep(0.5)
|
| 79 |
+
|
| 80 |
+
ti = handle.get_torrent_info()
|
| 81 |
+
handle.pause()
|
| 82 |
+
|
| 83 |
+
# Disable all files by default
|
| 84 |
+
priorities = [0] * ti.files().num_files()
|
| 85 |
+
handle.prioritize_files(priorities)
|
| 86 |
+
|
| 87 |
+
us.handle = handle
|
| 88 |
+
us.ti = ti
|
| 89 |
+
us.download_started = False
|
| 90 |
+
us.download_finished = False
|
| 91 |
+
us.selected_file_indices = []
|
| 92 |
+
|
| 93 |
+
files = []
|
| 94 |
+
for i in range(ti.files().num_files()):
|
| 95 |
+
files.append({
|
| 96 |
+
"index": i,
|
| 97 |
+
"name": ti.files().file_path(i),
|
| 98 |
+
"size": fmt_size(ti.files().file_size(i)),
|
| 99 |
+
"size_bytes": ti.files().file_size(i),
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
return files, ""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def remove_handle(handle):
|
| 106 |
+
if handle is None:
|
| 107 |
+
return
|
| 108 |
+
try:
|
| 109 |
+
sess = _get_lt_session()
|
| 110 |
+
if handle.is_valid():
|
| 111 |
+
sess.remove_torrent(handle)
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
torrent2drive/utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared utilities."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
VIDEO_EXTENSIONS = {
|
| 5 |
+
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm",
|
| 6 |
+
".m4v", ".mpg", ".mpeg", ".ts", ".m2ts", ".3gp",
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def fmt_size(n: int | float) -> str:
|
| 11 |
+
if n < 0:
|
| 12 |
+
return "—"
|
| 13 |
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
| 14 |
+
if n < 1024:
|
| 15 |
+
return f"{n:.1f} {unit}"
|
| 16 |
+
n /= 1024
|
| 17 |
+
return f"{n:.1f} PB"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def fmt_eta(seconds: float) -> str:
|
| 21 |
+
if seconds <= 0 or seconds != seconds:
|
| 22 |
+
return "—"
|
| 23 |
+
seconds = int(seconds)
|
| 24 |
+
h, r = divmod(seconds, 3600)
|
| 25 |
+
m, s = divmod(r, 60)
|
| 26 |
+
if h:
|
| 27 |
+
return f"{h}h {m}m"
|
| 28 |
+
if m:
|
| 29 |
+
return f"{m}m {s}s"
|
| 30 |
+
return f"{s}s"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def is_video(path: str) -> bool:
|
| 34 |
+
return os.path.splitext(path)[1].lower() in VIDEO_EXTENSIONS
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def find_first_video(directory: str) -> str:
|
| 38 |
+
for root, _, files in os.walk(directory):
|
| 39 |
+
for f in files:
|
| 40 |
+
if is_video(f):
|
| 41 |
+
return os.path.join(root, f)
|
| 42 |
+
return ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def ffmpeg_ok() -> bool:
|
| 46 |
+
import shutil
|
| 47 |
+
return shutil.which("ffmpeg") is not None
|