feat: initial full-stack nyaa-crawler implementation

- Node.js + TypeScript + Express backend using built-in node:sqlite
- React + Vite frontend with dark-themed UI
- Nyaa.si RSS polling via fast-xml-parser
- Watch list with show/episode CRUD and status tracking
- Auto-download scheduler with node-cron (configurable interval)
- .torrent file downloader with batch-release filtering
- Settings page for poll interval and quality defaults
- Dockerfile and docker-compose for Unraid deployment
- SQLite DB with migrations (shows, episodes, settings tables)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jason
2026-03-17 14:00:09 -05:00
parent 8294e8d95f
commit ded0875e72
31 changed files with 5560 additions and 12 deletions
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react'
import ShowsPage from './pages/ShowsPage'
import ShowDetailPage from './pages/ShowDetailPage'
import SettingsPage from './pages/SettingsPage'
type Page = { name: 'shows' } | { name: 'show'; id: number } | { name: 'settings' }
export default function App() {
const [page, setPage] = useState<Page>({ name: 'shows' })
return (
<div className="layout">
<aside className="sidebar">
<div className="logo">Nyaa Watcher</div>
<nav>
<a
href="#"
className={page.name === 'shows' || page.name === 'show' ? 'active' : ''}
onClick={(e) => { e.preventDefault(); setPage({ name: 'shows' }) }}
>
Shows
</a>
<a
href="#"
className={page.name === 'settings' ? 'active' : ''}
onClick={(e) => { e.preventDefault(); setPage({ name: 'settings' }) }}
>
Settings
</a>
</nav>
</aside>
<main className="main">
{page.name === 'shows' && (
<ShowsPage onSelectShow={(id) => setPage({ name: 'show', id })} />
)}
{page.name === 'show' && (
<ShowDetailPage
showId={page.id}
onBack={() => setPage({ name: 'shows' })}
/>
)}
{page.name === 'settings' && <SettingsPage />}
</main>
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
export interface Show {
id: number
name: string
search_query: string
quality: string | null
sub_group: string | null
rss_url: string | null
is_active: number
created_at: string
updated_at: string
}
export interface Episode {
id: number
show_id: number
episode_code: string
title: string
torrent_id: string
torrent_url: string
status: 'pending' | 'downloaded_auto' | 'downloaded_manual'
downloaded_at: string | null
created_at: string
updated_at: string
}
export interface NyaaItem {
torrent_id: string
title: string
torrent_url: string
magnet_url: string | null
category: string
size: string
seeders: number
leechers: number
downloads: number
published: string
}
export interface Settings {
poll_interval_seconds: string
default_quality: string
default_sub_group: string
torrent_output_dir: string
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error((body as { error?: string }).error ?? res.statusText)
}
return res.json() as Promise<T>
}
export const api = {
shows: {
list: () => request<Show[]>('/api/shows'),
get: (id: number) => request<Show>(`/api/shows/${id}`),
create: (data: { name: string; search_query: string; quality?: string; sub_group?: string }) =>
request<Show>('/api/shows', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Show>) =>
request<Show>(`/api/shows/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
remove: (id: number) => request<{ success: boolean }>(`/api/shows/${id}`, { method: 'DELETE' }),
},
episodes: {
list: (showId: number) => request<Episode[]>(`/api/shows/${showId}/episodes`),
update: (showId: number, epId: number, status: Episode['status']) =>
request<Episode>(`/api/shows/${showId}/episodes/${epId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
}),
bulkMark: (showId: number, up_to_code: string, status: string) =>
request<{ updated: number }>(`/api/shows/${showId}/episodes/bulk-mark`, {
method: 'POST',
body: JSON.stringify({ up_to_code, status }),
}),
},
nyaa: {
search: (q: string, c = '1_2') =>
request<NyaaItem[]>(`/api/nyaa/search?q=${encodeURIComponent(q)}&c=${c}`),
},
settings: {
get: () => request<Settings>('/api/settings'),
update: (data: Partial<Omit<Settings, 'torrent_output_dir'>>) =>
request<Settings>('/api/settings', { method: 'PATCH', body: JSON.stringify(data) }),
},
}
+127
View File
@@ -0,0 +1,127 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f13;
--surface: #1a1a22;
--surface2: #23232e;
--border: #2e2e3d;
--accent: #6c63ff;
--accent-hover: #857ef5;
--text: #e0e0ee;
--text-muted: #888899;
--green: #2ecc71;
--yellow: #f1c40f;
--red: #e74c3c;
--radius: 6px;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 14px; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
button {
cursor: pointer;
border: none;
border-radius: var(--radius);
padding: 6px 14px;
font-size: 13px;
background: var(--accent);
color: #fff;
transition: background 0.15s;
}
button:hover { background: var(--accent-hover); }
button.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
}
button.ghost:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
button.danger { background: var(--red); }
button.danger:hover { background: #c0392b; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
input, select, textarea {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 6px 10px;
font-size: 13px;
outline: none;
width: 100%;
}
input:focus, select:focus { border-color: var(--accent); }
label { display: block; margin-bottom: 4px; color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.badge.pending { background: #2a2a1a; color: var(--yellow); }
.badge.downloaded_auto { background: #1a2a1a; color: var(--green); }
.badge.downloaded_manual { background: #1a2533; color: #4da6ff; }
.layout { display: flex; height: 100vh; }
.sidebar { width: 200px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 16px 0; }
.sidebar .logo { padding: 0 16px 16px; font-size: 16px; font-weight: 700; color: var(--accent); border-bottom: 1px solid var(--border); margin-bottom: 8px; }
.sidebar nav a { display: block; padding: 8px 16px; color: var(--text-muted); border-radius: 0; transition: background 0.1s, color 0.1s; }
.sidebar nav a:hover, .sidebar nav a.active { background: var(--surface2); color: var(--text); }
.main { flex: 1; overflow-y: auto; padding: 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.page-header h1 { font-size: 20px; font-weight: 600; }
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.form-group { flex: 1; min-width: 140px; }
.form-group label { margin-bottom: 4px; }
.gap-2 { gap: 8px; }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.mt-4 { margin-top: 16px; }
.mt-2 { margin-top: 8px; }
.mb-4 { margin-bottom: 16px; }
.text-muted { color: var(--text-muted); }
.text-sm { font-size: 12px; }
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { text-align: center; padding: 48px; color: var(--text-muted); }
.search-results { margin-top: 12px; max-height: 320px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2); }
.search-result-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); gap: 8px; }
.search-result-item:last-child { border-bottom: none; }
.search-result-item .title { flex: 1; font-size: 13px; }
.search-result-item .meta { color: var(--text-muted); font-size: 11px; white-space: nowrap; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; width: 560px; max-width: 95vw; max-height: 85vh; overflow-y: auto; }
.modal h2 { margin-bottom: 16px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.show-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.show-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; cursor: pointer; transition: border-color 0.15s; }
.show-card:hover { border-color: var(--accent); }
.show-card h3 { font-size: 15px; margin-bottom: 6px; }
.show-card .meta { font-size: 12px; color: var(--text-muted); }
.show-card .inactive { opacity: 0.5; }
.back-link { display: inline-flex; align-items: center; gap: 4px; color: var(--text-muted); margin-bottom: 16px; font-size: 13px; }
.back-link:hover { color: var(--accent); }
.tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); margin-right: 4px; }
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nyaa Watcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { api, type Settings } from '../api'
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null)
const [form, setForm] = useState({ poll_interval_seconds: '', default_quality: '', default_sub_group: '' })
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
api.settings.get().then((s) => {
setSettings(s)
setForm({
poll_interval_seconds: s.poll_interval_seconds,
default_quality: s.default_quality,
default_sub_group: s.default_sub_group,
})
})
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError('')
setSaved(false)
try {
const updated = await api.settings.update(form)
setSettings(updated)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
if (!settings) return <div className="empty-state"><div className="spinner" /></div>
return (
<>
<div className="page-header">
<h1>Settings</h1>
</div>
<form onSubmit={handleSubmit} style={{ maxWidth: 480 }}>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 16, fontWeight: 600 }}>Polling</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<label>Poll Interval (seconds)</label>
<input
type="number"
min={60}
value={form.poll_interval_seconds}
onChange={(e) => setForm((f) => ({ ...f, poll_interval_seconds: e.target.value }))}
/>
<p className="text-muted text-sm" style={{ marginTop: 4 }}>
Minimum 60 seconds. Default is 900 (15 minutes).
</p>
</div>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 16, fontWeight: 600 }}>Defaults (applied to new shows)</div>
<div className="form-group" style={{ marginBottom: 12 }}>
<label>Default Quality</label>
<input
value={form.default_quality}
onChange={(e) => setForm((f) => ({ ...f, default_quality: e.target.value }))}
placeholder="1080p"
/>
</div>
<div className="form-group">
<label>Default Sub Group</label>
<input
value={form.default_sub_group}
onChange={(e) => setForm((f) => ({ ...f, default_sub_group: e.target.value }))}
placeholder="SubsPlease"
/>
</div>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Torrent Output Directory</div>
<code style={{ fontSize: 12, color: 'var(--text-muted)', wordBreak: 'break-all' }}>
{settings.torrent_output_dir}
</code>
<p className="text-muted text-sm" style={{ marginTop: 6 }}>
Configured via the <code>TORRENT_OUTPUT_DIR</code> environment variable.
</p>
</div>
{error && <p style={{ color: 'var(--red)', marginBottom: 12 }}>{error}</p>}
{saved && <p style={{ color: 'var(--green)', marginBottom: 12 }}>Settings saved.</p>}
<button type="submit" disabled={saving}>
{saving ? <span className="spinner" /> : 'Save Settings'}
</button>
</form>
</>
)
}
+185
View File
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react'
import { api, type Show, type Episode } from '../api'
interface Props {
showId: number
onBack: () => void
}
export default function ShowDetailPage({ showId, onBack }: Props) {
const [show, setShow] = useState<Show | null>(null)
const [episodes, setEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true)
const [bulkCode, setBulkCode] = useState('')
const [bulking, setBulking] = useState(false)
useEffect(() => {
Promise.all([api.shows.get(showId), api.episodes.list(showId)])
.then(([s, eps]) => { setShow(s); setEpisodes(eps) })
.finally(() => setLoading(false))
}, [showId])
const handleStatusChange = async (ep: Episode, status: Episode['status']) => {
const updated = await api.episodes.update(showId, ep.id, status)
setEpisodes((prev) => prev.map((e) => (e.id === updated.id ? updated : e)))
}
const handleBulkMark = async (e: React.FormEvent) => {
e.preventDefault()
if (!bulkCode.trim()) return
setBulking(true)
try {
await api.episodes.bulkMark(showId, bulkCode.trim(), 'downloaded_manual')
const eps = await api.episodes.list(showId)
setEpisodes(eps)
setBulkCode('')
} finally {
setBulking(false)
}
}
const handleToggleActive = async () => {
if (!show) return
const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 })
setShow(updated)
}
if (loading) return <div className="empty-state"><div className="spinner" /></div>
if (!show) return <div className="empty-state">Show not found.</div>
const stats = {
total: episodes.length,
pending: episodes.filter((e) => e.status === 'pending').length,
auto: episodes.filter((e) => e.status === 'downloaded_auto').length,
manual: episodes.filter((e) => e.status === 'downloaded_manual').length,
}
return (
<>
<a href="#" className="back-link" onClick={(e) => { e.preventDefault(); onBack() }}>
Shows
</a>
<div className="page-header">
<div>
<h1>{show.name}</h1>
<div style={{ marginTop: 6, display: 'flex', gap: 6, alignItems: 'center' }}>
{show.quality && <span className="tag">{show.quality}</span>}
{show.sub_group && <span className="tag">{show.sub_group}</span>}
<span className={`badge ${show.is_active ? 'downloaded_auto' : 'pending'}`}>
{show.is_active ? 'active' : 'inactive'}
</span>
</div>
</div>
<button className="ghost" onClick={handleToggleActive}>
{show.is_active ? 'Pause Polling' : 'Resume Polling'}
</button>
</div>
{/* Stats */}
<div className="flex" style={{ gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{[
{ label: 'Total', value: stats.total },
{ label: 'Pending', value: stats.pending },
{ label: 'Auto DL', value: stats.auto },
{ label: 'Manual', value: stats.manual },
].map(({ label, value }) => (
<div key={label} className="card" style={{ minWidth: 100, textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 700 }}>{value}</div>
<div className="text-muted text-sm">{label}</div>
</div>
))}
</div>
{/* Bulk mark */}
<form onSubmit={handleBulkMark} className="card mb-4" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Bulk Mark as Downloaded</div>
<div className="flex" style={{ gap: 8 }}>
<input
style={{ maxWidth: 160 }}
value={bulkCode}
onChange={(e) => setBulkCode(e.target.value)}
placeholder="Episode code, e.g. 12"
/>
<button type="submit" disabled={bulking || !bulkCode.trim()}>
{bulking ? <span className="spinner" /> : 'Mark up to this episode'}
</button>
</div>
<p className="text-muted text-sm" style={{ marginTop: 6 }}>
Marks all episodes up to and including the given episode code as manually downloaded.
</p>
</form>
{/* Episodes table */}
{episodes.length === 0 ? (
<div className="empty-state">No episodes found yet. Polling will populate this list.</div>
) : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table>
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Nyaa ID</th>
<th>Status</th>
<th>Downloaded</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{episodes.map((ep) => (
<tr key={ep.id}>
<td style={{ fontWeight: 600, width: 50 }}>{ep.episode_code}</td>
<td style={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<a href={ep.torrent_url} target="_blank" rel="noopener noreferrer" title={ep.title}>
{ep.title}
</a>
</td>
<td className="text-muted">{ep.torrent_id}</td>
<td><span className={`badge ${ep.status}`}>{ep.status.replace('_', ' ')}</span></td>
<td className="text-muted text-sm">
{ep.downloaded_at ? new Date(ep.downloaded_at).toLocaleDateString() : '—'}
</td>
<td>
<EpisodeActions ep={ep} onChange={handleStatusChange} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)
}
function EpisodeActions({
ep,
onChange,
}: {
ep: Episode
onChange: (ep: Episode, status: Episode['status']) => Promise<void>
}) {
const [busy, setBusy] = useState(false)
const handle = async (status: Episode['status']) => {
setBusy(true)
try { await onChange(ep, status) } finally { setBusy(false) }
}
if (busy) return <span className="spinner" />
if (ep.status === 'pending') {
return (
<button style={{ fontSize: 12 }} onClick={() => handle('downloaded_manual')}>
Mark Downloaded
</button>
)
}
return (
<button className="ghost" style={{ fontSize: 12 }} onClick={() => handle('pending')}>
Reset
</button>
)
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import { api, type Show, type NyaaItem } from '../api'
interface Props {
onSelectShow: (id: number) => void
}
export default function ShowsPage({ onSelectShow }: Props) {
const [shows, setShows] = useState<Show[]>([])
const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.shows.list().then(setShows).finally(() => setLoading(false))
}, [])
const handleToggleActive = async (show: Show, e: React.MouseEvent) => {
e.stopPropagation()
const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 })
setShows((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
}
const handleShowAdded = (show: Show) => {
setShows((prev) => [show, ...prev])
setShowModal(false)
}
return (
<>
<div className="page-header">
<h1>Shows</h1>
<button onClick={() => setShowModal(true)}>+ Add Show</button>
</div>
{loading && <div className="empty-state"><div className="spinner" /></div>}
{!loading && shows.length === 0 && (
<div className="empty-state">
<p>No shows yet. Add one to get started.</p>
</div>
)}
<div className="show-grid">
{shows.map((show) => (
<div
key={show.id}
className={`show-card ${!show.is_active ? 'inactive' : ''}`}
onClick={() => onSelectShow(show.id)}
>
<h3>{show.name}</h3>
<div className="meta" style={{ marginBottom: 8 }}>
<code style={{ fontSize: 11 }}>{show.search_query}</code>
</div>
<div className="flex items-center gap-2" style={{ gap: 6 }}>
{show.quality && <span className="tag">{show.quality}</span>}
{show.sub_group && <span className="tag">{show.sub_group}</span>}
<span className={`badge ${show.is_active ? 'downloaded_auto' : 'pending'}`} style={{ marginLeft: 'auto' }}>
{show.is_active ? 'active' : 'inactive'}
</span>
</div>
<div className="flex" style={{ marginTop: 10, gap: 6 }}>
<button
className="ghost"
style={{ flex: 1, fontSize: 12 }}
onClick={(e) => handleToggleActive(show, e)}
>
{show.is_active ? 'Pause' : 'Resume'}
</button>
</div>
</div>
))}
</div>
{showModal && (
<AddShowModal onClose={() => setShowModal(false)} onAdded={handleShowAdded} />
)}
</>
)
}
function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s: Show) => void }) {
const [searchQ, setSearchQ] = useState('')
const [results, setResults] = useState<NyaaItem[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('')
const [name, setName] = useState('')
const [query, setQuery] = useState('')
const [quality, setQuality] = useState('')
const [subGroup, setSubGroup] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!searchQ.trim()) return
setSearching(true)
setSearchError('')
try {
const res = await api.nyaa.search(searchQ)
setResults(res)
if (!query) setQuery(searchQ)
} catch (err) {
setSearchError(err instanceof Error ? err.message : 'Search failed')
} finally {
setSearching(false)
}
}
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !query.trim()) return
setSaving(true)
setError('')
try {
const show = await api.shows.create({ name: name.trim(), search_query: query.trim(), quality: quality || undefined, sub_group: subGroup || undefined })
onAdded(show)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add show')
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Add Show</h2>
{/* Search Nyaa */}
<form onSubmit={handleSearch} style={{ marginBottom: 16 }}>
<label>Search Nyaa.si</label>
<div className="flex" style={{ gap: 8 }}>
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder='e.g. "Jujutsu Kaisen SubsPlease 1080p"'
/>
<button type="submit" disabled={searching} style={{ whiteSpace: 'nowrap' }}>
{searching ? <span className="spinner" /> : 'Search'}
</button>
</div>
{searchError && <p style={{ color: 'var(--red)', marginTop: 6, fontSize: 12 }}>{searchError}</p>}
</form>
{results.length > 0 && (
<div className="search-results" style={{ marginBottom: 16 }}>
{results.slice(0, 20).map((item) => (
<div key={item.torrent_id} className="search-result-item">
<span className="title">{item.title}</span>
<span className="meta">{item.size} · {item.seeders}S</span>
</div>
))}
</div>
)}
{/* Add form */}
<form onSubmit={handleAdd}>
<div className="form-row" style={{ marginBottom: 12 }}>
<div className="form-group">
<label>Show Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Jujutsu Kaisen" required />
</div>
</div>
<div className="form-row" style={{ marginBottom: 12 }}>
<div className="form-group">
<label>Search / RSS Query *</label>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Jujutsu Kaisen SubsPlease 1080p" required />
</div>
</div>
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-group">
<label>Quality</label>
<input value={quality} onChange={(e) => setQuality(e.target.value)} placeholder="1080p" />
</div>
<div className="form-group">
<label>Sub Group</label>
<input value={subGroup} onChange={(e) => setSubGroup(e.target.value)} placeholder="SubsPlease" />
</div>
</div>
{error && <p style={{ color: 'var(--red)', marginBottom: 12, fontSize: 12 }}>{error}</p>}
<div className="modal-footer">
<button type="button" className="ghost" onClick={onClose}>Cancel</button>
<button type="submit" disabled={saving}>
{saving ? <span className="spinner" /> : 'Add Show'}
</button>
</div>
</form>
</div>
</div>
)
}