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:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
.env
|
||||||
233
AGENTS.md
Normal file
233
AGENTS.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Build a small, dockerized web service that lets a user:
|
||||||
|
|
||||||
|
- Search and select anime releases from Nyaa.si.
|
||||||
|
- Persist a personal “watch list” of shows and their release patterns.
|
||||||
|
- Poll Nyaa (via RSS or lightweight scraping / API wrapper) for new episodes.
|
||||||
|
- Automatically download the next .torrent file for each tracked show into a host-mounted download directory.
|
||||||
|
- Track which episodes are:
|
||||||
|
- Automatically downloaded (auto-checked),
|
||||||
|
- Manually checked as already downloaded by the user.
|
||||||
|
|
||||||
|
Target deployment is an Unraid server using a single Docker container with a simple web UI and a lightweight persistence layer (SQLite preferred).[^1]
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## High-level Architecture
|
||||||
|
|
||||||
|
- **Frontend**: Minimal web UI (SPA or server-rendered) for:
|
||||||
|
- Searching Nyaa.si.
|
||||||
|
- Adding/removing shows from the watch list.
|
||||||
|
- Viewing episodes per show with status (pending, downloaded).
|
||||||
|
- Manually checking episodes as downloaded.
|
||||||
|
- **Backend**:
|
||||||
|
- HTTP API for the UI.
|
||||||
|
- Nyaa integration (RSS and/or search scraping).
|
||||||
|
- Scheduler/worker to periodically poll Nyaa and enqueue downloads.
|
||||||
|
- Torrent fetcher that downloads `.torrent` files to a host-mounted directory.
|
||||||
|
- **Data store**:
|
||||||
|
- SQLite database stored on a bind-mounted volume for easy backup and migration.
|
||||||
|
- **Containerization**:
|
||||||
|
- Single Docker image with app + scheduler.
|
||||||
|
- Config via environment variables.
|
||||||
|
- Unraid-friendly: configurable ports, volume mapping for DB and torrents.[^2][^1]
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### 1. Nyaa Integration
|
||||||
|
|
||||||
|
- Use Nyaa’s RSS endpoints for polling where possible (e.g. `https://nyaa.si/?page=rss` plus query parameters), falling back to HTML scraping or an existing wrapper library if necessary.[^3][^4][^5][^6][^7]
|
||||||
|
- Support user-driven search:
|
||||||
|
- Input: search term (e.g. “Jujutsu Kaisen 1080p SubsPlease”).
|
||||||
|
- Output: recent matching torrents with:
|
||||||
|
- Title
|
||||||
|
- Torrent ID
|
||||||
|
- Category
|
||||||
|
- Size
|
||||||
|
- Magnet/torrent link URL if exposed in the feed or page.[^8][^9][^10]
|
||||||
|
- When a user “adds” an anime:
|
||||||
|
- Store a normalized pattern to match future episodes (e.g. base title + quality/resolution + sub group).
|
||||||
|
- Maintain reference to the Nyaa search or RSS query that defines this feed.[^6][^3]
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Watch List \& Episodes
|
||||||
|
|
||||||
|
- Entities:
|
||||||
|
- **Show**: id, display name, search/RSS query, quality filter, fansub group, active flag.
|
||||||
|
- **Episode**: id, show_id, episode_number (string or parsed integer), nyaa_torrent_id, title, status (`pending`, `downloaded_auto`, `downloaded_manual`), torrent_url, created_at, downloaded_at.
|
||||||
|
- Behavior:
|
||||||
|
- Adding a show:
|
||||||
|
- Run an immediate search.
|
||||||
|
- Populate existing episodes in DB as `pending` (no download) to let the user backfill by manually checking already downloaded ones.
|
||||||
|
- Removing a show:
|
||||||
|
- Leave episodes in DB but mark show as inactive (no further polling).
|
||||||
|
- Manual check:
|
||||||
|
- User can mark an episode as already downloaded (`downloaded_manual`), no torrent action taken.
|
||||||
|
|
||||||
|
|
||||||
|
### 3. Auto-Download Logic
|
||||||
|
|
||||||
|
- Periodic job (e.g. every 5–15 minutes, configurable):
|
||||||
|
- For each active show:
|
||||||
|
- Query Nyaa using its stored RSS/search parameters.[^4][^3][^6]
|
||||||
|
- Determine the “next” episode:
|
||||||
|
- Prefer simplest rule: highest episode number not yet marked downloaded.
|
||||||
|
- Guard against batch torrents by using size or title pattern heuristics (e.g. skip titles containing “Batch”).
|
||||||
|
- If the next episode’s torrent is not yet in DB:
|
||||||
|
- Create an Episode record with status `downloaded_auto`.
|
||||||
|
- Download the `.torrent` file (NOT the media itself) into the mapped host directory.
|
||||||
|
- Filename suggestion: `<show-slug>-ep<episode>-<torrent-id>.torrent`.
|
||||||
|
- Do not attempt to control or integrate directly with a torrent client (scope is “download the .torrent file” only).
|
||||||
|
|
||||||
|
|
||||||
|
### 4. Web UI
|
||||||
|
|
||||||
|
- Views:
|
||||||
|
- **Shows list**:
|
||||||
|
- Add show (form: name, search query, quality, group).
|
||||||
|
- Toggle active/inactive.
|
||||||
|
- Quick link to show detail.
|
||||||
|
- **Show detail**:
|
||||||
|
- Table of episodes: episode number/title, Nyaa ID, status, timestamps.
|
||||||
|
- Controls:
|
||||||
|
- Manually mark individual episodes as downloaded.
|
||||||
|
- Bulk “mark previous episodes as downloaded” helper (e.g. “mark up to episode N”).
|
||||||
|
- **Settings**:
|
||||||
|
- Poll interval.
|
||||||
|
- Default quality / sub group preferences.
|
||||||
|
- Torrent download directory (read-only display; actual path comes from environment/volume).
|
||||||
|
- UX constraints:
|
||||||
|
- Keep it extremely simple; focus is internal tool.
|
||||||
|
- Assume a single user instance behind LAN.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
- **Language/Stack**:
|
||||||
|
- Prefer Node.js + TypeScript backend with a minimal React or server-rendered frontend to align with existing projects, unless you choose a simpler stack.
|
||||||
|
- **Security**:
|
||||||
|
- App is assumed to run behind LAN; basic auth or reverse-proxy auth can be added later.
|
||||||
|
- Do not expose any admin-only functionality without at least a simple auth hook.
|
||||||
|
- **Resilience**:
|
||||||
|
- Polling should be robust to Nyaa timeouts and 4xx/5xx responses (retry with backoff, log errors).
|
||||||
|
- Do not spam Nyaa with aggressive polling; default interval should be conservative (e.g. 15 minutes, configurable).
|
||||||
|
- **Observability**:
|
||||||
|
- Minimal logging for:
|
||||||
|
- Polling attempts.
|
||||||
|
- New episodes found.
|
||||||
|
- Torrent downloads started/completed or failed.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Data Model (Initial)
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
- `shows`
|
||||||
|
- `id` (PK)
|
||||||
|
- `name` (string)
|
||||||
|
- `search_query` (string)
|
||||||
|
- `quality` (string, nullable)
|
||||||
|
- `sub_group` (string, nullable)
|
||||||
|
- `rss_url` (string, nullable)
|
||||||
|
- `is_active` (boolean, default true)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
- `episodes`
|
||||||
|
- `id` (PK)
|
||||||
|
- `show_id` (FK → shows.id)
|
||||||
|
- `episode_code` (string, e.g. “S01E03” or “03”)
|
||||||
|
- `title` (string)
|
||||||
|
- `torrent_id` (string, Nyaa ID)
|
||||||
|
- `torrent_url` (string)
|
||||||
|
- `status` (enum: `pending`, `downloaded_auto`, `downloaded_manual`)
|
||||||
|
- `downloaded_at` (datetime, nullable)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Container \& Unraid Integration
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
- `PORT` – HTTP port to listen on (default 3000).
|
||||||
|
- `POLL_INTERVAL_SECONDS` – Polling frequency.
|
||||||
|
- `TORRENT_OUTPUT_DIR` – Inside-container path where `.torrent` files are written.
|
||||||
|
- `DATABASE_PATH` – Inside-container path to SQLite file.
|
||||||
|
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
|
||||||
|
- Map SQLite DB to persistent storage:
|
||||||
|
- `/data/db.sqlite` → Unraid share: e.g. `/mnt/user/appdata/nyaa-watcher/db.sqlite`.[^1][^2]
|
||||||
|
- Map torrent output directory to a download share:
|
||||||
|
- `/data/torrents` → e.g. `/mnt/user/downloads/torrents/nyaa/`.
|
||||||
|
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
- Expose app port to LAN (bridge mode):
|
||||||
|
- Container: `3000`, Host: `YOUR_PORT` (e.g. 8082).
|
||||||
|
|
||||||
|
|
||||||
|
### Example docker-compose snippet
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nyaa-watcher:
|
||||||
|
image: your-registry/nyaa-watcher:latest
|
||||||
|
container_name: nyaa-watcher
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- POLL_INTERVAL_SECONDS=900
|
||||||
|
- TORRENT_OUTPUT_DIR=/data/torrents
|
||||||
|
- DATABASE_PATH=/data/db.sqlite
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/nyaa-watcher:/data
|
||||||
|
- /mnt/user/downloads/torrents/nyaa:/data/torrents
|
||||||
|
ports:
|
||||||
|
- "8082:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
This can be translated to an Unraid template or used via docker-compose with a Docker context pointing at the Unraid host.[^11][^2][^1]
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
1. **Skeleton app**
|
||||||
|
- Set up HTTP server, health endpoint, and a static web page.
|
||||||
|
- Wire SQLite with migrations for `shows` and `episodes`.
|
||||||
|
2. **Nyaa client**
|
||||||
|
- Implement RSS-based polling for a hard-coded query.
|
||||||
|
- Parse feed, extract torrent IDs, titles, and links.[^5][^3][^4][^6]
|
||||||
|
- Optionally evaluate an existing node `nyaa-si` wrapper as a shortcut.[^7]
|
||||||
|
3. **Watch list CRUD**
|
||||||
|
- API endpoints + UI for managing shows.
|
||||||
|
- Initial search → show add flow.
|
||||||
|
4. **Episode tracking**
|
||||||
|
- When adding a show, ingest existing feed items into `episodes` as `pending`.
|
||||||
|
- Implement manual check/mark endpoints and UI.
|
||||||
|
5. **Auto-download worker**
|
||||||
|
- Background job to poll active shows and write `.torrent` files.
|
||||||
|
- Update episode status to `downloaded_auto`.
|
||||||
|
6. **Dockerization \& Unraid deployment**
|
||||||
|
- Dockerfile, volume mappings, environment configuration.
|
||||||
|
- Test deployment on Unraid, ensure persistence and torrent file visibility.
|
||||||
|
7. **Polish**
|
||||||
|
- Basic auth or IP allowlist if desired.
|
||||||
|
- Guardrails against batch torrent downloads.
|
||||||
|
- Minimal styling for the UI.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Open Questions for Product Owner
|
||||||
|
|
||||||
|
- What poll interval do you consider acceptable by default (e.g. 5, 10, or 15 minutes)?
|
||||||
|
- Do you want any basic auth in front of the UI out of the box, or will this live behind an existing reverse proxy?
|
||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# ---- Build stage ----
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Production stage ----
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# Copy compiled server
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Default data directories
|
||||||
|
RUN mkdir -p /data/torrents
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATABASE_PATH=/data/db.sqlite
|
||||||
|
ENV TORRENT_OUTPUT_DIR=/data/torrents
|
||||||
|
ENV POLL_INTERVAL_SECONDS=900
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server/index.js"]
|
||||||
78
README.md
78
README.md
@@ -1,21 +1,75 @@
|
|||||||
# nyaa-crawler
|
# nyaa-crawler
|
||||||
|
|
||||||
A Torrent Crawler/Downloader for [Nyaa.si](https://nyaa.si).
|
A dockerized torrent crawler and downloader for [Nyaa.si](https://nyaa.si). Track anime shows, poll for new episodes via RSS, and automatically download `.torrent` files to a host-mounted directory.
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This project provides tools to crawl and download torrents from Nyaa.si, a torrent site primarily focused on anime, manga, and other Japanese media.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Browse and search Nyaa.si torrent listings
|
- Search Nyaa.si and add shows to a watch list
|
||||||
- Download torrents programmatically
|
- Automatic polling for new episodes (configurable interval, default 15 min)
|
||||||
- Filter by category, quality, and other metadata
|
- Auto-downloads `.torrent` files to a mapped host directory
|
||||||
|
- Track episode status: pending, auto-downloaded, or manually marked
|
||||||
|
- Bulk-mark episodes as already downloaded
|
||||||
|
- Minimal dark-themed web UI
|
||||||
|
- SQLite persistence — easy to back up and migrate
|
||||||
|
- Unraid-friendly Docker container
|
||||||
|
|
||||||
## Getting Started
|
## Stack
|
||||||
|
|
||||||
Documentation and setup instructions coming soon.
|
- **Backend**: Node.js + TypeScript + Express
|
||||||
|
- **Frontend**: React + Vite
|
||||||
|
- **Database**: SQLite (`better-sqlite3`)
|
||||||
|
- **Scheduler**: `node-cron`
|
||||||
|
- **Nyaa integration**: RSS via `fast-xml-parser`
|
||||||
|
|
||||||
## License
|
## Quick Start (Docker)
|
||||||
|
|
||||||
TBD
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8082` in your browser.
|
||||||
|
|
||||||
|
## Quick Start (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Client dev server: `http://localhost:5173` (proxies `/api` to `:3000`)
|
||||||
|
- API server: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3000` | HTTP port |
|
||||||
|
| `POLL_INTERVAL_SECONDS` | `900` | Polling frequency (min 60) |
|
||||||
|
| `TORRENT_OUTPUT_DIR` | `./data/torrents` | Where `.torrent` files are saved |
|
||||||
|
| `DATABASE_PATH` | `./data/db.sqlite` | SQLite database path |
|
||||||
|
|
||||||
|
## Unraid Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nyaa-watcher:
|
||||||
|
image: your-registry/nyaa-watcher:latest
|
||||||
|
container_name: nyaa-watcher
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- POLL_INTERVAL_SECONDS=900
|
||||||
|
- TORRENT_OUTPUT_DIR=/data/torrents
|
||||||
|
- DATABASE_PATH=/data/db.sqlite
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/nyaa-watcher:/data
|
||||||
|
- /mnt/user/downloads/torrents/nyaa:/data/torrents
|
||||||
|
ports:
|
||||||
|
- "8082:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Only `.torrent` files are downloaded — media is handled by your existing torrent client watching the output directory.
|
||||||
|
- Batch releases (titles containing "Batch", "Complete", "Vol.", or episode ranges) are automatically skipped.
|
||||||
|
- Removing a show marks it inactive (no further polling) but preserves episode history.
|
||||||
|
|||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
nyaa-watcher:
|
||||||
|
build: .
|
||||||
|
container_name: nyaa-watcher
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- POLL_INTERVAL_SECONDS=900
|
||||||
|
- TORRENT_OUTPUT_DIR=/data/torrents
|
||||||
|
- DATABASE_PATH=/data/db.sqlite
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
ports:
|
||||||
|
- "8082:3000"
|
||||||
3649
package-lock.json
generated
Normal file
3649
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "nyaa-crawler",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
|
"dev:server": "tsx watch src/server/index.ts",
|
||||||
|
"dev:client": "vite",
|
||||||
|
"build": "npm run build:client && npm run build:server",
|
||||||
|
"build:client": "vite build",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"start": "node dist/server/index.js",
|
||||||
|
"migrate": "tsx src/server/db/migrate.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"fast-xml-parser": "^4.3.6",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/react": "^18.2.73",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tsx": "^4.7.1",
|
||||||
|
"typescript": "^5.4.3",
|
||||||
|
"vite": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/client/App.tsx
Normal file
47
src/client/App.tsx
Normal 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
src/client/api.ts
Normal file
90
src/client/api.ts
Normal 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
src/client/index.css
Normal file
127
src/client/index.css
Normal 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
src/client/index.html
Normal file
12
src/client/index.html
Normal 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
src/client/main.tsx
Normal file
10
src/client/main.tsx
Normal 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
src/client/pages/SettingsPage.tsx
Normal file
106
src/client/pages/SettingsPage.tsx
Normal 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
src/client/pages/ShowDetailPage.tsx
Normal file
185
src/client/pages/ShowDetailPage.tsx
Normal 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
src/client/pages/ShowsPage.tsx
Normal file
192
src/client/pages/ShowsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/server/db/index.ts
Normal file
59
src/server/db/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_PATH ?? path.join(process.cwd(), 'data', 'db.sqlite')
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
const dbDir = path.dirname(dbPath)
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new DatabaseSync(dbPath)
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrent read performance
|
||||||
|
db.exec("PRAGMA journal_mode = WAL")
|
||||||
|
db.exec("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
export function runMigrations() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
search_query TEXT NOT NULL,
|
||||||
|
quality TEXT,
|
||||||
|
sub_group TEXT,
|
||||||
|
rss_url TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
episode_code TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
torrent_id TEXT NOT NULL,
|
||||||
|
torrent_url TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK(status IN ('pending','downloaded_auto','downloaded_manual')),
|
||||||
|
downloaded_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(show_id, torrent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||||
|
('poll_interval_seconds', '900'),
|
||||||
|
('default_quality', '1080p'),
|
||||||
|
('default_sub_group', '');
|
||||||
|
`)
|
||||||
|
console.log('[db] Migrations applied.')
|
||||||
|
}
|
||||||
2
src/server/db/migrate.ts
Normal file
2
src/server/db/migrate.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { runMigrations } from './index.js'
|
||||||
|
runMigrations()
|
||||||
37
src/server/index.ts
Normal file
37
src/server/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import path from 'path'
|
||||||
|
import { runMigrations } from './db/index.js'
|
||||||
|
import { startScheduler } from './services/scheduler.js'
|
||||||
|
import showsRouter from './routes/shows.js'
|
||||||
|
import episodesRouter from './routes/episodes.js'
|
||||||
|
import nyaaRouter from './routes/nyaa.js'
|
||||||
|
import settingsRouter from './routes/settings.js'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = parseInt(process.env.PORT ?? '3000', 10)
|
||||||
|
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/shows', showsRouter)
|
||||||
|
app.use('/api/shows/:showId/episodes', episodesRouter)
|
||||||
|
app.use('/api/nyaa', nyaaRouter)
|
||||||
|
app.use('/api/settings', settingsRouter)
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }))
|
||||||
|
|
||||||
|
// Serve static client build in production
|
||||||
|
const clientDist = path.join(__dirname, '../client')
|
||||||
|
app.use(express.static(clientDist))
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(clientDist, 'index.html'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bootstrap
|
||||||
|
runMigrations()
|
||||||
|
startScheduler()
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[server] Listening on http://0.0.0.0:${PORT}`)
|
||||||
|
})
|
||||||
77
src/server/routes/episodes.ts
Normal file
77
src/server/routes/episodes.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Router, type Request } from 'express'
|
||||||
|
import { db } from '../db/index.js'
|
||||||
|
import type { Episode } from '../types.js'
|
||||||
|
|
||||||
|
type EpParams = { showId: string; id: string }
|
||||||
|
|
||||||
|
const router = Router({ mergeParams: true })
|
||||||
|
|
||||||
|
// GET /api/shows/:showId/episodes
|
||||||
|
router.get('/', (req: Request<EpParams>, res) => {
|
||||||
|
const episodes = db
|
||||||
|
.prepare('SELECT * FROM episodes WHERE show_id = ? ORDER BY episode_code ASC, created_at DESC')
|
||||||
|
.all(req.params.showId) as unknown as Episode[]
|
||||||
|
res.json(episodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/shows/:showId/episodes/:id – mark status manually
|
||||||
|
router.patch('/:id', (req: Request<EpParams>, res) => {
|
||||||
|
const ep = db
|
||||||
|
.prepare('SELECT * FROM episodes WHERE id = ? AND show_id = ?')
|
||||||
|
.get(req.params.id, req.params.showId) as unknown as Episode | undefined
|
||||||
|
|
||||||
|
if (!ep) return res.status(404).json({ error: 'Episode not found' })
|
||||||
|
|
||||||
|
const { status } = req.body as { status?: string }
|
||||||
|
const allowed = ['pending', 'downloaded_auto', 'downloaded_manual']
|
||||||
|
if (!status || !allowed.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `status must be one of: ${allowed.join(', ')}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedAt = status === 'pending' ? null : ep.downloaded_at ?? new Date().toISOString()
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE episodes
|
||||||
|
SET status = ?, downloaded_at = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(status, downloadedAt, ep.id)
|
||||||
|
|
||||||
|
res.json(db.prepare('SELECT * FROM episodes WHERE id = ?').get(ep.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/shows/:showId/episodes/bulk-mark – mark episodes up to a given episode_code
|
||||||
|
router.post('/bulk-mark', (req: Request<EpParams>, res) => {
|
||||||
|
const { up_to_code, status } = req.body as { up_to_code: string; status: string }
|
||||||
|
const allowed = ['downloaded_manual', 'pending']
|
||||||
|
if (!up_to_code || !allowed.includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'up_to_code and valid status required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all episodes for this show sorted by code
|
||||||
|
const episodes = db
|
||||||
|
.prepare('SELECT * FROM episodes WHERE show_id = ? ORDER BY CAST(episode_code AS REAL) ASC, episode_code ASC')
|
||||||
|
.all(req.params.showId) as unknown as Episode[]
|
||||||
|
|
||||||
|
const targetIdx = episodes.findIndex((e) => e.episode_code === up_to_code)
|
||||||
|
if (targetIdx === -1) return res.status(404).json({ error: 'Episode code not found' })
|
||||||
|
|
||||||
|
const toUpdate = episodes.slice(0, targetIdx + 1).map((e) => e.id)
|
||||||
|
|
||||||
|
const update = db.prepare(`
|
||||||
|
UPDATE episodes
|
||||||
|
SET status = ?, downloaded_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`)
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const id of toUpdate) update.run(status, id)
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ updated: toUpdate.length })
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
22
src/server/routes/nyaa.ts
Normal file
22
src/server/routes/nyaa.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { searchNyaa } from '../services/nyaa.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// GET /api/nyaa/search?q=...&c=1_2
|
||||||
|
router.get('/search', async (req, res) => {
|
||||||
|
const q = String(req.query.q ?? '').trim()
|
||||||
|
const c = String(req.query.c ?? '1_2')
|
||||||
|
|
||||||
|
if (!q) return res.status(400).json({ error: 'q is required' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchNyaa(q, c)
|
||||||
|
res.json(results)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nyaa] Search error:', err)
|
||||||
|
res.status(502).json({ error: 'Failed to fetch from Nyaa. Please try again.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
43
src/server/routes/settings.ts
Normal file
43
src/server/routes/settings.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { db } from '../db/index.js'
|
||||||
|
import { restartScheduler } from '../services/scheduler.js'
|
||||||
|
import { getTorrentDir } from '../services/downloader.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// GET /api/settings
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const rows = db.prepare('SELECT key, value FROM settings').all() as unknown as { key: string; value: string }[]
|
||||||
|
const settings = Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||||
|
settings['torrent_output_dir'] = getTorrentDir()
|
||||||
|
res.json(settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/settings
|
||||||
|
router.patch('/', (req, res) => {
|
||||||
|
const allowed = ['poll_interval_seconds', 'default_quality', 'default_sub_group']
|
||||||
|
const body = req.body as Record<string, string>
|
||||||
|
|
||||||
|
const entries = Object.entries(body).filter(([k]) => allowed.includes(k)) as [string, string][]
|
||||||
|
if (entries.length === 0) return res.status(400).json({ error: 'No valid settings keys provided' })
|
||||||
|
|
||||||
|
const update = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const [key, value] of entries) update.run(key, String(value))
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart scheduler if interval changed
|
||||||
|
if ('poll_interval_seconds' in body) restartScheduler()
|
||||||
|
|
||||||
|
const rows = db.prepare('SELECT key, value FROM settings').all() as unknown as { key: string; value: string }[]
|
||||||
|
const settings = Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||||
|
settings['torrent_output_dir'] = getTorrentDir()
|
||||||
|
res.json(settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
125
src/server/routes/shows.ts
Normal file
125
src/server/routes/shows.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { db } from '../db/index.js'
|
||||||
|
import { searchNyaa, parseEpisodeCode, isBatchRelease, buildRssUrl } from '../services/nyaa.js'
|
||||||
|
import type { Show } from '../types.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
// GET /api/shows
|
||||||
|
router.get('/', (_req, res) => {
|
||||||
|
const shows = db.prepare('SELECT * FROM shows ORDER BY created_at DESC').all()
|
||||||
|
res.json(shows)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/shows/:id
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(req.params.id) as unknown as Show | undefined
|
||||||
|
if (!show) return res.status(404).json({ error: 'Show not found' })
|
||||||
|
res.json(show)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/shows – add a new show and ingest existing episodes
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { name, search_query, quality, sub_group } = req.body as {
|
||||||
|
name: string
|
||||||
|
search_query: string
|
||||||
|
quality?: string
|
||||||
|
sub_group?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name?.trim() || !search_query?.trim()) {
|
||||||
|
return res.status(400).json({ error: 'name and search_query are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rss_url = buildRssUrl(search_query, '1_2')
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO shows (name, search_query, quality, sub_group, rss_url, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, datetime('now'), datetime('now'))
|
||||||
|
`)
|
||||||
|
.run(name.trim(), search_query.trim(), quality ?? null, sub_group ?? null, rss_url)
|
||||||
|
|
||||||
|
const showId = result.lastInsertRowid
|
||||||
|
|
||||||
|
// Ingest existing Nyaa feed as pending episodes (fire and forget, don't block response)
|
||||||
|
ingestExistingEpisodes(Number(showId), search_query, quality, sub_group).catch((err: unknown) =>
|
||||||
|
console.error('[shows] Ingest error:', err)
|
||||||
|
)
|
||||||
|
|
||||||
|
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(showId)
|
||||||
|
res.status(201).json(show)
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/shows/:id – update name, quality, sub_group, is_active
|
||||||
|
router.patch('/:id', (req, res) => {
|
||||||
|
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(req.params.id) as unknown as Show | undefined
|
||||||
|
if (!show) return res.status(404).json({ error: 'Show not found' })
|
||||||
|
|
||||||
|
const { name, quality, sub_group, is_active, search_query } = req.body as Partial<Show>
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
name: name ?? show.name,
|
||||||
|
quality: quality !== undefined ? quality : show.quality,
|
||||||
|
sub_group: sub_group !== undefined ? sub_group : show.sub_group,
|
||||||
|
is_active: is_active !== undefined ? is_active : show.is_active,
|
||||||
|
search_query: search_query ?? show.search_query,
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE shows
|
||||||
|
SET name = ?, quality = ?, sub_group = ?, is_active = ?, search_query = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(updated.name, updated.quality, updated.sub_group, updated.is_active, updated.search_query, show.id)
|
||||||
|
|
||||||
|
res.json(db.prepare('SELECT * FROM shows WHERE id = ?').get(show.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/shows/:id – marks inactive (does not delete)
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const show = db.prepare('SELECT * FROM shows WHERE id = ?').get(req.params.id) as unknown as Show | undefined
|
||||||
|
if (!show) return res.status(404).json({ error: 'Show not found' })
|
||||||
|
db.prepare("UPDATE shows SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(show.id)
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function ingestExistingEpisodes(
|
||||||
|
showId: number,
|
||||||
|
searchQuery: string,
|
||||||
|
quality?: string,
|
||||||
|
sub_group?: string
|
||||||
|
) {
|
||||||
|
const parts = [searchQuery]
|
||||||
|
if (quality) parts.push(quality)
|
||||||
|
if (sub_group) parts.push(sub_group)
|
||||||
|
const query = parts.join(' ')
|
||||||
|
|
||||||
|
let items
|
||||||
|
try {
|
||||||
|
items = await searchNyaa(query)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[shows] Failed to ingest episodes:', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO episodes
|
||||||
|
(show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now'))
|
||||||
|
`)
|
||||||
|
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
if (isBatchRelease(item.title)) continue
|
||||||
|
insert.run(showId, parseEpisodeCode(item.title), item.title, item.torrent_id, item.torrent_url)
|
||||||
|
}
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
console.log(`[shows] Ingested ${items.length} episodes for show ${showId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router
|
||||||
52
src/server/services/downloader.ts
Normal file
52
src/server/services/downloader.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const TORRENT_DIR = process.env.TORRENT_OUTPUT_DIR ?? path.join(process.cwd(), 'data', 'torrents')
|
||||||
|
|
||||||
|
export function getTorrentDir(): string {
|
||||||
|
return TORRENT_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTorrentDir() {
|
||||||
|
if (!fs.existsSync(TORRENT_DIR)) {
|
||||||
|
fs.mkdirSync(TORRENT_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a .torrent file from the given URL and save it to TORRENT_DIR.
|
||||||
|
* Returns the absolute path of the saved file.
|
||||||
|
*/
|
||||||
|
export async function downloadTorrent(
|
||||||
|
torrentUrl: string,
|
||||||
|
showSlug: string,
|
||||||
|
episodeCode: string,
|
||||||
|
torrentId: string
|
||||||
|
): Promise<string> {
|
||||||
|
ensureTorrentDir()
|
||||||
|
|
||||||
|
const filename = `${showSlug}-ep${episodeCode}-${torrentId}.torrent`
|
||||||
|
const filePath = path.join(TORRENT_DIR, filename)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(torrentUrl, { signal: controller.signal })
|
||||||
|
if (!res.ok) throw new Error(`Failed to download torrent: ${res.status} ${res.statusText}`)
|
||||||
|
const buf = await res.arrayBuffer()
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(buf))
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slugify a show name for use in filenames. */
|
||||||
|
export function slugify(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
105
src/server/services/nyaa.ts
Normal file
105
src/server/services/nyaa.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { XMLParser } from 'fast-xml-parser'
|
||||||
|
import type { NyaaItem } from '../types.js'
|
||||||
|
|
||||||
|
const NYAA_BASE = 'https://nyaa.si'
|
||||||
|
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Nyaa RSS URL from a search query and optional category.
|
||||||
|
* Category defaults to 1_2 (Anime - English-translated).
|
||||||
|
*/
|
||||||
|
export function buildRssUrl(query: string, category = '1_2'): string {
|
||||||
|
const params = new URLSearchParams({ page: 'rss', q: query, c: category, f: '0' })
|
||||||
|
return `${NYAA_BASE}/?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse a Nyaa RSS feed, returning normalized NyaaItems.
|
||||||
|
* Throws on network or parse errors (caller should handle/retry).
|
||||||
|
*/
|
||||||
|
export async function fetchRss(url: string): Promise<NyaaItem[]> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15_000)
|
||||||
|
|
||||||
|
let text: string
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: controller.signal })
|
||||||
|
if (!res.ok) throw new Error(`Nyaa RSS responded ${res.status} ${res.statusText}`)
|
||||||
|
text = await res.text()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parser.parse(text)
|
||||||
|
const items: unknown[] = parsed?.rss?.channel?.item ?? []
|
||||||
|
if (!Array.isArray(items)) return []
|
||||||
|
|
||||||
|
return items.map((item: unknown) => parseItem(item as Record<string, unknown>))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Nyaa via RSS and return results.
|
||||||
|
*/
|
||||||
|
export async function searchNyaa(query: string, category = '1_2'): Promise<NyaaItem[]> {
|
||||||
|
const url = buildRssUrl(query, category)
|
||||||
|
return fetchRss(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItem(item: Record<string, unknown>): NyaaItem {
|
||||||
|
const guid = String(item['guid'] ?? '')
|
||||||
|
// guid is like https://nyaa.si/view/1234567
|
||||||
|
const torrent_id = guid.split('/').pop() ?? guid
|
||||||
|
|
||||||
|
const link = String(item['link'] ?? '')
|
||||||
|
// link in the RSS feed is the magnet or torrent link; torrent download is /download/<id>.torrent
|
||||||
|
const torrent_url = torrent_id
|
||||||
|
? `${NYAA_BASE}/download/${torrent_id}.torrent`
|
||||||
|
: link
|
||||||
|
|
||||||
|
// Nyaa RSS uses nyaa: namespace for extended fields
|
||||||
|
const magnet = item['nyaa:magnetUri'] ?? item['nyaa:magnetLink'] ?? null
|
||||||
|
const category = String(item['nyaa:category'] ?? item['category'] ?? '')
|
||||||
|
const size = String(item['nyaa:size'] ?? '')
|
||||||
|
const seeders = Number(item['nyaa:seeders'] ?? 0)
|
||||||
|
const leechers = Number(item['nyaa:leechers'] ?? 0)
|
||||||
|
const downloads = Number(item['nyaa:downloads'] ?? 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
torrent_id,
|
||||||
|
title: String(item['title'] ?? ''),
|
||||||
|
torrent_url,
|
||||||
|
magnet_url: magnet ? String(magnet) : null,
|
||||||
|
category,
|
||||||
|
size,
|
||||||
|
seeders,
|
||||||
|
leechers,
|
||||||
|
downloads,
|
||||||
|
published: String(item['pubDate'] ?? ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an episode number from a torrent title.
|
||||||
|
* Handles common patterns: " - 12", "[12]", "E12", "EP12", " 12 "
|
||||||
|
* Returns the matched string or 'unknown'.
|
||||||
|
*/
|
||||||
|
export function parseEpisodeCode(title: string): string {
|
||||||
|
// Match patterns like " - 12 " or " - 12v2"
|
||||||
|
let m = title.match(/\s-\s(\d{1,4}(?:\.\d)?(?:v\d)?)\s/)
|
||||||
|
if (m) return m[1]
|
||||||
|
// Match [12] or [12v2]
|
||||||
|
m = title.match(/\[(\d{1,4}(?:\.\d)?(?:v\d)?)\]/)
|
||||||
|
if (m) return m[1]
|
||||||
|
// Match EP12 or E12
|
||||||
|
m = title.match(/[Ee][Pp]?(\d{1,4})/)
|
||||||
|
if (m) return m[1]
|
||||||
|
// Match S01E12
|
||||||
|
m = title.match(/[Ss]\d{1,2}[Ee](\d{1,4})/)
|
||||||
|
if (m) return m[1]
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if title looks like a batch release (should be skipped in auto-download). */
|
||||||
|
export function isBatchRelease(title: string): boolean {
|
||||||
|
return /batch|complete|vol\.|volume|\d+\s*-\s*\d+/i.test(title)
|
||||||
|
}
|
||||||
125
src/server/services/scheduler.ts
Normal file
125
src/server/services/scheduler.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import cron from 'node-cron'
|
||||||
|
import { db } from '../db/index.js'
|
||||||
|
import { fetchRss, parseEpisodeCode, isBatchRelease } from './nyaa.js'
|
||||||
|
import { downloadTorrent, slugify } from './downloader.js'
|
||||||
|
import type { Show, Episode } from '../types.js'
|
||||||
|
|
||||||
|
let currentTask: cron.ScheduledTask | null = null
|
||||||
|
|
||||||
|
export function startScheduler() {
|
||||||
|
const intervalSeconds = getIntervalSeconds()
|
||||||
|
scheduleJob(intervalSeconds)
|
||||||
|
console.log(`[scheduler] Started. Poll interval: ${intervalSeconds}s`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restartScheduler() {
|
||||||
|
if (currentTask) {
|
||||||
|
currentTask.stop()
|
||||||
|
currentTask = null
|
||||||
|
}
|
||||||
|
startScheduler()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntervalSeconds(): number {
|
||||||
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'poll_interval_seconds'").get() as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
return Math.max(60, parseInt(row?.value ?? '900', 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsToCron(seconds: number): string {
|
||||||
|
if (seconds < 60) return '* * * * *'
|
||||||
|
const minutes = Math.round(seconds / 60)
|
||||||
|
if (minutes < 60) return `*/${minutes} * * * *`
|
||||||
|
const hours = Math.round(minutes / 60)
|
||||||
|
return `0 */${hours} * * *`
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleJob(intervalSeconds: number) {
|
||||||
|
const cronExpr = secondsToCron(intervalSeconds)
|
||||||
|
currentTask = cron.schedule(cronExpr, () => {
|
||||||
|
pollAllShows().catch((err: unknown) => {
|
||||||
|
console.error('[scheduler] Poll error:', err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollAllShows() {
|
||||||
|
const shows = db.prepare('SELECT * FROM shows WHERE is_active = 1').all() as unknown as Show[]
|
||||||
|
console.log(`[scheduler] Polling ${shows.length} active show(s)...`)
|
||||||
|
|
||||||
|
for (const show of shows) {
|
||||||
|
try {
|
||||||
|
await pollShow(show)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[scheduler] Error polling show "${show.name}":`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollShow(show: Show) {
|
||||||
|
const rssUrl = show.rss_url ?? buildRssUrl(show)
|
||||||
|
console.log(`[scheduler] Polling "${show.name}" via ${rssUrl}`)
|
||||||
|
|
||||||
|
let items
|
||||||
|
try {
|
||||||
|
items = await fetchRss(rssUrl)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[scheduler] RSS fetch failed for "${show.name}":`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = slugify(show.name)
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (isBatchRelease(item.title)) {
|
||||||
|
console.log(`[scheduler] Skipping batch: ${item.title}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already tracked
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT id FROM episodes WHERE show_id = ? AND torrent_id = ?')
|
||||||
|
.get(show.id, item.torrent_id) as Episode | undefined
|
||||||
|
|
||||||
|
if (existing) continue
|
||||||
|
|
||||||
|
const episodeCode = parseEpisodeCode(item.title)
|
||||||
|
|
||||||
|
// Insert as pending first
|
||||||
|
const insertResult = db
|
||||||
|
.prepare(`
|
||||||
|
INSERT OR IGNORE INTO episodes
|
||||||
|
(show_id, episode_code, title, torrent_id, torrent_url, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), datetime('now'))
|
||||||
|
`)
|
||||||
|
.run(show.id, episodeCode, item.title, item.torrent_id, item.torrent_url)
|
||||||
|
|
||||||
|
if (insertResult.changes === 0) continue
|
||||||
|
|
||||||
|
const epId = insertResult.lastInsertRowid
|
||||||
|
|
||||||
|
// Auto-download
|
||||||
|
try {
|
||||||
|
console.log(`[scheduler] Downloading torrent for "${item.title}"`)
|
||||||
|
await downloadTorrent(item.torrent_url, slug, episodeCode, item.torrent_id)
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE episodes
|
||||||
|
SET status = 'downloaded_auto', downloaded_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(epId)
|
||||||
|
console.log(`[scheduler] Downloaded: ${item.title}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[scheduler] Download failed for "${item.title}":`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRssUrl(show: Show): string {
|
||||||
|
const parts = [show.search_query]
|
||||||
|
if (show.quality) parts.push(show.quality)
|
||||||
|
if (show.sub_group) parts.push(show.sub_group)
|
||||||
|
const query = parts.join(' ')
|
||||||
|
const params = new URLSearchParams({ page: 'rss', q: query, c: '1_2', f: '0' })
|
||||||
|
return `https://nyaa.si/?${params.toString()}`
|
||||||
|
}
|
||||||
43
src/server/types.ts
Normal file
43
src/server/types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/client"]
|
||||||
|
}
|
||||||
16
tsconfig.server.json
Normal file
16
tsconfig.server.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "dist/server",
|
||||||
|
"rootDir": "src/server",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src/server"]
|
||||||
|
}
|
||||||
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: 'src/client',
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist/client',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user