Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /* ══ DOWNLOAD MANAGER VIEW ════════════════════════════════════════════════ |
| 2 | * URL input, active downloads, history. |
| 3 | * ES5 compatible for PS5 browser. |
| 4 | * ═════════════════════════════════════════════════════════════════════════ */ |
| 5 | |
| 6 | var ZFTPD = ZFTPD || {}; |
| 7 | |
| 8 | (function (Z) { |
| 9 | 'use strict'; |
| 10 | |
| 11 | var D = document; |
| 12 | var $ = Z.$; |
| 13 | var ICO = Z.ICO; |
| 14 | |
| 15 | var dlm = {}; |
| 16 | var _downloads = []; |
| 17 | var _history = []; |
| 18 | var _pollTimer = null; |
| 19 | var _destPath = '/'; |
| 20 | |
| 21 | dlm.refresh = function () { |
| 22 | renderActive(); |
| 23 | renderHistory(); |
| 24 | startPolling(); |
| 25 | }; |
| 26 | |
| 27 | /* ── Start a download ── */ |
| 28 | dlm.start = function () { |
| 29 | var input = $('dl-url'); |
| 30 | if (!input) return; |
| 31 | var url = (input.value || '').trim(); |
| 32 | if (!url) { |
| 33 | Z.toast('Enter a URL first', 'wn'); |
| 34 | input.focus(); |
| 35 | return; |
| 36 | } |
| 37 | |
| 38 | /* Detect URL type */ |
| 39 | var type = detectUrlType(url); |
| 40 | var displayName = extractFilename(url) || 'download'; |
| 41 | |
| 42 | Z.toast('Starting download: ' + displayName, 'ok'); |
| 43 | input.value = ''; |
| 44 | |
| 45 | var entry = { |
| 46 | id: Date.now(), |
| 47 | url: url, |
| 48 | name: displayName, |
| 49 | type: type, |
| 50 | dst: _destPath, |
| 51 | status: 'starting', |
| 52 | progress: 0, |
| 53 | speed: 0, |
| 54 | size: 0, |
| 55 | downloaded: 0, |
| 56 | startTime: Date.now() |
| 57 | }; |
| 58 | _downloads.push(entry); |
| 59 | renderActive(); |
| 60 | |
| 61 | Z.api.downloadStart(url, _destPath).then(function (d) { |
| 62 | entry.status = 'downloading'; |
| 63 | if (d && d.id) entry.serverId = d.id; |
| 64 | if (d && d.name) entry.name = d.name; |
| 65 | if (d && d.size) entry.size = d.size; |
| 66 | renderActive(); |
| 67 | /* Start topbar download progress polling */ |
| 68 | if (Z.onDownloadStarted) Z.onDownloadStarted(); |
| 69 | }).catch(function (e) { |
| 70 | entry.status = 'error'; |
| 71 | entry.error = e.message; |
| 72 | renderActive(); |
| 73 | Z.notify('Download failed', displayName + ': ' + e.message, 'er'); |
| 74 | }); |
| 75 | }; |
| 76 | |
| 77 | /* ── URL type detection ── */ |
| 78 | function detectUrlType(url) { |
| 79 | if (/magnet:/i.test(url)) return 'magnet'; |
| 80 | if (/drive\.google\.com/i.test(url)) return 'gdrive'; |
| 81 | if (/mega\.(nz|co\.nz)/i.test(url)) return 'mega'; |
| 82 | if (/mediafire\.com/i.test(url)) return 'mediafire'; |
| 83 | if (/1fichier\.com/i.test(url)) return '1fichier'; |
| 84 | return 'http'; |
| 85 | } |
| 86 | |
| 87 | /* ── Extract filename from URL ── */ |
| 88 | function extractFilename(url) { |
| 89 | try { |
| 90 | if (/magnet:/i.test(url)) { |
| 91 | var dnMatch = url.match(/dn=([^&]+)/); |
| 92 | return dnMatch ? decodeURIComponent(dnMatch[1]) : 'magnet-download'; |
| 93 | } |
| 94 | var path = url.split('?')[0].split('#')[0]; |
| 95 | var parts = path.split('/'); |
| 96 | var last = parts[parts.length - 1]; |
| 97 | return last ? decodeURIComponent(last) : 'download'; |
| 98 | } catch (e) { |
| 99 | return 'download'; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /* ── Type icon ── */ |
| 104 | function typeIcon(type) { |
| 105 | switch (type) { |
| 106 | case 'magnet': return ICO.link; |
| 107 | case 'gdrive': return ICO.cloud; |
| 108 | case 'mega': return ICO.cloud; |
| 109 | case 'mediafire': return ICO.cloud; |
| 110 | default: return ICO.cloudDown; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /* ── Render active downloads ── */ |
| 115 | function renderActive() { |
| 116 | var wrap = $('dl-active'); |
| 117 | if (!wrap) return; |
| 118 | wrap.innerHTML = ''; |
| 119 | |
| 120 | var countEl = $('dl-active-count'); |
| 121 | var active = _downloads.filter(function (d) { return d.status !== 'done' && d.status !== 'error'; }); |
| 122 | if (countEl) countEl.textContent = active.length; |
| 123 | |
| 124 | if (!_downloads.length) { |
| 125 | wrap.innerHTML = '<div class="dl-empty"><div class="dl-empty-icon">' + ICO.cloudDown + '</div><div>No active downloads</div><div style="font-size:11px;color:var(--tx3);">Paste a URL above to start downloading</div></div>'; |
| 126 | return; |
| 127 | } |
| 128 | |
| 129 | for (var i = 0; i < _downloads.length; i++) { |
| 130 | var dl = _downloads[i]; |
| 131 | var card = D.createElement('div'); |
| 132 | card.className = 'dl-card'; |
| 133 | |
| 134 | var elapsed = (Date.now() - dl.startTime) / 1000; |
| 135 | var speedStr = dl.speed > 0 ? Z.bps(dl.speed) : '\u2014'; |
| 136 | var etaStr = dl.speed > 0 && dl.size > 0 ? Z.duration((dl.size - dl.downloaded) / dl.speed) : '\u2014'; |
| 137 | var progressClass = dl.status === 'error' ? ' error' : dl.status === 'done' ? ' done' : ''; |
| 138 | var statusLabel = dl.status === 'downloading' ? 'Downloading' : dl.status === 'starting' ? 'Starting\u2026' : dl.status === 'paused' ? 'Paused' : dl.status === 'done' ? 'Complete' : dl.status === 'error' ? 'Failed' : dl.status; |
| 139 | |
| 140 | card.innerHTML = |
| 141 | '<div class="dl-card-top">' + |
| 142 | '<div class="dl-card-icon">' + typeIcon(dl.type) + '</div>' + |
| 143 | '<div class="dl-card-info">' + |
| 144 | '<div class="dl-card-name">' + dl.name + '</div>' + |
| 145 | '<div class="dl-card-url">' + dl.url.substring(0, 80) + (dl.url.length > 80 ? '\u2026' : '') + '</div>' + |
| 146 | '</div>' + |
| 147 | '<div class="dl-card-stats">' + |
| 148 | '<div class="dl-stat"><div class="dl-stat-value">' + speedStr + '</div><div class="dl-stat-label">Speed</div></div>' + |
| 149 | '<div class="dl-stat"><div class="dl-stat-value">' + etaStr + '</div><div class="dl-stat-label">ETA</div></div>' + |
| 150 | '<div class="dl-stat"><div class="dl-stat-value">' + dl.progress + '%</div><div class="dl-stat-label">' + statusLabel + '</div></div>' + |
| 151 | '</div>' + |
| 152 | '</div>' + |
| 153 | '<div class="dl-progress"><div class="dl-progress-fill' + progressClass + '" style="width:' + dl.progress + '%"></div></div>' + |
| 154 | '<div class="dl-card-actions">' + |
| 155 | (dl.status === 'downloading' ? '<button class="btn" data-action="pause" data-id="' + dl.id + '">' + ICO.pause + ' Pause</button>' : '') + |
| 156 | (dl.status === 'paused' ? '<button class="btn" data-action="resume" data-id="' + dl.id + '">' + ICO.play + ' Resume</button>' : '') + |
| 157 | '<button class="btn danger" data-action="cancel" data-id="' + dl.id + '">' + ICO.xcancel + ' Cancel</button>' + |
| 158 | '</div>'; |
| 159 | |
| 160 | wrap.appendChild(card); |
| 161 | } |
| 162 | |
| 163 | /* Wire action buttons */ |
| 164 | var btns = wrap.querySelectorAll('button[data-action]'); |
| 165 | for (var b = 0; b < btns.length; b++) { |
| 166 | (function (btn) { |
| 167 | btn.onclick = function () { |
| 168 | var action = btn.getAttribute('data-action'); |
| 169 | var id = parseInt(btn.getAttribute('data-id'), 10); |
| 170 | handleAction(action, id); |
| 171 | }; |
| 172 | })(btns[b]); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | /* ── Handle button actions ── */ |
| 177 | function handleAction(action, id) { |
| 178 | var dl = _downloads.filter(function (d) { return d.id === id; })[0]; |
| 179 | if (!dl) return; |
| 180 | |
| 181 | if (action === 'cancel') { |
| 182 | if (dl.serverId) Z.api.downloadCancel(dl.serverId).catch(function () { }); |
| 183 | dl.status = 'error'; |
| 184 | dl.error = 'Cancelled'; |
| 185 | /* Move to history */ |
| 186 | _history.unshift({ name: dl.name, size: dl.downloaded, date: Date.now(), status: 'cancelled' }); |
| 187 | _downloads = _downloads.filter(function (d) { return d.id !== id; }); |
| 188 | renderActive(); |
| 189 | renderHistory(); |
| 190 | Z.toast('Download cancelled', 'wn'); |
| 191 | } else if (action === 'pause') { |
| 192 | if (dl.serverId) Z.api.downloadPause(dl.serverId).catch(function () { }); |
| 193 | dl.status = 'paused'; |
| 194 | renderActive(); |
| 195 | } else if (action === 'resume') { |
| 196 | if (dl.serverId) Z.api.downloadPause(dl.serverId).catch(function () { }); |
| 197 | dl.status = 'downloading'; |
| 198 | renderActive(); |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | /* ── Render history ── */ |
| 203 | function renderHistory() { |
| 204 | var wrap = $('dl-history'); |
| 205 | if (!wrap) return; |
| 206 | wrap.innerHTML = ''; |
| 207 | |
| 208 | if (!_history.length) { |
| 209 | wrap.innerHTML = '<div style="padding:16px;text-align:center;color:var(--tx3);font-size:11px;">No download history</div>'; |
| 210 | return; |
| 211 | } |
| 212 | |
| 213 | for (var i = 0; i < _history.length && i < 20; i++) { |
| 214 | var h = _history[i]; |
| 215 | var item = D.createElement('div'); |
| 216 | item.className = 'dl-history-item'; |
| 217 | item.innerHTML = |
| 218 | '<div class="dl-history-name">' + h.name + '</div>' + |
| 219 | '<div class="dl-history-size">' + Z.bytes(h.size || 0) + '</div>' + |
| 220 | '<div class="dl-history-date">' + Z.relativeTime(Math.floor(h.date / 1000)) + '</div>' + |
| 221 | '<div class="dl-history-status ' + (h.status === 'complete' ? 'ok' : 'er') + '">' + h.status + '</div>'; |
| 222 | wrap.appendChild(item); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /* ── Poll for progress ── */ |
| 227 | function startPolling() { |
| 228 | if (_pollTimer) clearInterval(_pollTimer); |
| 229 | _pollTimer = setInterval(function () { |
| 230 | var active = _downloads.filter(function (d) { return d.status === 'downloading'; }); |
| 231 | if (!active.length) return; |
| 232 | |
| 233 | Z.api.downloadStatus().then(function (data) { |
| 234 | if (!data || !Array.isArray(data.downloads)) return; |
| 235 | for (var i = 0; i < data.downloads.length; i++) { |
| 236 | var sd = data.downloads[i]; |
| 237 | var local = _downloads.filter(function (d) { return d.serverId === sd.id; })[0]; |
| 238 | if (!local) continue; |
| 239 | local.progress = sd.progress || 0; |
| 240 | local.speed = sd.speed || 0; |
| 241 | local.downloaded = sd.downloaded || 0; |
| 242 | local.size = sd.total_size || local.size; |
| 243 | if (sd.done) { |
| 244 | local.status = sd.error ? 'error' : 'done'; |
| 245 | if (sd.error) local.error = sd.error; |
| 246 | /* Move to history */ |
| 247 | _history.unshift({ name: local.name, size: local.size, date: Date.now(), status: local.status === 'done' ? 'complete' : 'failed' }); |
| 248 | _downloads = _downloads.filter(function (d) { return d.id !== local.id; }); |
| 249 | if (local.status === 'done') Z.toast('Downloaded: ' + local.name, 'ok'); |
| 250 | } |
| 251 | } |
| 252 | renderActive(); |
| 253 | renderHistory(); |
| 254 | }).catch(function () { }); |
| 255 | }, 1500); |
| 256 | } |
| 257 | |
| 258 | /* ── Destination selector — modal folder browser ────────────────────── |
| 259 | * |
| 260 | * Opens an overlay that browses the console filesystem via /api/list. |
| 261 | * Only directories are shown; clicking one navigates into it. |
| 262 | * "Select this folder" confirms the choice. |
| 263 | * |
| 264 | * ┌──────────────────────────────────────────────┐ |
| 265 | * │ Choose download destination [✕] │ |
| 266 | * │ ─────────────────────────────────────────── │ |
| 267 | * │ / > data > user │ |
| 268 | * │ [↑ Back] │ |
| 269 | * │ ┌──────────────────────────────┐ │ |
| 270 | * │ │ 📁 folder_a │ │ |
| 271 | * │ │ 📁 folder_b │ │ |
| 272 | * │ │ 📁 folder_c │ │ |
| 273 | * │ └──────────────────────────────┘ │ |
| 274 | * │ [ Select this folder ] │ |
| 275 | * └──────────────────────────────────────────────┘ |
| 276 | */ |
| 277 | dlm.selectDest = function () { |
| 278 | var browsePath = _destPath; |
| 279 | var overlay = D.createElement('div'); |
| 280 | overlay.className = 'dl-dest-overlay'; |
| 281 | overlay.style.cssText = 'position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.65);' + |
| 282 | 'display:flex;align-items:center;justify-content:center;animation:fadeIn .15s ease;'; |
| 283 | |
| 284 | var card = D.createElement('div'); |
| 285 | card.style.cssText = 'background:var(--sf);border:1px solid var(--bd2);border-radius:16px;' + |
| 286 | 'width:min(460px,90vw);max-height:70vh;display:flex;flex-direction:column;' + |
| 287 | 'box-shadow:0 32px 80px rgba(0,0,0,.6);overflow:hidden;'; |
| 288 | |
| 289 | /* Header */ |
| 290 | var hdr = D.createElement('div'); |
| 291 | hdr.style.cssText = 'display:flex;align-items:center;padding:16px 20px;' + |
| 292 | 'border-bottom:1px solid var(--bd);gap:10px;'; |
| 293 | hdr.innerHTML = '<div style="flex:1;font-weight:700;font-size:14px;color:var(--tx);">' + |
| 294 | 'Choose download destination</div>' + |
| 295 | '<button id="dl-dest-close" style="background:none;border:none;color:var(--tx3);' + |
| 296 | 'font-size:20px;cursor:pointer;padding:0 4px;line-height:1;">×</button>'; |
| 297 | |
| 298 | /* Breadcrumb bar */ |
| 299 | var bcBar = D.createElement('div'); |
| 300 | bcBar.style.cssText = 'padding:10px 20px 4px;font-size:11px;color:var(--tx3);' + |
| 301 | 'font-family:monospace;white-space:nowrap;overflow-x:auto;'; |
| 302 | |
| 303 | /* Body — folder list */ |
| 304 | var body = D.createElement('div'); |
| 305 | body.style.cssText = 'flex:1;overflow-y:auto;padding:8px 12px;min-height:120px;max-height:50vh;'; |
| 306 | |
| 307 | /* Footer — back, new folder, select */ |
| 308 | var ftr = D.createElement('div'); |
| 309 | ftr.style.cssText = 'padding:12px 20px;border-top:1px solid var(--bd);display:flex;gap:8px;'; |
| 310 | ftr.innerHTML = '<button id="dl-dest-up" class="btn" style="padding:6px 10px;font-size:11px;">' + |
| 311 | '↑ Back</button>' + |
| 312 | '<button id="dl-dest-mkdir" class="btn" style="padding:6px 10px;font-size:11px;">' + |
| 313 | '+ New Folder</button>' + |
| 314 | '<div style="flex:1;"></div>' + |
| 315 | '<button id="dl-dest-ok" class="btn" style="padding:8px 18px;font-size:12px;font-weight:700;' + |
| 316 | 'background:var(--ac);color:#fff;border:none;border-radius:8px;cursor:pointer;">' + |
| 317 | 'Select this folder</button>'; |
| 318 | |
| 319 | card.appendChild(hdr); |
| 320 | card.appendChild(bcBar); |
| 321 | card.appendChild(body); |
| 322 | card.appendChild(ftr); |
| 323 | overlay.appendChild(card); |
| 324 | D.body.appendChild(overlay); |
| 325 | |
| 326 | /* Close overlay */ |
| 327 | function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } |
| 328 | overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); |
| 329 | hdr.querySelector('#dl-dest-close').onclick = close; |
| 330 | |
| 331 | /* Navigate up */ |
| 332 | ftr.querySelector('#dl-dest-up').onclick = function () { |
| 333 | var parent = Z.parent(browsePath); |
| 334 | if (parent !== null) { browsePath = parent; loadDir(browsePath); } |
| 335 | }; |
| 336 | |
| 337 | /* Create new folder in current directory */ |
| 338 | ftr.querySelector('#dl-dest-mkdir').onclick = function () { |
| 339 | Z.modal.prompt('New Folder', '').then(function (name) { |
| 340 | if (!name) return; |
| 341 | Z.api.mkdir(browsePath, name).then(function () { |
| 342 | Z.toast('Folder created', 'ok'); |
| 343 | loadDir(browsePath); |
| 344 | }).catch(function (e) { |
| 345 | Z.toast('Failed: ' + e.message, 'er'); |
| 346 | }); |
| 347 | }); |
| 348 | }; |
| 349 | |
| 350 | /* Confirm selection */ |
| 351 | ftr.querySelector('#dl-dest-ok').onclick = function () { |
| 352 | _destPath = browsePath; |
| 353 | var el = $('dl-dest-path'); |
| 354 | if (el) el.textContent = _destPath; |
| 355 | close(); |
| 356 | }; |
| 357 | |
| 358 | /* Render breadcrumb */ |
| 359 | function renderBreadcrumb(path) { |
| 360 | var parts = path.split('/').filter(function (s) { return s.length > 0; }); |
| 361 | var html = '<span style="color:var(--ac);cursor:pointer;" data-path="/">/</span>'; |
| 362 | var cum = ''; |
| 363 | for (var i = 0; i < parts.length; i++) { |
| 364 | cum += '/' + parts[i]; |
| 365 | html += ' <span style="color:var(--tx3);">›</span> ' + |
| 366 | '<span style="color:var(--ac);cursor:pointer;" data-path="' + cum + '">' + |
| 367 | parts[i] + '</span>'; |
| 368 | } |
| 369 | bcBar.innerHTML = html; |
| 370 | /* Wire breadcrumb clicks */ |
| 371 | var spans = bcBar.querySelectorAll('span[data-path]'); |
| 372 | for (var s = 0; s < spans.length; s++) { |
| 373 | (function (sp) { |
| 374 | sp.onclick = function () { |
| 375 | browsePath = sp.getAttribute('data-path'); |
| 376 | loadDir(browsePath); |
| 377 | }; |
| 378 | })(spans[s]); |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | /* Load directory listing */ |
| 383 | function loadDir(path) { |
| 384 | renderBreadcrumb(path); |
| 385 | body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">Loading…</div>'; |
| 386 | Z.api.list(path).then(function (data) { |
| 387 | body.innerHTML = ''; |
| 388 | if (!data || !data.entries || !data.entries.length) { |
| 389 | body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">Empty directory</div>'; |
| 390 | return; |
| 391 | } |
| 392 | /* Show only directories, sorted alphabetically */ |
| 393 | var dirs = data.entries.filter(function (e) { return e.type === 'directory'; }); |
| 394 | dirs.sort(function (a, b) { return a.name.localeCompare(b.name); }); |
| 395 | |
| 396 | if (!dirs.length) { |
| 397 | body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--tx3);font-size:12px;">No subdirectories</div>'; |
| 398 | return; |
| 399 | } |
| 400 | |
| 401 | for (var i = 0; i < dirs.length; i++) { |
| 402 | (function (entry) { |
| 403 | var row = D.createElement('div'); |
| 404 | row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 12px;' + |
| 405 | 'border-radius:8px;cursor:pointer;color:var(--tx2);transition:all .1s;font-size:12px;'; |
| 406 | row.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + |
| 407 | 'stroke-width="2" style="flex-shrink:0;color:var(--ac);"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 ' + |
| 408 | '1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' + |
| 409 | '<span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' + |
| 410 | entry.name + '</span>' + |
| 411 | '<span style="color:var(--tx3);font-size:10px;">›</span>'; |
| 412 | row.onmouseenter = function () { row.style.background = 'var(--sf2)'; row.style.color = 'var(--tx)'; }; |
| 413 | row.onmouseleave = function () { row.style.background = 'none'; row.style.color = 'var(--tx2)'; }; |
| 414 | row.onclick = function () { |
| 415 | browsePath = Z.join(path, entry.name); |
| 416 | loadDir(browsePath); |
| 417 | }; |
| 418 | body.appendChild(row); |
| 419 | })(dirs[i]); |
| 420 | } |
| 421 | }).catch(function (err) { |
| 422 | body.innerHTML = '<div style="text-align:center;padding:24px;color:var(--er);font-size:12px;">' + |
| 423 | 'Error loading directory: ' + err.message + '</div>'; |
| 424 | }); |
| 425 | } |
| 426 | |
| 427 | loadDir(browsePath); |
| 428 | }; |
| 429 | |
| 430 | Z.downloadMgr = dlm; |
| 431 | |
| 432 | })(ZFTPD); |
| 433 |