alisaadhq commited on
Commit
d286c92
·
verified ·
1 Parent(s): f09630e

Upload 19 files

Browse files
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: gray
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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