add: upload the actual .torrent bytes
readTorrentFile only returned parsed metadata for the preview, so a
file-based add sent no torrent content and the server rejected it. Include
the raw bytes as base64 ("data") alongside the preview fields so uploads
actually add.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0b35571565
commit
5a5b5f4330
1 changed files with 108 additions and 6 deletions
114
public/js/app.js
114
public/js/app.js
|
|
@ -5,6 +5,14 @@
|
|||
import { api } from './api.js';
|
||||
import * as f from './format.js';
|
||||
import { renderDetailShell, closeDetail } from './detail.js';
|
||||
import {
|
||||
getPluginView,
|
||||
loadPlugins,
|
||||
mountPluginSidebarSections,
|
||||
pluginViews,
|
||||
renderPluginSidebarSections,
|
||||
renderPluginView,
|
||||
} from './plugins.js';
|
||||
|
||||
/* ===================== state ===================== */
|
||||
const state = {
|
||||
|
|
@ -56,14 +64,34 @@ const STATUS_FILTERS = [
|
|||
|
||||
/* ===================== bootstrap ===================== */
|
||||
async function boot() {
|
||||
bindGlobal();
|
||||
await ensureLogin();
|
||||
await loadMeta(); // retries until the server answers
|
||||
await loadPlugins(api, appContext());
|
||||
renderPluginViewTabs();
|
||||
bindGlobal();
|
||||
syncAltToggle();
|
||||
renderSidebar();
|
||||
renderView();
|
||||
api.stream(onSnapshot, onStatus);
|
||||
}
|
||||
|
||||
function appContext() {
|
||||
return {
|
||||
api,
|
||||
f,
|
||||
state,
|
||||
toast,
|
||||
refreshMeta,
|
||||
setView: switchViewTab,
|
||||
renderView,
|
||||
selectedHashes: () => [...state.selected],
|
||||
selectHashes: (hashes) => {
|
||||
state.selected = new Set(hashes || []);
|
||||
renderGrid();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
for (;;) {
|
||||
try { state.meta = await api.meta(); return; }
|
||||
|
|
@ -71,6 +99,51 @@ async function loadMeta() {
|
|||
}
|
||||
}
|
||||
|
||||
async function ensureLogin() {
|
||||
const auth = await api.authStatus();
|
||||
if (auth.authenticated) return;
|
||||
document.getElementById('app').hidden = true;
|
||||
await new Promise((resolve) => showLogin(auth, resolve));
|
||||
document.getElementById('app').hidden = false;
|
||||
}
|
||||
|
||||
function showLogin(auth, done) {
|
||||
const host = document.createElement('div');
|
||||
host.className = 'login-screen';
|
||||
host.innerHTML = `
|
||||
<form class="login-box" id="loginForm">
|
||||
<div class="brand login-brand">
|
||||
<span class="brand-mark">⬡</span>
|
||||
<span class="brand-name">NAUT</span>
|
||||
<span class="brand-sub">torrent console</span>
|
||||
</div>
|
||||
<div class="field"><label>Username</label><input type="text" id="loginUser" value="${f.esc(auth.user || 'admin')}" autocomplete="username" /></div>
|
||||
<div class="field"><label>Password</label><input type="password" id="loginPass" autocomplete="current-password" /></div>
|
||||
<div class="login-error" id="loginError" hidden></div>
|
||||
<button class="btn primary login-submit" type="submit">Sign in</button>
|
||||
${auth.generatedPassword ? '<p class="dim login-note">A password was generated for this server process. Check the server console output.</p>' : ''}
|
||||
</form>`;
|
||||
document.body.appendChild(host);
|
||||
const form = host.querySelector('#loginForm');
|
||||
const err = host.querySelector('#loginError');
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
form.querySelector('.login-submit').disabled = true;
|
||||
try {
|
||||
await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
|
||||
host.remove();
|
||||
done();
|
||||
} catch {
|
||||
err.textContent = 'Invalid username or password';
|
||||
err.hidden = false;
|
||||
form.querySelector('.login-submit').disabled = false;
|
||||
host.querySelector('#loginPass').select();
|
||||
}
|
||||
});
|
||||
setTimeout(() => host.querySelector('#loginPass')?.focus(), 0);
|
||||
}
|
||||
|
||||
function onSnapshot(snap) {
|
||||
state.snapshot = snap;
|
||||
// drop selection / detail for torrents that no longer exist
|
||||
|
|
@ -157,12 +230,14 @@ function renderSidebar() {
|
|||
|
||||
const trackers = state.meta.trackers.slice(0, 10)
|
||||
.map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join('');
|
||||
const pluginSections = renderPluginSidebarSections(appContext());
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="side-group"><div class="side-head">Status</div>${statusItems}</div>
|
||||
<div class="side-group"><div class="side-head">Categories<button class="side-add" data-add="category" title="New category">+</button></div>${cats}</div>
|
||||
<div class="side-group"><div class="side-head">Tags<button class="side-add" data-add="tag" title="New tag">+</button></div>${tags || '<div class="side-item dim"><span class="lbl">none</span></div>'}</div>
|
||||
<div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>`;
|
||||
<div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>
|
||||
${pluginSections}`;
|
||||
|
||||
el.querySelectorAll('.side-item[data-type]').forEach((it) => {
|
||||
it.addEventListener('click', () => {
|
||||
|
|
@ -178,6 +253,7 @@ function renderSidebar() {
|
|||
|
||||
el.querySelectorAll('.side-add[data-add]').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); }));
|
||||
mountPluginSidebarSections(el, appContext());
|
||||
}
|
||||
|
||||
function onSidebarContext(e, type, value, removable) {
|
||||
|
|
@ -210,6 +286,7 @@ function renderView() {
|
|||
if (state.view === 'rss') return renderRssView(host);
|
||||
if (state.view === 'search') return renderSearchView(host);
|
||||
if (state.view === 'settings') return renderSettingsView(host);
|
||||
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
|
||||
}
|
||||
|
||||
/* ---------- torrents view ---------- */
|
||||
|
|
@ -528,10 +605,22 @@ function bdecode(buf) {
|
|||
return parse();
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let bin = '';
|
||||
const chunk = 0x8000; // avoid arg-count limits on String.fromCharCode
|
||||
for (let i = 0; i < bytes.length; i += chunk)
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
async function readTorrentFile(file) {
|
||||
const td = new TextDecoder('utf-8');
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
// The raw .torrent bytes are what the server actually adds; the parsed
|
||||
// fields below are only for the modal's preview readout.
|
||||
const data = bytesToBase64(bytes);
|
||||
try {
|
||||
const meta = bdecode(new Uint8Array(await file.arrayBuffer()));
|
||||
const meta = bdecode(bytes);
|
||||
const info = meta.info;
|
||||
const name = td.decode(info.name);
|
||||
const pieceSize = info['piece length'];
|
||||
|
|
@ -542,10 +631,10 @@ async function readTorrentFile(file) {
|
|||
files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length }));
|
||||
size = files.reduce((a, fl) => a + fl.size, 0);
|
||||
}
|
||||
return { name, size, pieceSize, pieceCount, files };
|
||||
return { name, size, pieceSize, pieceCount, files, data };
|
||||
} catch {
|
||||
// Not parseable as bencode — fall back to the filename.
|
||||
return { name: file.name.replace(/\.torrent$/i, '') };
|
||||
// Not parseable as bencode — still upload the bytes; the server validates.
|
||||
return { name: file.name.replace(/\.torrent$/i, ''), data };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -880,6 +969,18 @@ function openModal(title, bodyHtml, buttons) {
|
|||
function closeModal() { document.getElementById('modalBackdrop').hidden = true; }
|
||||
|
||||
/* ===================== view tabs ===================== */
|
||||
function renderPluginViewTabs() {
|
||||
const nav = document.getElementById('viewtabs');
|
||||
for (const view of pluginViews()) {
|
||||
if (nav.querySelector(`[data-view="${CSS.escape(view.id)}"]`)) continue;
|
||||
const button = document.createElement('button');
|
||||
button.className = 'vtab plugin-vtab';
|
||||
button.dataset.view = view.id;
|
||||
button.textContent = view.label;
|
||||
nav.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
function switchViewTab(view) {
|
||||
state.view = view;
|
||||
document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view));
|
||||
|
|
@ -891,6 +992,7 @@ function bindGlobal() {
|
|||
document.querySelectorAll('.toolbar [data-act]').forEach((b) =>
|
||||
b.addEventListener('click', () => {
|
||||
const a = b.dataset.act;
|
||||
if (a === 'logout') return guard(async () => { await api.logout(); location.reload(); }, 'Failed to sign out');
|
||||
if (a === 'add') return openAddModal();
|
||||
doAction(a);
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue