This commit is contained in:
+9
-1
@@ -1,12 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ShowsPage from './pages/ShowsPage'
|
||||
import ShowDetailPage from './pages/ShowDetailPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import HealthBanner from './components/HealthBanner'
|
||||
import { api, type HealthStatus } from './api'
|
||||
|
||||
type Page = { name: 'shows' } | { name: 'show'; id: number } | { name: 'settings' }
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>({ name: 'shows' })
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.health.get().then(setHealth).catch(() => setHealth(null))
|
||||
}, [page])
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
@@ -31,6 +38,7 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<HealthBanner health={health} />
|
||||
{page.name === 'shows' && (
|
||||
<ShowsPage onSelectShow={(id) => setPage({ name: 'show', id })} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type HealthStatus } from '../api'
|
||||
|
||||
// App-wide warning shown whenever the torrent output directory cannot be
|
||||
// written to. This is rendered on every page because a non-writable directory
|
||||
// makes *all* auto-downloads fail silently — the user needs to see it no matter
|
||||
// where they are in the app, not only on a show's detail page.
|
||||
export default function HealthBanner({ health }: { health: HealthStatus | null }) {
|
||||
if (!health || health.torrent_dir_writable) return null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--red)', background: '#1a0a0a' }}>
|
||||
<div style={{ color: 'var(--red)', fontWeight: 600, marginBottom: 4 }}>
|
||||
⚠ Torrent directory is not writable
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Downloads will fail until this is fixed. Path: <code>{health.torrent_dir}</code>
|
||||
</div>
|
||||
{health.torrent_dir_error && (
|
||||
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 4 }}>{health.torrent_dir_error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,6 +118,9 @@ tr:hover td { background: var(--surface2); }
|
||||
.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.clickable { cursor: pointer; transition: background 0.1s; }
|
||||
.search-result-item.clickable:hover { background: var(--surface); }
|
||||
.search-result-item.selected { background: var(--surface); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.search-result-item .title { flex: 1; font-size: 13px; }
|
||||
.search-result-item .meta { color: var(--text-muted); font-size: 11px; white-space: nowrap; }
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseRelease } from './parseRelease.js'
|
||||
|
||||
test('parseRelease: extracts name, quality, and sub group', () => {
|
||||
assert.deepEqual(
|
||||
parseRelease('[SubsPlease] Jujutsu Kaisen - 12 (1080p) [A1B2C3D4].mkv'),
|
||||
{ name: 'Jujutsu Kaisen', quality: '1080p', subGroup: 'SubsPlease' }
|
||||
)
|
||||
})
|
||||
|
||||
test('parseRelease: strips versioned episode markers and trailing brackets', () => {
|
||||
assert.deepEqual(
|
||||
parseRelease('[Erai-raws] Some Show Name - 07v2 [720p][ABCD].mkv'),
|
||||
{ name: 'Some Show Name', quality: '720p', subGroup: 'Erai-raws' }
|
||||
)
|
||||
})
|
||||
|
||||
test('parseRelease: tolerates titles with no group or quality', () => {
|
||||
assert.deepEqual(
|
||||
parseRelease('Naruto Shippuuden - 500'),
|
||||
{ name: 'Naruto Shippuuden', quality: '', subGroup: '' }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// Best-effort extraction of a clean show name, quality, and sub group from a
|
||||
// raw Nyaa release title like "[SubsPlease] Some Show - 12 (1080p) [A1B2].mkv".
|
||||
export function parseRelease(title: string): { name: string; quality: string; subGroup: string } {
|
||||
const subGroup = title.match(/^\[([^\]]+)\]/)?.[1] ?? ''
|
||||
const quality = title.match(/(2160p|1080p|720p|480p)/i)?.[1] ?? ''
|
||||
const name = title
|
||||
.replace(/\.(mkv|mp4|avi)$/i, '')
|
||||
.replace(/[[(][^\])]*[\])]/g, ' ') // strip [..] and (..) groups
|
||||
.replace(/\s-\s*\d{1,4}(v\d)?(?=\s|$)/gi, ' ') // strip " - 12" episode markers
|
||||
.replace(/\b(S\d{1,2}E\d{1,3}|EP?\d{1,3})\b/gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[-–]\s*$/, '')
|
||||
.trim()
|
||||
return { name, quality, subGroup }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type Show, type Episode, type HealthStatus } from '../api'
|
||||
import { api, type Show, type Episode } from '../api'
|
||||
|
||||
interface Props {
|
||||
showId: number
|
||||
@@ -9,32 +9,41 @@ interface Props {
|
||||
export default function ShowDetailPage({ showId, onBack }: Props) {
|
||||
const [show, setShow] = useState<Show | null>(null)
|
||||
const [episodes, setEpisodes] = useState<Episode[]>([])
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [bulkCode, setBulkCode] = useState('')
|
||||
const [bulking, setBulking] = useState(false)
|
||||
const [polling, setPolling] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.shows.get(showId), api.episodes.list(showId), api.health.get()])
|
||||
.then(([s, eps, h]) => { setShow(s); setEpisodes(eps); setHealth(h) })
|
||||
Promise.all([api.shows.get(showId), api.episodes.list(showId)])
|
||||
.then(([s, eps]) => { setShow(s); setEpisodes(eps) })
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load show'))
|
||||
.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 as Episode) : e)))
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.episodes.update(showId, ep.id, status)
|
||||
setEpisodes((prev) => prev.map((e) => (e.id === updated.id ? (updated as Episode) : e)))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update episode')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkMark = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!bulkCode.trim()) return
|
||||
setBulking(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.episodes.bulkMark(showId, bulkCode.trim(), 'downloaded_manual')
|
||||
const eps = await api.episodes.list(showId)
|
||||
setEpisodes(eps)
|
||||
setBulkCode('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Bulk mark failed')
|
||||
} finally {
|
||||
setBulking(false)
|
||||
}
|
||||
@@ -42,21 +51,44 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!show) return
|
||||
const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 })
|
||||
setShow(updated)
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.shows.update(show.id, { is_active: show.is_active ? 0 : 1 })
|
||||
setShow(updated)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update show')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePollNow = async () => {
|
||||
setPolling(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.health.poll()
|
||||
const eps = await api.episodes.list(showId)
|
||||
setEpisodes(eps)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Poll failed')
|
||||
} finally {
|
||||
setPolling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!show) return
|
||||
if (!window.confirm(
|
||||
`Delete "${show.name}"? This permanently removes the show and its episode history. ` +
|
||||
`Already-downloaded .torrent files on disk are kept.`
|
||||
)) return
|
||||
setError('')
|
||||
try {
|
||||
await api.shows.remove(show.id)
|
||||
onBack()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete show')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="empty-state"><div className="spinner" /></div>
|
||||
if (!show) return <div className="empty-state">Show not found.</div>
|
||||
|
||||
@@ -75,16 +107,12 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
|
||||
← Shows
|
||||
</a>
|
||||
|
||||
{/* Health warning banner */}
|
||||
{health && !health.torrent_dir_writable && (
|
||||
{error && (
|
||||
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--red)', background: '#1a0a0a' }}>
|
||||
<div style={{ color: 'var(--red)', fontWeight: 600, marginBottom: 4 }}>⚠ Torrent directory is not writable</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Path: <code>{health.torrent_dir}</code>
|
||||
<div className="flex items-center justify-between" style={{ gap: 8 }}>
|
||||
<span style={{ color: 'var(--red)', fontSize: 13 }}>{error}</span>
|
||||
<button className="ghost" style={{ fontSize: 12 }} onClick={() => setError('')}>Dismiss</button>
|
||||
</div>
|
||||
{health.torrent_dir_error && (
|
||||
<div style={{ fontSize: 11, color: 'var(--red)', marginTop: 4 }}>{health.torrent_dir_error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -106,6 +134,9 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
|
||||
<button className="ghost" onClick={handleToggleActive}>
|
||||
{show.is_active ? 'Pause Polling' : 'Resume Polling'}
|
||||
</button>
|
||||
<button className="danger" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +194,14 @@ export default function ShowDetailPage({ showId, onBack }: Props) {
|
||||
|
||||
{/* Episodes table */}
|
||||
{episodes.length === 0 ? (
|
||||
<div className="empty-state">No episodes found yet. Polling will populate this list.</div>
|
||||
<div className="empty-state">
|
||||
<p>No episodes yet.</p>
|
||||
<p className="text-sm" style={{ marginTop: 6 }}>
|
||||
{show.is_active
|
||||
? 'The first poll runs shortly — or click ↻ Poll Now to fetch immediately.'
|
||||
: 'This show is paused. Resume polling to start fetching episodes.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table>
|
||||
@@ -237,8 +275,14 @@ function EpisodeActions({
|
||||
)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm(
|
||||
`Reset episode ${ep.episode_code} to pending? It will be re-downloaded on the next poll.`
|
||||
)) handle('pending')
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="ghost" style={{ fontSize: 12 }} onClick={() => handle('pending')}>
|
||||
<button className="ghost" style={{ fontSize: 12 }} onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, type Show, type NyaaItem } from '../api'
|
||||
import { parseRelease } from '../lib/parseRelease'
|
||||
|
||||
interface Props {
|
||||
onSelectShow: (id: number) => void
|
||||
@@ -9,6 +10,7 @@ export default function ShowsPage({ onSelectShow }: Props) {
|
||||
const [shows, setShows] = useState<Show[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.shows.list().then(setShows).finally(() => setLoading(false))
|
||||
@@ -23,6 +25,8 @@ export default function ShowsPage({ onSelectShow }: Props) {
|
||||
const handleShowAdded = (show: Show) => {
|
||||
setShows((prev) => [show, ...prev])
|
||||
setShowModal(false)
|
||||
setNotice(`Added “${show.name}” — fetching existing episodes in the background. Open the show to track progress.`)
|
||||
setTimeout(() => setNotice(''), 6000)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -32,6 +36,12 @@ export default function ShowsPage({ onSelectShow }: Props) {
|
||||
<button onClick={() => setShowModal(true)}>+ Add Show</button>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className="card" style={{ marginBottom: 16, borderColor: 'var(--green)', background: '#0a1a0f' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 13 }}>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="empty-state"><div className="spinner" /></div>}
|
||||
|
||||
{!loading && shows.length === 0 && (
|
||||
@@ -83,6 +93,7 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
|
||||
const [results, setResults] = useState<NyaaItem[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -91,6 +102,17 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Clicking a result is an explicit "use this release" action: pre-fill the
|
||||
// form so the user never has to retype (and risk typos in) the query.
|
||||
const handlePick = (item: NyaaItem) => {
|
||||
const parsed = parseRelease(item.title)
|
||||
setSelectedId(item.torrent_id)
|
||||
if (parsed.name) setName(parsed.name)
|
||||
if (parsed.quality) setQuality(parsed.quality)
|
||||
if (parsed.subGroup) setSubGroup(parsed.subGroup)
|
||||
setQuery(searchQ || parsed.name)
|
||||
}
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!searchQ.trim()) return
|
||||
@@ -144,14 +166,26 @@ function AddShowModal({ onClose, onAdded }: { onClose: () => void; onAdded: (s:
|
||||
</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>
|
||||
<>
|
||||
<p className="text-muted text-sm" style={{ marginBottom: 6 }}>
|
||||
Click a result to fill in the form below.
|
||||
</p>
|
||||
<div className="search-results" style={{ marginBottom: 16 }}>
|
||||
{results.slice(0, 20).map((item) => (
|
||||
<div
|
||||
key={item.torrent_id}
|
||||
className={`search-result-item clickable ${selectedId === item.torrent_id ? 'selected' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handlePick(item)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePick(item) } }}
|
||||
>
|
||||
<span className="title">{item.title}</span>
|
||||
<span className="meta">{item.size} · {item.seeders}S</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
|
||||
Reference in New Issue
Block a user