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 { api } from './api.js';
|
||||||
import * as f from './format.js';
|
import * as f from './format.js';
|
||||||
import { renderDetailShell, closeDetail } from './detail.js';
|
import { renderDetailShell, closeDetail } from './detail.js';
|
||||||
|
import {
|
||||||
|
getPluginView,
|
||||||
|
loadPlugins,
|
||||||
|
mountPluginSidebarSections,
|
||||||
|
pluginViews,
|
||||||
|
renderPluginSidebarSections,
|
||||||
|
renderPluginView,
|
||||||
|
} from './plugins.js';
|
||||||
|
|
||||||
/* ===================== state ===================== */
|
/* ===================== state ===================== */
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -56,14 +64,34 @@ const STATUS_FILTERS = [
|
||||||
|
|
||||||
/* ===================== bootstrap ===================== */
|
/* ===================== bootstrap ===================== */
|
||||||
async function boot() {
|
async function boot() {
|
||||||
bindGlobal();
|
await ensureLogin();
|
||||||
await loadMeta(); // retries until the server answers
|
await loadMeta(); // retries until the server answers
|
||||||
|
await loadPlugins(api, appContext());
|
||||||
|
renderPluginViewTabs();
|
||||||
|
bindGlobal();
|
||||||
syncAltToggle();
|
syncAltToggle();
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
renderView();
|
renderView();
|
||||||
api.stream(onSnapshot, onStatus);
|
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() {
|
async function loadMeta() {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try { state.meta = await api.meta(); return; }
|
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) {
|
function onSnapshot(snap) {
|
||||||
state.snapshot = snap;
|
state.snapshot = snap;
|
||||||
// drop selection / detail for torrents that no longer exist
|
// drop selection / detail for torrents that no longer exist
|
||||||
|
|
@ -157,12 +230,14 @@ function renderSidebar() {
|
||||||
|
|
||||||
const trackers = state.meta.trackers.slice(0, 10)
|
const trackers = state.meta.trackers.slice(0, 10)
|
||||||
.map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join('');
|
.map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join('');
|
||||||
|
const pluginSections = renderPluginSidebarSections(appContext());
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="side-group"><div class="side-head">Status</div>${statusItems}</div>
|
<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">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">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) => {
|
el.querySelectorAll('.side-item[data-type]').forEach((it) => {
|
||||||
it.addEventListener('click', () => {
|
it.addEventListener('click', () => {
|
||||||
|
|
@ -178,6 +253,7 @@ function renderSidebar() {
|
||||||
|
|
||||||
el.querySelectorAll('.side-add[data-add]').forEach((b) =>
|
el.querySelectorAll('.side-add[data-add]').forEach((b) =>
|
||||||
b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); }));
|
b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); }));
|
||||||
|
mountPluginSidebarSections(el, appContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSidebarContext(e, type, value, removable) {
|
function onSidebarContext(e, type, value, removable) {
|
||||||
|
|
@ -210,6 +286,7 @@ function renderView() {
|
||||||
if (state.view === 'rss') return renderRssView(host);
|
if (state.view === 'rss') return renderRssView(host);
|
||||||
if (state.view === 'search') return renderSearchView(host);
|
if (state.view === 'search') return renderSearchView(host);
|
||||||
if (state.view === 'settings') return renderSettingsView(host);
|
if (state.view === 'settings') return renderSettingsView(host);
|
||||||
|
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- torrents view ---------- */
|
/* ---------- torrents view ---------- */
|
||||||
|
|
@ -528,10 +605,22 @@ function bdecode(buf) {
|
||||||
return parse();
|
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) {
|
async function readTorrentFile(file) {
|
||||||
const td = new TextDecoder('utf-8');
|
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 {
|
try {
|
||||||
const meta = bdecode(new Uint8Array(await file.arrayBuffer()));
|
const meta = bdecode(bytes);
|
||||||
const info = meta.info;
|
const info = meta.info;
|
||||||
const name = td.decode(info.name);
|
const name = td.decode(info.name);
|
||||||
const pieceSize = info['piece length'];
|
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 }));
|
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);
|
size = files.reduce((a, fl) => a + fl.size, 0);
|
||||||
}
|
}
|
||||||
return { name, size, pieceSize, pieceCount, files };
|
return { name, size, pieceSize, pieceCount, files, data };
|
||||||
} catch {
|
} catch {
|
||||||
// Not parseable as bencode — fall back to the filename.
|
// Not parseable as bencode — still upload the bytes; the server validates.
|
||||||
return { name: file.name.replace(/\.torrent$/i, '') };
|
return { name: file.name.replace(/\.torrent$/i, ''), data };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -880,6 +969,18 @@ function openModal(title, bodyHtml, buttons) {
|
||||||
function closeModal() { document.getElementById('modalBackdrop').hidden = true; }
|
function closeModal() { document.getElementById('modalBackdrop').hidden = true; }
|
||||||
|
|
||||||
/* ===================== view tabs ===================== */
|
/* ===================== 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) {
|
function switchViewTab(view) {
|
||||||
state.view = view;
|
state.view = view;
|
||||||
document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.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) =>
|
document.querySelectorAll('.toolbar [data-act]').forEach((b) =>
|
||||||
b.addEventListener('click', () => {
|
b.addEventListener('click', () => {
|
||||||
const a = b.dataset.act;
|
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();
|
if (a === 'add') return openAddModal();
|
||||||
doAction(a);
|
doAction(a);
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue