Send UI downloads into Movies library and harden queue API.
UI grabs write Movies/<Title (Year)>/; chown to 1000:1000; CORS and safer /api/queue handling for the Web UI.
This commit is contained in:
@@ -20,3 +20,8 @@ WEBSHARE_PASSWORD=
|
|||||||
# DOWNLOAD_PATH=/downloads/webshare
|
# DOWNLOAD_PATH=/downloads/webshare
|
||||||
# MEDIA_ROOT=/data
|
# MEDIA_ROOT=/data
|
||||||
# MAX_CONCURRENT_DOWNLOADS=2
|
# MAX_CONCURRENT_DOWNLOADS=2
|
||||||
|
|
||||||
|
# UI downloads → Jellyfin Movies library (container path)
|
||||||
|
# MOVIES_PATH=/data/Movies
|
||||||
|
# DOWNLOAD_UID=1000
|
||||||
|
# DOWNLOAD_GID=1000
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ Environment variables (required unless noted):
|
|||||||
| `BASE_URL` | `http://localhost:$PORT` | Public base URL Sonarr must reach (use the Compose service hostname when linking containers) |
|
| `BASE_URL` | `http://localhost:$PORT` | Public base URL Sonarr must reach (use the Compose service hostname when linking containers) |
|
||||||
| `DOWNLOAD_PATH` | `/downloads/webshare` | Where the fake qBittorrent writes files |
|
| `DOWNLOAD_PATH` | `/downloads/webshare` | Where the fake qBittorrent writes files |
|
||||||
| `MEDIA_ROOT` | `/data` | Media root (used for path reporting) |
|
| `MEDIA_ROOT` | `/data` | Media root (used for path reporting) |
|
||||||
|
| `MOVIES_PATH` | `$MEDIA_ROOT/Movies` | UI / direct grabs write into this library tree (Jellyfin folders) |
|
||||||
|
| `DOWNLOAD_UID` / `DOWNLOAD_GID` | `1000` | chown finished files when the container runs as root |
|
||||||
|
| `MOVIES_PATH` | `$MEDIA_ROOT/Movies` | UI / direct grabs write into this library tree (Jellyfin folders) |
|
||||||
|
| `DOWNLOAD_UID` / `DOWNLOAD_GID` | `1000` | chown finished files when container runs as root |
|
||||||
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Parallel stream downloads |
|
| `MAX_CONCURRENT_DOWNLOADS` | `2` | Parallel stream downloads |
|
||||||
| `SONARR_URL` | `http://sonarr:8989` | Optional; Sonarr base URL |
|
| `SONARR_URL` | `http://sonarr:8989` | Optional; Sonarr base URL |
|
||||||
| `SONARR_API_KEY` | _(empty)_ | Optional; enables periodic missing-episode search |
|
| `SONARR_API_KEY` | _(empty)_ | Optional; enables periodic missing-episode search |
|
||||||
|
|||||||
49
src/index.js
49
src/index.js
@@ -17,6 +17,15 @@ const MCP_ENABLED = process.env.MCP_ENABLED !== '0' && process.env.MCP_ENABLED !
|
|||||||
const client = new WebshareClient(USERNAME, PASSWORD);
|
const client = new WebshareClient(USERNAME, PASSWORD);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// UI may be opened from LAN hostnames; allow simple cross-origin reads of JSON APIs.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
app.use(express.json({ limit: '4mb' }));
|
app.use(express.json({ limit: '4mb' }));
|
||||||
|
|
||||||
@@ -114,15 +123,7 @@ app.get('/resolve/:ident', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── UI static files (dev: ../ui from src/; production: dist/ui next to server.js)
|
// ── UI API (before static so /api/* is never swallowed) ───────────────────
|
||||||
const uiRoot =
|
|
||||||
process.env.UI_DIR ||
|
|
||||||
(fs.existsSync(path.join(__dirname, 'ui'))
|
|
||||||
? path.join(__dirname, 'ui')
|
|
||||||
: path.join(__dirname, '..', 'ui'));
|
|
||||||
app.use(express.static(uiRoot));
|
|
||||||
|
|
||||||
// ── UI API: JSON search ───────────────────────────────────────────────────
|
|
||||||
app.get('/api/search', async (req, res) => {
|
app.get('/api/search', async (req, res) => {
|
||||||
const q = (req.query.q || '').trim();
|
const q = (req.query.q || '').trim();
|
||||||
if (!q) return res.json([]);
|
if (!q) return res.json([]);
|
||||||
@@ -137,31 +138,55 @@ app.get('/api/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── UI API: add download to queue ─────────────────────────────────────────
|
|
||||||
app.post('/api/queue/add', (req, res) => {
|
app.post('/api/queue/add', (req, res) => {
|
||||||
|
try {
|
||||||
const { ident, name, size } = req.body || {};
|
const { ident, name, size } = req.body || {};
|
||||||
if (!ident) return res.status(400).json({ error: 'ident required' });
|
if (!ident) return res.status(400).json({ error: 'ident required' });
|
||||||
const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0);
|
const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0);
|
||||||
if (!ok) return res.status(409).json({ error: 'already in queue' });
|
if (!ok) return res.status(409).json({ error: 'already in queue' });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('queue/add', err);
|
||||||
|
res.status(500).json({ error: err.message || 'queue add failed' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── UI API: queue status ──────────────────────────────────────────────────
|
|
||||||
app.get('/api/queue', (req, res) => {
|
app.get('/api/queue', (req, res) => {
|
||||||
|
try {
|
||||||
res.json(qbt.getQueue());
|
res.json(qbt.getQueue());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('queue', err);
|
||||||
|
res.status(500).json({ error: err.message || 'queue failed' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── UI API: cancel download ───────────────────────────────────────────────
|
|
||||||
app.post('/api/queue/cancel', (req, res) => {
|
app.post('/api/queue/cancel', (req, res) => {
|
||||||
|
try {
|
||||||
const { hash } = req.body || {};
|
const { hash } = req.body || {};
|
||||||
if (!hash) return res.status(400).json({ error: 'hash required' });
|
if (!hash) return res.status(400).json({ error: 'hash required' });
|
||||||
qbt.cancelDownload(hash);
|
qbt.cancelDownload(hash);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('queue/cancel', err);
|
||||||
|
res.status(500).json({ error: err.message || 'cancel failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ ok: true, guest: client.guest });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Fake qBittorrent Web API ──────────────────────────────────────────────
|
// ── Fake qBittorrent Web API ──────────────────────────────────────────────
|
||||||
qbt.register(app);
|
qbt.register(app);
|
||||||
|
|
||||||
|
// ── UI static files (dev: ../ui from src/; production: dist/ui next to server.js)
|
||||||
|
const uiRoot =
|
||||||
|
process.env.UI_DIR ||
|
||||||
|
(fs.existsSync(path.join(__dirname, 'ui'))
|
||||||
|
? path.join(__dirname, 'ui')
|
||||||
|
: path.join(__dirname, '..', 'ui'));
|
||||||
|
app.use(express.static(uiRoot));
|
||||||
|
|
||||||
// ── MCP Streamable HTTP (AI agents) ───────────────────────────────────────
|
// ── MCP Streamable HTTP (AI agents) ───────────────────────────────────────
|
||||||
if (MCP_ENABLED) {
|
if (MCP_ENABLED) {
|
||||||
registerMcpHttp(app, {
|
registerMcpHttp(app, {
|
||||||
|
|||||||
119
src/qbt.js
119
src/qbt.js
@@ -6,10 +6,73 @@ const crypto = require('crypto');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { extractWebseed, extractInfoHash } = require('./torrent');
|
const { extractWebseed, extractInfoHash } = require('./torrent');
|
||||||
|
|
||||||
const SAVE_PATH = process.env.DOWNLOAD_PATH || '/downloads/webshare';
|
|
||||||
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data';
|
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data';
|
||||||
|
// Staging for *arr (Sonarr/Radarr) import pipeline
|
||||||
|
const SAVE_PATH = process.env.DOWNLOAD_PATH || path.join(MEDIA_ROOT, 'webshare');
|
||||||
|
// UI / direct movie downloads land in the Jellyfin Movies library
|
||||||
|
const MOVIES_PATH = process.env.MOVIES_PATH || path.join(MEDIA_ROOT, 'Movies');
|
||||||
|
const FILE_UID = parseInt(process.env.DOWNLOAD_UID || '1000', 10);
|
||||||
|
const FILE_GID = parseInt(process.env.DOWNLOAD_GID || '1000', 10);
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
function safeFileName(name) {
|
||||||
|
return path.basename(String(name || 'download.bin'))
|
||||||
|
.replace(/[\/\\?%*:|"<>]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim() || 'download.bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Jellyfin-friendly movie folder name from a release filename.
|
||||||
|
* e.g. "Predator 1987 1080p CZ DAB.mkv" → "Predator (1987)"
|
||||||
|
*/
|
||||||
|
function movieFolderFromName(name) {
|
||||||
|
let base = safeFileName(name).replace(/\.[^.]+$/i, '');
|
||||||
|
base = base.replace(/[\[\(][^\]\)]*[\]\)]/g, ' ');
|
||||||
|
base = base.replace(
|
||||||
|
/\b(1080p|720p|2160p|4k|uhd|hdr|bluray|blu-ray|webrip|web-dl|hdtv|bdrip|dvdrip|x264|x265|h264|h265|hevc|avc|aac|ac3|dts|truehd|atmos|remux|repack|proper|extended|directors?\.?\s*cut|theatrical|remaster(ed)?|multi|cz|cze|cestina|cesky|dab|dabing|titulky|eng|en|sk|slovak|subs?)\b/gi,
|
||||||
|
' ',
|
||||||
|
);
|
||||||
|
base = base.replace(/[._]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const yearMatch = base.match(/\b((?:19|20)\d{2})\b/);
|
||||||
|
if (yearMatch) {
|
||||||
|
const year = yearMatch[1];
|
||||||
|
const title = base.replace(year, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return `${title || 'Movie'} (${year})`;
|
||||||
|
}
|
||||||
|
return base || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryDir(entry) {
|
||||||
|
return entry.saveDir || SAVE_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryPath(entry) {
|
||||||
|
return path.join(entryDir(entry), entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
try {
|
||||||
|
if (typeof process.getuid === 'function' && process.getuid() === 0
|
||||||
|
&& Number.isFinite(FILE_UID) && Number.isFinite(FILE_GID)) {
|
||||||
|
fs.chownSync(dir, FILE_UID, FILE_GID);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixOwnership(filePath) {
|
||||||
|
try {
|
||||||
|
if (typeof process.getuid === 'function' && process.getuid() === 0
|
||||||
|
&& Number.isFinite(FILE_UID) && Number.isFinite(FILE_GID)
|
||||||
|
&& fs.existsSync(filePath)) {
|
||||||
|
fs.chownSync(filePath, FILE_UID, FILE_GID);
|
||||||
|
// also chown parent movie folder
|
||||||
|
try { fs.chownSync(path.dirname(filePath), FILE_UID, FILE_GID); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
function formatSpeed(bps) {
|
function formatSpeed(bps) {
|
||||||
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
|
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
|
||||||
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
|
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
|
||||||
@@ -33,10 +96,13 @@ function isCancelled(hash) {
|
|||||||
|
|
||||||
function saveState() {
|
function saveState() {
|
||||||
try {
|
try {
|
||||||
|
ensureDir(SAVE_PATH);
|
||||||
const data = [...downloads.values()].map(e => ({
|
const data = [...downloads.values()].map(e => ({
|
||||||
hash: e.hash, name: e.name, size: e.size,
|
hash: e.hash, name: e.name, size: e.size,
|
||||||
progress: e.progress, state: e.state, category: e.category,
|
progress: e.progress, state: e.state, category: e.category,
|
||||||
webseedUrl: e.webseedUrl,
|
webseedUrl: e.webseedUrl,
|
||||||
|
saveDir: e.saveDir,
|
||||||
|
library: e.library,
|
||||||
}));
|
}));
|
||||||
fs.writeFileSync(STATE_FILE, JSON.stringify(data));
|
fs.writeFileSync(STATE_FILE, JSON.stringify(data));
|
||||||
} catch { }
|
} catch { }
|
||||||
@@ -81,7 +147,9 @@ function loadState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanupAfterDownload(entry) {
|
function cleanupAfterDownload(entry) {
|
||||||
const fpath = path.join(SAVE_PATH, entry.name);
|
// Library-bound downloads (UI → Movies/) stay on disk for Jellyfin — do not delete.
|
||||||
|
if (entry.library) return;
|
||||||
|
const fpath = entryPath(entry);
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const check = setInterval(() => {
|
const check = setInterval(() => {
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -124,15 +192,16 @@ function startDownload(entry, webseedUrl) {
|
|||||||
throw new Error('cancelled');
|
throw new Error('cancelled');
|
||||||
}
|
}
|
||||||
// Use actual file size — Content-Length header can lie
|
// Use actual file size — Content-Length header can lie
|
||||||
try { entry.size = fs.statSync(path.join(SAVE_PATH, entry.name)).size; } catch {}
|
try { entry.size = fs.statSync(entryPath(entry)).size; } catch {}
|
||||||
entry.progress = 1.0;
|
entry.progress = 1.0;
|
||||||
entry.state = 'uploading';
|
entry.state = 'uploading';
|
||||||
console.log(`[qbt] done: ${entry.name}`);
|
fixOwnership(entryPath(entry));
|
||||||
|
console.log(`[qbt] done: ${entryPath(entry)}`);
|
||||||
cleanupAfterDownload(entry);
|
cleanupAfterDownload(entry);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err && err.message ? err.message : String(err);
|
const msg = err && err.message ? err.message : String(err);
|
||||||
// Delete partial file before retry / after cancel
|
// Delete partial file before retry / after cancel
|
||||||
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch { }
|
try { fs.unlinkSync(entryPath(entry)); } catch { }
|
||||||
|
|
||||||
if (isCancelled(entry.hash) || msg === 'cancelled') {
|
if (isCancelled(entry.hash) || msg === 'cancelled') {
|
||||||
console.log(`[qbt] cancelled: ${entry.name}`);
|
console.log(`[qbt] cancelled: ${entry.name}`);
|
||||||
@@ -215,12 +284,13 @@ function streamToFile(url, entry) {
|
|||||||
const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)/i);
|
const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)/i);
|
||||||
if (m) {
|
if (m) {
|
||||||
const fn = decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, ''));
|
const fn = decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, ''));
|
||||||
if (fn) entry.name = fn;
|
if (fn) entry.name = safeFileName(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
let received = 0;
|
let received = 0;
|
||||||
let lastDataAt = Date.now();
|
let lastDataAt = Date.now();
|
||||||
const dest = path.join(SAVE_PATH, entry.name);
|
ensureDir(entryDir(entry));
|
||||||
|
const dest = entryPath(entry);
|
||||||
const file = fs.createWriteStream(dest);
|
const file = fs.createWriteStream(dest);
|
||||||
|
|
||||||
// Poll every 10s — avoids setTimeout/clearTimeout on every chunk
|
// Poll every 10s — avoids setTimeout/clearTimeout on every chunk
|
||||||
@@ -279,7 +349,8 @@ function streamToFile(url, entry) {
|
|||||||
|
|
||||||
|
|
||||||
function register(app) {
|
function register(app) {
|
||||||
fs.mkdirSync(SAVE_PATH, { recursive: true });
|
ensureDir(SAVE_PATH);
|
||||||
|
ensureDir(MOVIES_PATH);
|
||||||
|
|
||||||
// ── auth ──────────────────────────────────────────────────────────────
|
// ── auth ──────────────────────────────────────────────────────────────
|
||||||
app.post('/api/v2/auth/login', (req, res) => {
|
app.post('/api/v2/auth/login', (req, res) => {
|
||||||
@@ -310,8 +381,8 @@ function register(app) {
|
|||||||
progress: d.progress,
|
progress: d.progress,
|
||||||
size: d.size,
|
size: d.size,
|
||||||
downloaded: Math.floor(d.progress * d.size),
|
downloaded: Math.floor(d.progress * d.size),
|
||||||
save_path: SAVE_PATH + '/',
|
save_path: entryDir(d) + '/',
|
||||||
content_path: path.join(SAVE_PATH, d.name),
|
content_path: entryPath(d),
|
||||||
num_seeds: 0,
|
num_seeds: 0,
|
||||||
num_leechs: 0,
|
num_leechs: 0,
|
||||||
ratio: 0,
|
ratio: 0,
|
||||||
@@ -482,15 +553,36 @@ function getQueue() {
|
|||||||
progress: e.progress,
|
progress: e.progress,
|
||||||
state: e.state,
|
state: e.state,
|
||||||
speed: e.speed || '',
|
speed: e.speed || '',
|
||||||
|
path: entryPath(e),
|
||||||
|
library: Boolean(e.library),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI / manual grab: write into Movies/<Title (Year)>/ for Jellyfin.
|
||||||
|
* *arr clients still use DOWNLOAD_PATH staging via /api/v2/torrents/add.
|
||||||
|
*/
|
||||||
function enqueueDirect(ident, name, size) {
|
function enqueueDirect(ident, name, size) {
|
||||||
const hash = identToHash(ident);
|
const hash = identToHash(ident);
|
||||||
if (downloads.has(hash)) return false;
|
if (downloads.has(hash)) return false;
|
||||||
const webseedUrl = `${process.env.BASE_URL || `http://localhost:${process.env.PORT || 3001}`}/stream/${ident}`;
|
const webseedUrl = `${process.env.BASE_URL || `http://localhost:${process.env.PORT || 3001}`}/stream/${ident}`;
|
||||||
const entry = { hash, name, size, progress: 0, state: 'downloading', category: '', speed: '' };
|
const fileName = safeFileName(name || ident);
|
||||||
|
const folder = movieFolderFromName(fileName);
|
||||||
|
const saveDir = path.join(MOVIES_PATH, folder);
|
||||||
|
ensureDir(saveDir);
|
||||||
|
const entry = {
|
||||||
|
hash,
|
||||||
|
name: fileName,
|
||||||
|
size: size || 0,
|
||||||
|
progress: 0,
|
||||||
|
state: 'downloading',
|
||||||
|
category: 'movies',
|
||||||
|
speed: '',
|
||||||
|
saveDir,
|
||||||
|
library: true,
|
||||||
|
};
|
||||||
downloads.set(hash, entry);
|
downloads.set(hash, entry);
|
||||||
|
console.log(`[qbt] library target: ${path.join(saveDir, fileName)}`);
|
||||||
enqueue(entry, webseedUrl);
|
enqueue(entry, webseedUrl);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -528,7 +620,10 @@ function cancelDownload(hash) {
|
|||||||
const wasInFlight = Boolean(entry && entry.state === 'downloading') || hadActiveReq;
|
const wasInFlight = Boolean(entry && entry.state === 'downloading') || hadActiveReq;
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch { /* ignore */ }
|
// Only delete partials for incomplete work — keep finished library files
|
||||||
|
if (entry.state === 'downloading' || entry.state === 'queuedDL') {
|
||||||
|
try { fs.unlinkSync(entryPath(entry)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
downloads.delete(hash);
|
downloads.delete(hash);
|
||||||
console.log(`[qbt] cancel: ${entry.name} (${entry.state})`);
|
console.log(`[qbt] cancel: ${entry.name} (${entry.state})`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
|
// Same origin as the page (works for LAN IP / hostname). Avoid relative issues
|
||||||
|
// when the app is served with a non-root base path later.
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let queuePoll = null;
|
let queuePoll = null;
|
||||||
|
|
||||||
|
async function apiFetch(path, opts) {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(opts && opts.headers),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
if (ct.includes('application/json')) return res.json();
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function startQueuePoll() {
|
function startQueuePoll() {
|
||||||
@@ -51,9 +70,7 @@ async function doSearch() {
|
|||||||
if (!q) return;
|
if (!q) return;
|
||||||
searchResults.innerHTML = '<div class="spinner">Searching...</div>';
|
searchResults.innerHTML = '<div class="spinner">Searching...</div>';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(q)}&limit=30`);
|
const data = await apiFetch(`/api/search?q=${encodeURIComponent(q)}&limit=30`);
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const data = await res.json();
|
|
||||||
renderSearchResults(data);
|
renderSearchResults(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
searchResults.innerHTML = `<div class="empty">Search failed: ${err.message}</div>`;
|
searchResults.innerHTML = `<div class="empty">Search failed: ${err.message}</div>`;
|
||||||
@@ -116,12 +133,11 @@ function renderSearchResults(files) {
|
|||||||
const addedIdents = {};
|
const addedIdents = {};
|
||||||
|
|
||||||
async function addDownload(ident, name, size) {
|
async function addDownload(ident, name, size) {
|
||||||
const res = await fetch(`${API_BASE}/api/queue/add`, {
|
await apiFetch('/api/queue/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ident, name, size }),
|
body: JSON.stringify({ ident, name, size }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
addedIdents[ident] = true;
|
addedIdents[ident] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +146,7 @@ async function addDownload(ident, name, size) {
|
|||||||
const queueList = document.getElementById('queueList');
|
const queueList = document.getElementById('queueList');
|
||||||
|
|
||||||
async function fetchQueue() {
|
async function fetchQueue() {
|
||||||
const res = await fetch(`${API_BASE}/api/queue`);
|
return apiFetch('/api/queue');
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue() {
|
function renderQueue() {
|
||||||
@@ -175,7 +189,7 @@ function renderQueue() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '[…]';
|
btn.textContent = '[…]';
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/api/queue/cancel`, {
|
await apiFetch('/api/queue/cancel', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hash }),
|
body: JSON.stringify({ hash }),
|
||||||
|
|||||||
Reference in New Issue
Block a user