webui: RSS + Search tab management UI

Wire the RSS and Search tabs to the new backend:

- RSS: add/remove feeds, per-article "Add" (download), and a full
  auto-download rule editor (match/regex, must[-not]-contain, per-feed
  scope, category, save path, paused) with edit/delete.
- Search: results now add via the server-side download endpoint
  (magnet/.torrent); add a Torznab indexer manager (list/add/remove).
- api.js: feed/rule/indexer/download client methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-23 01:01:36 -04:00
parent d8c4a96e88
commit ed906a3549
3 changed files with 163 additions and 21 deletions

View file

@ -43,6 +43,13 @@ export const api = {
rss: () => jget('/api/rss'),
rssRules: () => jget('/api/rss/rules'),
addFeed: (name, url) => jpost('/api/rss', { name, url }),
deleteFeed: (name) => jpost('/api/rss/delete', { name }),
saveRssRule: (rule) => jpost('/api/rss/rules', rule),
deleteRssRule: (name) => jpost('/api/rss/rules/delete', { name }),
rssDownload: (item) => jpost('/api/rss/download', item),
saveIndexer: (ix) => jpost('/api/indexers', ix),
deleteIndexer: (name) => jpost('/api/indexers/delete', { name }),
script: () => jget('/api/script'),
saveScript: (source) => jpost('/api/script', { source }),
saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }),

View file

@ -956,26 +956,114 @@ async function renderRssView(host, seq) {
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
if (seq !== viewRenderSeq || state.view !== 'rss') return;
// flatten articles, newest first, tagged with their feed
const articles = feeds.flatMap((fd) => fd.articles.map((a) => ({ ...a, feed: fd.name })))
.sort((a, b) => (b.pubDate || '').localeCompare(a.pubDate || ''));
host.innerHTML = `<div class="pane"><div class="split2">
<div>
<div class="card"><h3>Feeds</h3>
${feeds.map((fd) => `<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b></span><span class="dim">${f.ago(fd.lastUpdate)}</span></div>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')}
<div class="card">
<div class="card-head"><h3>Feeds</h3><button class="btn" id="addFeedBtn"> Add feed</button></div>
${feeds.length ? feeds.map((fd) => `<div class="feed-row" data-feed="${f.esc(fd.name)}">
<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b> <span class="dim">(${fd.articles.length})</span></span>
<span class="dim">${fd.lastUpdate ? f.ago(fd.lastUpdate) : 'not fetched yet'}</span>
<button class="iconbtn del-feed" title="Remove feed"></button></div>
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>
</div>`).join('<div style="height:8px"></div>') : '<div class="dim">No feeds yet. Add one to start polling.</div>'}
</div>
</div>
<div>
<div class="card"><h3>Unread articles</h3>
<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(fd.name)}</td><td>${f.esc(a.title)}</td>
<td class="num">${f.bytes(a.size)}</td><td class="num dim">${f.ago(a.date)}</td>
<td>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')}
</tbody></table>
<div class="card"><h3>Articles</h3>
${articles.length ? `<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
${articles.map((a, i) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
<td class="dim">${f.esc(a.feed)}</td><td>${f.esc(a.title)}</td>
<td class="num">${a.size ? f.bytes(a.size) : '—'}</td><td class="num dim">${f.esc(a.pubDate || '')}</td>
<td>${(a.magnet || a.torrentUrl) ? `<button class="btn dl-art" data-art="${i}"> Add</button>` : '<span class="faint">—</span>'}</td></tr>`).join('')}
</tbody></table>` : '<div class="dim">No articles yet.</div>'}
</div>
<h3 style="margin:16px 0 8px">Auto-download rules</h3>
${rules.map((r) => renderRule(r)).join('')}
<div class="card-head" style="margin-top:16px"><h3>Auto-download rules</h3><button class="btn" id="addRuleBtn"> New rule</button></div>
${rules.length ? rules.map((r) => renderRule(r)).join('') : '<div class="dim">No rules yet. New matching articles are not auto-downloaded.</div>'}
</div>
</div></div>`;
document.getElementById('addFeedBtn').addEventListener('click', openAddFeed);
document.getElementById('addRuleBtn').addEventListener('click', () => openRuleEditor(null, feeds));
host.querySelectorAll('.del-feed').forEach((b) => b.addEventListener('click', async () => {
const name = b.closest('.feed-row').dataset.feed;
await guard(() => api.deleteFeed(name), 'Failed to remove feed');
renderView();
}));
host.querySelectorAll('.dl-art').forEach((b) => b.addEventListener('click', () => guard(async () => {
const a = articles[+b.dataset.art];
await api.rssDownload({ magnet: a.magnet || '', torrentUrl: a.torrentUrl || '' });
b.textContent = '✓ Added'; b.disabled = true;
}, 'Failed to add torrent')));
host.querySelectorAll('[data-rule-edit]').forEach((b) => b.addEventListener('click', () =>
openRuleEditor(rules.find((r) => r.name === b.dataset.ruleEdit), feeds)));
host.querySelectorAll('[data-rule-del]').forEach((b) => b.addEventListener('click', async () => {
await guard(() => api.deleteRssRule(b.dataset.ruleDel), 'Failed to delete rule');
renderView();
}));
}
function openAddFeed() {
openModal('Add RSS feed', `
<div class="field"><label>Name</label><input type="text" id="feedName" placeholder="e.g. EZTV" /></div>
<div class="field"><label>Feed URL</label><input type="text" id="feedUrl" placeholder="https:///rss.xml" /></div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Add', primary: true, act: () => guard(async () => {
const name = document.getElementById('feedName').value.trim();
const url = document.getElementById('feedUrl').value.trim();
if (!name || !url) return closeModal();
await api.addFeed(name, url);
closeModal();
renderView();
}, 'Failed to add feed'),
}]);
setTimeout(() => document.getElementById('feedName')?.focus(), 0);
}
// Rule editor: create (rule=null) or edit an existing auto-download rule.
function openRuleEditor(rule, feeds) {
const r = rule || { name: '', enabled: true, useRegex: false, addPaused: false,
mustContain: '', mustNotContain: '', assignedCategory: '', savePath: '', affectedFeeds: [] };
const catOpts = '<option value="">— none —</option>' +
state.meta.categories.filter((c) => c.name).map((c) =>
`<option value="${f.esc(c.name)}"${c.name === r.assignedCategory ? ' selected' : ''}>${f.esc(c.name)}</option>`).join('');
const feedChecks = (feeds || []).map((fd) =>
`<label class="cbdrop-item"><input type="checkbox" value="${f.esc(fd.name)}"${r.affectedFeeds.includes(fd.name) ? ' checked' : ''}><span>${f.esc(fd.name)}</span></label>`).join('')
|| '<div class="dim" style="padding:4px">No feeds yet.</div>';
openModal(rule ? 'Edit rule' : 'New rule', `
<div class="field"><label>Rule name</label><input type="text" id="rName" value="${f.esc(r.name)}" ${rule ? 'readonly' : ''} placeholder="e.g. My show 1080p" /></div>
<div class="field"><label>Must contain</label><input type="text" id="rMust" value="${f.esc(r.mustContain)}" placeholder="title substring or regex" /></div>
<div class="field"><label>Must not contain</label><input type="text" id="rMustNot" value="${f.esc(r.mustNotContain)}" placeholder="optional" /></div>
<div class="field"><label>Apply to feeds (none = all)</label><div class="rule-feeds">${feedChecks}</div></div>
<div class="field"><label>Assign category</label><select id="rCat">${catOpts}</select></div>
<div class="field"><label>Save path (blank = category/default)</label><input type="text" id="rPath" value="${f.esc(r.savePath)}" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
<div class="checks">
<label><input type="checkbox" id="rEnabled" ${r.enabled ? 'checked' : ''}> Enabled</label>
<label><input type="checkbox" id="rRegex" ${r.useRegex ? 'checked' : ''}> Use regex</label>
<label><input type="checkbox" id="rPaused" ${r.addPaused ? 'checked' : ''}> Add paused</label>
</div>`,
[{ label: 'Cancel', act: closeModal }, {
label: 'Save', primary: true, act: () => guard(async () => {
const name = document.getElementById('rName').value.trim();
if (!name) return closeModal();
await api.saveRssRule({
name,
enabled: document.getElementById('rEnabled').checked,
useRegex: document.getElementById('rRegex').checked,
addPaused: document.getElementById('rPaused').checked,
mustContain: document.getElementById('rMust').value.trim(),
mustNotContain: document.getElementById('rMustNot').value.trim(),
assignedCategory: document.getElementById('rCat').value,
savePath: document.getElementById('rPath').value.trim(),
affectedFeeds: [...document.querySelectorAll('.rule-feeds input:checked')].map((c) => c.value),
});
closeModal();
renderView();
}, 'Failed to save rule'),
}]);
setTimeout(() => document.getElementById(rule ? 'rMust' : 'rName')?.focus(), 0);
}
function renderRule(r) {
@ -985,11 +1073,14 @@ function renderRule(r) {
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
${r.useRegex ? '<span class="pill">regex</span>' : ''}
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
<span style="flex:1"></span>
<button class="iconbtn" data-rule-edit="${f.esc(r.name)}" title="Edit rule"></button>
<button class="iconbtn" data-rule-del="${f.esc(r.name)}" title="Delete rule"></button>
</div>
<div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></div>
${r.mustNotContain ? `<div class="kvrow">must not contain <code class="inline">${f.esc(r.mustNotContain)}</code></div>` : ''}
<div class="kvrow"> category <b>${f.esc(r.assignedCategory)}</b> · save to <b>${f.esc(r.savePath)}</b></div>
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div>
<div class="kvrow"> category <b>${f.esc(r.assignedCategory || '—')}</b> · save to <b>${f.esc(r.savePath || 'default')}</b></div>
<div class="kvrow dim">feeds: ${r.affectedFeeds.length ? r.affectedFeeds.join(', ') : 'all'} · last match ${r.lastMatch ? f.ago(r.lastMatch) : 'never'}</div>
</div>`;
}
@ -1202,30 +1293,65 @@ function bindSettingsPanel(script) {
/* ===================== Search view ===================== */
function renderSearchView(host) {
const plugins = state.meta.searchPlugins.map((p) =>
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ');
const idx = state.meta.searchPlugins || [];
const plugins = idx.length
? idx.map((p) => `<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ')
: '<span class="dim">none configured</span>';
host.innerHTML = `<div class="pane">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" />
<button class="btn primary" id="searchBtn">Search</button>
</div>
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</div>
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}
<span style="flex:1"></span><button class="btn" id="manageIdxBtn">Manage indexers</button></div>
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
</div>`;
const input = document.getElementById('searchInput');
const run = async () => {
state.searchQuery = input.value.trim();
if (!state.searchQuery) return;
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
state.searchResults = await api.search(state.searchQuery);
try { state.searchResults = await api.search(state.searchQuery); }
catch (e) { document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Search failed</div>'; return; }
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
bindSearchRows();
};
document.getElementById('searchBtn').addEventListener('click', run);
document.getElementById('manageIdxBtn').addEventListener('click', openIndexerManager);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
input.focus();
bindSearchRows();
}
// Manage Torznab indexers (list, add, remove).
function openIndexerManager() {
const idx = state.meta.searchPlugins || [];
const rows = idx.length ? idx.map((p) => `<div class="kvrow" data-idx="${f.esc(p.name)}">
<span style="flex:1">🔎 <b>${f.esc(p.name)}</b> <code class="inline">${f.esc(p.url || '')}</code></span>
<button class="iconbtn del-idx" title="Remove"></button></div>`).join('')
: '<div class="dim">No indexers yet.</div>';
openModal('Torznab indexers', `
<div id="idxList" style="margin-bottom:12px">${rows}</div>
<div class="field"><label>Name</label><input type="text" id="ixName" placeholder="e.g. Jackett/Prowlarr indexer" /></div>
<div class="field"><label>Torznab API URL</label><input type="text" id="ixUrl" placeholder="https://host/api/v2.0/indexers/.../results/torznab/api" /></div>
<div class="field"><label>API key</label><input type="text" id="ixKey" placeholder="optional" /></div>`,
[{ label: 'Close', act: () => { closeModal(); refreshMeta().then(renderView); } }, {
label: 'Add indexer', primary: true, act: () => guard(async () => {
const name = document.getElementById('ixName').value.trim();
const url = document.getElementById('ixUrl').value.trim();
if (!name || !url) return;
await api.saveIndexer({ name, url, apikey: document.getElementById('ixKey').value.trim(), enabled: true });
await refreshMeta();
openIndexerManager();
}, 'Failed to add indexer'),
}]);
document.querySelectorAll('.del-idx').forEach((b) => b.addEventListener('click', async () => {
await guard(() => api.deleteIndexer(b.closest('[data-idx]').dataset.idx), 'Failed to remove indexer');
await refreshMeta();
openIndexerManager();
}));
}
function searchTable(rows) {
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
return `<table class="dtbl"><thead><tr>
@ -1241,11 +1367,11 @@ function searchTable(rows) {
function bindSearchRows() {
document.querySelectorAll('[data-sr]').forEach((b) =>
b.addEventListener('click', async () => {
b.addEventListener('click', () => guard(async () => {
const r = state.searchResults[+b.dataset.sr];
await api.add({ name: r.name });
await api.rssDownload({ magnet: r.magnet || '', torrentUrl: r.torrentUrl || '' });
b.textContent = '✓ Added'; b.disabled = true;
}));
}, 'Failed to add torrent')));
}
/* ===================== Engine/settings view ===================== */