diff --git a/.env.example b/.env.example index 574c80d..8e3c37d 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,8 @@ WEBSHARE_PASSWORD= # DOWNLOAD_PATH=/downloads/webshare # MEDIA_ROOT=/data # MAX_CONCURRENT_DOWNLOADS=2 + +# UI downloads → Jellyfin Movies library (container path) +# MOVIES_PATH=/data/Movies +# DOWNLOAD_UID=1000 +# DOWNLOAD_GID=1000 diff --git a/README.md b/README.md index e04e65e..45e0154 100644 --- a/README.md +++ b/README.md @@ -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) | | `DOWNLOAD_PATH` | `/downloads/webshare` | Where the fake qBittorrent writes files | | `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 | | `SONARR_URL` | `http://sonarr:8989` | Optional; Sonarr base URL | | `SONARR_API_KEY` | _(empty)_ | Optional; enables periodic missing-episode search | diff --git a/src/index.js b/src/index.js index 259e49c..221c94f 100644 --- a/src/index.js +++ b/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 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.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) -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 ─────────────────────────────────────────────────── +// ── UI API (before static so /api/* is never swallowed) ─────────────────── app.get('/api/search', async (req, res) => { const q = (req.query.q || '').trim(); 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) => { - const { ident, name, size } = req.body || {}; - if (!ident) return res.status(400).json({ error: 'ident required' }); - const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0); - if (!ok) return res.status(409).json({ error: 'already in queue' }); - res.json({ ok: true }); + try { + const { ident, name, size } = req.body || {}; + if (!ident) return res.status(400).json({ error: 'ident required' }); + const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0); + if (!ok) return res.status(409).json({ error: 'already in queue' }); + 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) => { - res.json(qbt.getQueue()); + try { + 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) => { - const { hash } = req.body || {}; - if (!hash) return res.status(400).json({ error: 'hash required' }); - qbt.cancelDownload(hash); - res.json({ ok: true }); + try { + const { hash } = req.body || {}; + if (!hash) return res.status(400).json({ error: 'hash required' }); + qbt.cancelDownload(hash); + 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 ────────────────────────────────────────────── 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) ─────────────────────────────────────── if (MCP_ENABLED) { registerMcpHttp(app, { diff --git a/src/qbt.js b/src/qbt.js index 97d0296..eaba407 100644 --- a/src/qbt.js +++ b/src/qbt.js @@ -6,10 +6,73 @@ const crypto = require('crypto'); const multer = require('multer'); const { extractWebseed, extractInfoHash } = require('./torrent'); -const SAVE_PATH = process.env.DOWNLOAD_PATH || '/downloads/webshare'; 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() }); +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) { if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s'; if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s'; @@ -33,10 +96,13 @@ function isCancelled(hash) { function saveState() { try { + ensureDir(SAVE_PATH); const data = [...downloads.values()].map(e => ({ hash: e.hash, name: e.name, size: e.size, progress: e.progress, state: e.state, category: e.category, webseedUrl: e.webseedUrl, + saveDir: e.saveDir, + library: e.library, })); fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch { } @@ -81,7 +147,9 @@ function loadState() { } 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; const check = setInterval(() => { attempts++; @@ -124,15 +192,16 @@ function startDownload(entry, webseedUrl) { throw new Error('cancelled'); } // 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.state = 'uploading'; - console.log(`[qbt] done: ${entry.name}`); + fixOwnership(entryPath(entry)); + console.log(`[qbt] done: ${entryPath(entry)}`); cleanupAfterDownload(entry); } catch (err) { const msg = err && err.message ? err.message : String(err); // 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') { console.log(`[qbt] cancelled: ${entry.name}`); @@ -215,12 +284,13 @@ function streamToFile(url, entry) { const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)/i); if (m) { const fn = decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, '')); - if (fn) entry.name = fn; + if (fn) entry.name = safeFileName(fn); } let received = 0; let lastDataAt = Date.now(); - const dest = path.join(SAVE_PATH, entry.name); + ensureDir(entryDir(entry)); + const dest = entryPath(entry); const file = fs.createWriteStream(dest); // Poll every 10s — avoids setTimeout/clearTimeout on every chunk @@ -279,7 +349,8 @@ function streamToFile(url, entry) { function register(app) { - fs.mkdirSync(SAVE_PATH, { recursive: true }); + ensureDir(SAVE_PATH); + ensureDir(MOVIES_PATH); // ── auth ────────────────────────────────────────────────────────────── app.post('/api/v2/auth/login', (req, res) => { @@ -310,8 +381,8 @@ function register(app) { progress: d.progress, size: d.size, downloaded: Math.floor(d.progress * d.size), - save_path: SAVE_PATH + '/', - content_path: path.join(SAVE_PATH, d.name), + save_path: entryDir(d) + '/', + content_path: entryPath(d), num_seeds: 0, num_leechs: 0, ratio: 0, @@ -482,15 +553,36 @@ function getQueue() { progress: e.progress, state: e.state, speed: e.speed || '', + path: entryPath(e), + library: Boolean(e.library), })); } +/** + * UI / manual grab: write into Movies// for Jellyfin. + * *arr clients still use DOWNLOAD_PATH staging via /api/v2/torrents/add. + */ function enqueueDirect(ident, name, size) { const hash = identToHash(ident); if (downloads.has(hash)) return false; 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); + console.log(`[qbt] library target: ${path.join(saveDir, fileName)}`); enqueue(entry, webseedUrl); return true; } @@ -528,7 +620,10 @@ function cancelDownload(hash) { const wasInFlight = Boolean(entry && entry.state === 'downloading') || hadActiveReq; 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); console.log(`[qbt] cancel: ${entry.name} (${entry.state})`); } else { diff --git a/ui/scripts.js b/ui/scripts.js index aec0cb8..88c9002 100644 --- a/ui/scripts.js +++ b/ui/scripts.js @@ -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; 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 ──────────────────────────────────────────────────────────────────── function startQueuePoll() { @@ -51,9 +70,7 @@ async function doSearch() { if (!q) return; searchResults.innerHTML = '<div class="spinner">Searching...</div>'; try { - const res = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(q)}&limit=30`); - if (!res.ok) throw new Error(await res.text()); - const data = await res.json(); + const data = await apiFetch(`/api/search?q=${encodeURIComponent(q)}&limit=30`); renderSearchResults(data); } catch (err) { searchResults.innerHTML = `<div class="empty">Search failed: ${err.message}</div>`; @@ -116,12 +133,11 @@ function renderSearchResults(files) { const addedIdents = {}; async function addDownload(ident, name, size) { - const res = await fetch(`${API_BASE}/api/queue/add`, { + await apiFetch('/api/queue/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ident, name, size }), }); - if (!res.ok) throw new Error(await res.text()); addedIdents[ident] = true; } @@ -130,9 +146,7 @@ async function addDownload(ident, name, size) { const queueList = document.getElementById('queueList'); async function fetchQueue() { - const res = await fetch(`${API_BASE}/api/queue`); - if (!res.ok) throw new Error(await res.text()); - return res.json(); + return apiFetch('/api/queue'); } function renderQueue() { @@ -175,7 +189,7 @@ function renderQueue() { btn.disabled = true; btn.textContent = '[…]'; try { - await fetch(`${API_BASE}/api/queue/cancel`, { + await apiFetch('/api/queue/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }),