'use strict'; const $ = (id) => document.getElementById(id); const NS = 'http://www.w3.org/2000/svg'; const UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; const ACTIVE = new Set(['downloading', 'preparing', 'extracting', 'converting', 'vimm_browser']); const LABELS = { queued: 'W kolejce', downloading: 'Pobieranie', paused: 'Wstrzymano', preparing: 'Przygotowanie', extracting: 'Rozpakowanie', converting: 'Konwersja', done: 'Gotowe', error: 'Błąd', cancelled: 'Anulowano', vimm_browser: 'Vimm' }; const S = { archiveId: '', archiveTitle: '', archiveFiles: [], selected: new Set(), jobs: [], filter: 'all', connected: false, hostRoot: '/xbox', magnetPreview: null, magnetFiles: [], magnetSelected: new Set(), magnetName: '', magnetTitle: '', magnetFormat: 'xex' }; function normalizeMagnet(raw) { return raw.trim() .replace(/^[\'"`]*\*{0,2}magnet\*{0,2}:\?/i, 'magnet:?') .replace(/&|&|&/gi, '&') .replace(/\\\s*/g, '') .replace(/\s+/g, '') .replace(/[\'"`]+$/g, ''); } function bytes(n) { n = Math.max(0, Number(n) || 0); let i = 0; while (n >= 1024 && i < UNITS.length - 1) { n /= 1024; i++; } return (i ? n.toFixed(1) : n.toFixed(0)) + ' ' + UNITS[i]; } function el(tag, cls, text) { const node = document.createElement(tag); if (cls) node.className = cls; if (text !== undefined && text !== null) node.textContent = text; return node; } function icon(name) { const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'icon'); svg.setAttribute('aria-hidden', 'true'); const use = document.createElementNS(NS, 'use'); use.setAttribute('href', '#i-' + name); svg.append(use); return svg; } function makeButton(label, glyph, cls = 'secondary') { const button = el('button', cls); button.type = 'button'; button.append(icon(glyph), document.createTextNode(label)); return button; } async function api(path, options = {}) { const response = await fetch('/api' + path, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); let result; try { result = await response.json(); } catch { result = { detail: 'Nieprawidłowa odpowiedź serwera' }; } if (!response.ok) { throw new Error(typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail || result)); } return result; } function connection(online) { S.connected = online; $('connectionState').classList.toggle('online', online); $('connectionState').classList.toggle('offline', !online); $('connectionText').textContent = online ? 'Połączono' : 'Brak połączenia'; } function flash(message, bad = false) { const t = $('toast'); t.textContent = message; t.classList.toggle('bad', bad); t.style.display = 'block'; clearTimeout(window._toastTimeout); window._toastTimeout = setTimeout(() => { t.style.display = 'none'; }, 5000); } function filterJobs(jobs) { if (S.filter === 'active') return jobs.filter(j => !['done', 'error', 'cancelled'].includes(j.state)); if (S.filter === 'done') return jobs.filter(j => j.state === 'done'); if (S.filter === 'error') return jobs.filter(j => j.state === 'error'); return jobs; } function sourceDescription(job) { if (job.source_type === 'archive_many') { try { return 'Internet Archive · ' + JSON.parse(job.source).length + ' plików'; } catch { return 'Internet Archive'; } } if (job.source_type === 'import') return 'Import · ' + job.source; if (job.source_type === 'magnet') return 'Magnet'; if (job.source_type === 'torrent_selected') { try { return 'Torrent · ' + JSON.parse(job.source).count + ' plików'; } catch { return 'Torrent'; } } if (job.source_type === 'vimm') return 'Vimm · ' + job.source; return job.source; } function jobAction(label, glyph, action, job, danger = false) { const button = makeButton(label, glyph, 'action' + (danger ? ' danger' : '')); button.addEventListener('click', async () => { if (action === 'remove' && !confirm('Usunąć wpis? Pobrane pliki zostaną na dysku.')) return; if (action === 'cancel' && !confirm('Anulować to zadanie?')) return; button.disabled = true; try { await api('/jobs/' + encodeURIComponent(job.id) + '/' + action, { method: 'POST' }); await refreshJobs(); } catch (e) { flash(e.message, true); } finally { button.disabled = false; } }); return button; } async function showLog(job) { try { const response = await fetch('/api/jobs/' + encodeURIComponent(job.id) + '/log'); if (!response.ok) throw new Error('HTTP ' + response.status); $('logContent').textContent = await response.text(); $('logDialog').showModal(); } catch (e) { flash(e.message, true); } } function hostPath(path) { if (typeof path !== 'string') return ''; if (path === '/data/raw') return S.hostRoot + '/downloads'; if (path.startsWith('/data/raw/')) return S.hostRoot + '/downloads/' + path.slice('/data/raw/'.length); if (path === '/data') return S.hostRoot; if (path.startsWith('/data/')) return S.hostRoot + '/' + path.slice('/data/'.length); return path; } function jobRow(job) { const card = el('article', 'job'); const header = el('div', 'job-top'); const heading = el('div', 'job-heading'); heading.append(el('div', 'job-name', job.title), el('div', 'job-sub', sourceDescription(job))); header.append(heading); const statusClass = job.state === 'done' ? 'done' : job.state === 'error' ? 'error' : ACTIVE.has(job.state) ? 'running' : ''; header.append(el('span', 'job-status ' + statusClass, LABELS[job.state] || job.state)); card.append(header); const pct = Math.max(0, Math.min(100, Number(job.progress) || 0)); const unknown = job.state === 'vimm_browser'; const progress = el('div', 'job-progress'); const track = el('div', 'progressbar' + (job.state === 'error' ? ' is-error' : '')); const fill = el('span'); fill.style.width = pct + '%'; track.append(fill); progress.append(track, el('span', 'progress-text', unknown ? '—' : pct.toFixed(1) + '%')); card.append(progress); const meta = el('div', 'job-meta'); meta.append(el('span', 'note-text', job.note || 'Oczekiwanie')); meta.append(el('span', 'speed', Number(job.speed) > 0 ? bytes(job.speed) + '/s' : '')); card.append(meta); if (job.state === 'error' && job.error) card.append(el('div', 'job-error', job.error)); if (job.output) card.append(el('div', 'job-output', 'Zapisano: ' + hostPath(job.output))); const bottom = el('div', 'job-bottom'); const chips = el('div', 'job-chips'); chips.append(el('span', 'job-chip', (job.format || 'raw').toUpperCase())); bottom.append(chips); const actions = el('div', 'job-actions'); if (['queued', 'downloading'].includes(job.state)) { actions.append(jobAction('Pauza', 'pause', 'pause', job), jobAction('Anuluj', 'x', 'cancel', job, true)); } if (job.state === 'paused') { actions.append(jobAction('Wznów', 'play', 'resume', job), jobAction('Anuluj', 'x', 'cancel', job, true)); } if (['error', 'cancelled'].includes(job.state)) actions.append(jobAction('Ponów', 'retry', 'retry', job)); if (['error', 'done', 'cancelled', 'paused'].includes(job.state)) actions.append(jobAction('Usuń', 'trash', 'remove', job, true)); const log = makeButton('Log', 'file', 'action'); log.addEventListener('click', () => showLog(job)); actions.append(log); bottom.append(actions); card.append(bottom); return card; } function renderJobs() { $('jobsCount').textContent = String(S.jobs.length); $('countQueued').textContent = String(S.jobs.filter(j => j.state === 'queued' || j.state === 'paused').length); $('countActive').textContent = String(S.jobs.filter(j => ACTIVE.has(j.state)).length); $('countDone').textContent = String(S.jobs.filter(j => j.state === 'done').length); const list = $('jobsList'); list.replaceChildren(); const filtered = filterJobs(S.jobs); if (!filtered.length) { list.append(el('div', 'empty-state', S.filter === 'all' ? 'Brak zadań. Dodaj link powyżej.' : 'Brak zadań w tym widoku.')); return; } for (const job of filtered) list.append(jobRow(job)); } async function refreshJobs(showError = false) { try { S.jobs = await api('/jobs'); renderJobs(); connection(true); } catch (e) { connection(false); if (showError) flash('Nie można odczytać kolejki: ' + e.message, true); } } async function refreshSystem() { try { const info = await api('/system'); $('storageNum').textContent = bytes(info.free); const percent = info.total > 0 ? (100 * (1 - info.free / info.total)) : 0; $('storageFill').style.width = Math.max(0, Math.min(100, percent)) + '%'; $('storageText').textContent = bytes(info.total) + ' łącznie'; const hostRoot = String(info.host_data_dir || '/xbox').replace(/\/+$/, ''); S.hostRoot = hostRoot; $('importHostPath').textContent = hostRoot + '/imports/'; $('readyXexPath').textContent = hostRoot + '/ready/xex/'; $('readyGodPath').textContent = hostRoot + '/ready/god/'; $('downloadHostPath').textContent = hostRoot + '/downloads/'; $('workHostPath').textContent = hostRoot + '/work/'; connection(true); } catch { $('storageText').textContent = 'Nie udało się odczytać dysku'; connection(false); } } for (const tab of document.querySelectorAll('.tab')) { tab.addEventListener('click', () => { for (const other of document.querySelectorAll('.tab')) { const selected = other === tab; other.classList.toggle('active', selected); other.setAttribute('aria-selected', String(selected)); } for (const name of ['links', 'archive', 'import']) { $('pane-' + name).classList.toggle('hidden', name !== tab.dataset.tab); } if (tab.dataset.tab === 'import') refreshImports(); }); } for (const filter of document.querySelectorAll('.filter')) { filter.addEventListener('click', () => { S.filter = filter.dataset.filter; for (const other of document.querySelectorAll('.filter')) { const selected = other === filter; other.classList.toggle('active', selected); other.setAttribute('aria-pressed', String(selected)); } renderJobs(); }); } async function refreshArchiveSession() { try { const result = await api('/archive/session'); $('archiveSessionText').textContent = result.authenticated ? ('Sesja zapisana · ' + result.email) : 'Niezalogowano'; $('archiveLoginForm').classList.toggle('hidden', result.authenticated); $('archiveLogout').classList.toggle('hidden', !result.authenticated); } catch (e) { $('archiveSessionText').textContent = 'Błąd sprawdzania sesji'; } } $('archiveLoginForm').addEventListener('submit', async (event) => { event.preventDefault(); const button = $('archiveLoginForm').querySelector('[type=submit]'); button.disabled = true; try { const result = await api('/archive/session', { method: 'POST', body: JSON.stringify({ email: $('archiveEmail').value.trim(), password: $('archivePassword').value }) }); $('archivePassword').value = ''; flash('Zapisano sesję Archive.org: ' + result.email); await refreshArchiveSession(); } catch (e) { $('archivePassword').value = ''; flash('Archive.org: ' + e.message, true); } finally { button.disabled = false; } }); $('archiveLogout').addEventListener('click', async () => { try { await api('/archive/session', { method: 'DELETE' }); await refreshArchiveSession(); flash('Usunięto zapisaną sesję Archive.org.'); } catch (e) { flash(e.message, true); } }); function renderMagnet() { const list = $('magnetFiles'); list.replaceChildren(); const text = $('magnetFilter').value.toLowerCase(); let displayed = 0; for (const file of S.magnetFiles) { if (!file.name.toLowerCase().includes(text)) continue; if (displayed >= 500) break; displayed++; const row = el('label', 'file-row'); const box = el('input'); box.type = 'checkbox'; box.checked = S.magnetSelected.has(file.index); box.disabled = Boolean(file.padding); box.addEventListener('change', () => { if (box.checked) S.magnetSelected.add(file.index); else S.magnetSelected.delete(file.index); $('magnetSelectedCount').textContent = 'Wybrano: ' + S.magnetSelected.size; }); row.append(box, el('span', 'file-name', file.name), el('span', 'size', file.padding ? 'Systemowy' : bytes(file.size))); list.append(row); } if (!displayed) list.append(el('div', 'empty-state', 'Brak plików.')); if (displayed >= 500) list.append(el('div', 'empty-state', 'Pierwsze 500 plików. Zawęź filtr, aby wyszukać kolejne.')); $('magnetSelectedCount').textContent = 'Wybrano: ' + S.magnetSelected.size; } async function pollMagnet(previewId) { if (S.magnetPreview !== previewId || !$('magnetDialog').open) return; try { const data = await api('/magnet/inspect/' + encodeURIComponent(previewId)); if (S.magnetPreview !== previewId || !$('magnetDialog').open) return; if (data.status === 'ready') { S.magnetName = data.name; S.magnetFiles = data.files; S.magnetSelected = new Set((data.suggested || []).filter(i => !data.files.find(f => f.index === i)?.padding)); $('magnetName').textContent = data.name + ' (' + data.files.length + ' plików)'; $('magnetStatus').textContent = 'Metadane pobrane. Wybierz pliki.'; $('magnetResults').classList.remove('hidden'); renderMagnet(); return; } if (data.status === 'loading') { const seconds = Number(data.elapsed) || 0; const connections = Number(data.connections) || 0; const known = Number(data.known_peers) || 0; let notice = 'Metadane: ' + seconds + ' s | połączenia: ' + connections + ' | znani peerzy: ' + known; if (seconds >= 25 && connections === 0) notice += ' | Brak połączeń. Sprawdź UDP/DHT lub wczytaj .torrent.'; $('magnetStatus').textContent = notice; } if (data.status === 'error') { $('magnetStatus').textContent = 'Błąd: ' + data.error; return; } } catch (e) { $('magnetStatus').textContent = 'Błąd: ' + e.message; return; } setTimeout(() => pollMagnet(previewId), 3000); } async function inspectMagnet(magnet, title, format) { S.magnetPreview = null; S.magnetTitle = title; S.magnetFormat = format; S.magnetFiles = []; S.magnetSelected.clear(); $('magnetStatus').textContent = 'Oczekiwanie na metadane z sieci BitTorrent…'; $('magnetResults').classList.add('hidden'); $('magnetFilter').value = ''; $('magnetDialog').showModal(); try { const data = await api('/magnet/inspect', { method: 'POST', body: JSON.stringify({ magnet }) }); if (!$('magnetDialog').open) return; S.magnetPreview = data.preview_id; pollMagnet(data.preview_id); } catch (e) { $('magnetStatus').textContent = 'Błąd: ' + e.message; } } async function uploadTorrent(file, fromLinks) { if (!file) return; if (file.size > 32 * 1024 * 1024) return flash('Limit pliku .torrent: 32 MiB.', true); if (fromLinks) { S.magnetTitle = $('linkTitle').value.trim(); S.magnetFormat = $('linkFormat').value; } const previous = S.magnetPreview; S.magnetPreview = null; S.magnetFiles = []; S.magnetSelected.clear(); $('magnetResults').classList.add('hidden'); $('magnetFilter').value = ''; $('magnetStatus').textContent = 'Wczytywanie pliku .torrent...'; if (!$('magnetDialog').open) $('magnetDialog').showModal(); if (previous) await api('/magnet/inspect/' + encodeURIComponent(previous) + '/cancel', { method: 'POST' }).catch(() => {}); try { const response = await fetch('/api/magnet/upload', { method: 'POST', headers: { 'Content-Type': 'application/x-bittorrent' }, body: file }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || 'HTTP ' + response.status); if (!$('magnetDialog').open) return; S.magnetPreview = data.preview_id; await pollMagnet(data.preview_id); } catch (error) { $('magnetStatus').textContent = 'Błąd: ' + error.message; } } for (const id of ['torrentFileLinks', 'torrentFileDialog']) { $(id).addEventListener('change', async (event) => { const file = event.target.files?.[0]; event.target.value = ''; await uploadTorrent(file, id === 'torrentFileLinks'); }); } $('magnetFilter').addEventListener('input', renderMagnet); $('magnetToggleAll').addEventListener('click', () => { const visible = S.magnetFiles.filter(f => !f.padding && f.name.toLowerCase().includes($('magnetFilter').value.toLowerCase())).slice(0, 500); const allSelected = visible.every(f => S.magnetSelected.has(f.index)); for (const file of visible) { if (allSelected) S.magnetSelected.delete(file.index); else S.magnetSelected.add(file.index); } renderMagnet(); }); $('closeMagnet').addEventListener('click', () => $('magnetDialog').close()); $('magnetDialog').addEventListener('close', () => { const preview = S.magnetPreview; S.magnetPreview = null; if (preview) api('/magnet/inspect/' + encodeURIComponent(preview) + '/cancel', { method: 'POST' }).catch(() => {}); }); $('magnetAdd').addEventListener('click', async () => { if (!S.magnetSelected.size) return flash('Zaznacz co najmniej jeden plik.', true); const button = $('magnetAdd'); button.disabled = true; try { await api('/magnet/jobs', { method: 'POST', body: JSON.stringify({ preview_id: S.magnetPreview, files: [...S.magnetSelected], title: S.magnetTitle, format: S.magnetFormat }) }); $('urls').value = ''; S.magnetPreview = null; $('magnetDialog').close(); flash('Wybrane pliki dodano do kolejki.'); await refreshJobs(); } catch (e) { flash(e.message, true); } finally { button.disabled = false; } }); $('linksForm').addEventListener('submit', async (event) => { event.preventDefault(); const raw = $('urls').value.trim(); if (!raw) return flash('Podaj co najmniej jeden link.', true); const button = $('linksForm').querySelector('[type=submit]'); const title = $('linkTitle').value.trim(); const format = $('linkFormat').value; if (/^[\'"`]*\*{0,2}magnet\*{0,2}:\?/i.test(raw)) { const magnet = normalizeMagnet(raw); if ((magnet.match(/magnet:\?/gi) || []).length !== 1) return flash('Magnet wklej pojedynczo, aby wybrać pliki.', true); await inspectMagnet(magnet, title, format); return; } const items = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean); let ok = 0; const errors = []; button.disabled = true; try { for (const url of items) { try { await api('/jobs', { method: 'POST', body: JSON.stringify({ url, title, format }) }); ok++; } catch (e) { errors.push(url.slice(0, 65) + ': ' + e.message); } } if (ok) { $('urls').value = ''; flash('Dodano zadań: ' + ok); await refreshJobs(); } if (errors.length) flash(errors.join('\n'), true); } finally { button.disabled = false; } }); $('archiveForm').addEventListener('submit', async (event) => { event.preventDefault(); const button = $('archiveForm').querySelector('[type=submit]'); button.disabled = true; $('archiveResults').classList.add('hidden'); try { const data = await api('/archive/lookup', { method: 'POST', body: JSON.stringify({ identifier: $('archiveId').value }) }); S.archiveId = data.identifier; S.archiveTitle = data.title; S.archiveFiles = data.files; S.selected.clear(); $('archiveName').textContent = data.title + ' (' + data.files.length + ')'; $('archiveResults').classList.remove('hidden'); renderArchive(); } catch (e) { flash('Internet Archive: ' + e.message, true); } finally { button.disabled = false; } }); function renderArchive() { const list = $('archiveFiles'); list.replaceChildren(); const text = $('archiveFilter').value.toLowerCase(); let displayed = 0; for (const file of S.archiveFiles) { if (!file.name.toLowerCase().includes(text)) continue; if (displayed >= 400) break; displayed++; const row = el('label', 'file-row'); const checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.checked = S.selected.has(file.name); checkbox.addEventListener('change', () => { if (checkbox.checked) S.selected.add(file.name); else S.selected.delete(file.name); $('selectedCount').textContent = 'Wybrano: ' + S.selected.size; }); row.append(checkbox, el('span', 'file-name', file.name), el('span', 'size', bytes(file.size))); list.append(row); } if (!displayed) list.append(el('div', 'empty-state', 'Brak plików.')); $('selectedCount').textContent = 'Wybrano: ' + S.selected.size; } $('archiveFilter').addEventListener('input', renderArchive); $('archiveAdd').addEventListener('click', async () => { if (!S.selected.size) return flash('Zaznacz pliki.', true); const button = $('archiveAdd'); button.disabled = true; try { await api('/archive/jobs', { method: 'POST', body: JSON.stringify({ identifier: S.archiveId, files: [...S.selected], title: S.archiveTitle, format: $('archiveFormat').value }) }); S.selected.clear(); renderArchive(); flash('Dodano do kolejki.'); await refreshJobs(); } catch (e) { flash(e.message, true); } finally { button.disabled = false; } }); async function refreshImports() { try { const files = await api('/imports'); const list = $('importList'); list.replaceChildren(); if (!files.length) { list.append(el('div', 'empty-state', 'Katalog data/imports/ jest pusty.')); return; } for (const file of files) { const row = el('div', 'file-row'); row.append(icon('file'), el('span', 'file-name', file.name), el('span', 'size', bytes(file.size))); const button = makeButton('Przetwórz', 'play'); button.addEventListener('click', async () => { button.disabled = true; try { await api('/imports/jobs', { method: 'POST', body: JSON.stringify({ filename: file.name, format: $('importFormat').value }) }); flash('Dodano: ' + file.name); await refreshJobs(); } catch (e) { flash(e.message, true); } finally { button.disabled = false; } }); row.append(button); list.append(row); } } catch (e) { flash('Import: ' + e.message, true); } } $('refreshImports').addEventListener('click', refreshImports); $('refreshJobs').addEventListener('click', () => refreshJobs(true)); $('closeLog').addEventListener('click', () => $('logDialog').close()); refreshJobs(); refreshSystem(); refreshArchiveSession(); setInterval(() => refreshJobs(), 3000); setInterval(refreshSystem, 30000);