Files
x360-web-queue/app/static/app.js
T
2026-10-15 14:15:10 +02:00

578 lines
22 KiB
JavaScript

'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);