version 2 build
Build and Push Docker Image / build (push) Failing after 1s

This commit is contained in:
Jason Stedwell
2026-06-28 21:47:04 -05:00
parent 8498f7abc4
commit 14ce08c3c2
22 changed files with 462 additions and 116 deletions
+9 -1
View File
@@ -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 })} />
)}
+23
View File
@@ -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>
)
}
+3
View File
@@ -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; }
+24
View File
@@ -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: '' }
)
})
+15
View File
@@ -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 }
}
+62 -18
View File
@@ -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>
)
+42 -8
View File
@@ -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 */}