feat: implement minimal vertical slice — backend, worker, frontend, docker-compose
- FastAPI backend with /health, /api/search, /api/downloads CRUD + retry - Provider abstraction (mock provider for architecture validation) - SQLAlchemy models with DownloadStatus state machine - Python polling worker simulating job lifecycle without real downloads - React/Vite/TypeScript frontend: search, results table, download queue with progress - Docker Compose wiring all services with postgres healthcheck - .env.example, updated README and .gitignore Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@ node_modules/
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
.venv/
|
||||
|
||||
55
README.md
55
README.md
@@ -1,5 +1,56 @@
|
||||
# project-kino
|
||||
|
||||
Initial Paperclip workspace commit.
|
||||
Docker-hosted web GUI for searching and downloading movies/series, with Jellyfin integration.
|
||||
|
||||
This repository was initialized to provide a valid default Git ref for Paperclip worktree-based execution.
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env — set DB_PASSWORD, SECRET_KEY, JELLYFIN_URL, JELLYFIN_API_KEY
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend API: http://localhost:8000
|
||||
- Health: http://localhost:8000/health
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
frontend (React/Vite + nginx)
|
||||
└─ /api/* → backend (FastAPI)
|
||||
└─ PostgreSQL
|
||||
worker (Python polling)
|
||||
└─ reads pending downloads from DB, simulates job execution
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | /health | Health check |
|
||||
| GET | /api/search?q= | Search across configured providers |
|
||||
| POST | /api/downloads | Queue a download |
|
||||
| GET | /api/downloads | List all downloads |
|
||||
| GET | /api/downloads/:id | Get one download |
|
||||
| POST | /api/downloads/:id/retry | Retry a failed download |
|
||||
|
||||
## Providers
|
||||
|
||||
Providers are operator-configured search sources. The default `mock` provider returns
|
||||
test data. Add real providers by implementing `BaseProvider` in `backend/src/providers.py`
|
||||
and registering them in the `_registry`.
|
||||
|
||||
No hardcoded piracy sources. All download URLs come from operator-configured providers only.
|
||||
|
||||
## Configuration
|
||||
|
||||
All secrets via environment variables or `.env` (never committed):
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `SECRET_KEY` | App secret key |
|
||||
| `JELLYFIN_URL` | Jellyfin base URL |
|
||||
| `JELLYFIN_API_KEY` | Jellyfin API key |
|
||||
| `MEDIA_PATH` | Local path for downloaded media |
|
||||
|
||||
9
backend/Dockerfile
Normal file
9
backend/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src/ ./src/
|
||||
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy==2.0.30
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.7.1
|
||||
pydantic-settings==2.2.1
|
||||
httpx==0.27.0
|
||||
python-multipart==0.0.9
|
||||
0
backend/src/__init__.py
Normal file
0
backend/src/__init__.py
Normal file
15
backend/src/config.py
Normal file
15
backend/src/config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql://kino:kino@localhost:5432/kino"
|
||||
secret_key: str = "changeme"
|
||||
jellyfin_url: str = "http://localhost:8096"
|
||||
jellyfin_api_key: str = ""
|
||||
media_path: str = "/media"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
18
backend/src/database.py
Normal file
18
backend/src/database.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
from .config import settings
|
||||
|
||||
engine = create_engine(settings.database_url)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
66
backend/src/main.py
Normal file
66
backend/src/main.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from .database import engine, get_db
|
||||
from .models import Base, Download, DownloadStatus
|
||||
from .schemas import SearchResult, DownloadCreate, DownloadRead
|
||||
from .providers import get_providers
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="project-kino", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/search", response_model=list[SearchResult])
|
||||
async def search(q: str):
|
||||
results: list[SearchResult] = []
|
||||
for provider in get_providers():
|
||||
results.extend(await provider.search(q))
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/api/downloads", response_model=DownloadRead, status_code=201)
|
||||
def create_download(payload: DownloadCreate, db: Session = Depends(get_db)):
|
||||
dl = Download(**payload.model_dump())
|
||||
db.add(dl)
|
||||
db.commit()
|
||||
db.refresh(dl)
|
||||
return dl
|
||||
|
||||
|
||||
@app.get("/api/downloads", response_model=list[DownloadRead])
|
||||
def list_downloads(db: Session = Depends(get_db)):
|
||||
return db.query(Download).order_by(Download.created_at.desc()).all()
|
||||
|
||||
|
||||
@app.get("/api/downloads/{download_id}", response_model=DownloadRead)
|
||||
def get_download(download_id: int, db: Session = Depends(get_db)):
|
||||
dl = db.get(Download, download_id)
|
||||
if not dl:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return dl
|
||||
|
||||
|
||||
@app.post("/api/downloads/{download_id}/retry", response_model=DownloadRead)
|
||||
def retry_download(download_id: int, db: Session = Depends(get_db)):
|
||||
dl = db.get(Download, download_id)
|
||||
if not dl:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if dl.status not in (DownloadStatus.failed,):
|
||||
raise HTTPException(status_code=400, detail="Can only retry failed downloads")
|
||||
dl.status = DownloadStatus.pending
|
||||
dl.error = None
|
||||
dl.progress = 0.0
|
||||
db.commit()
|
||||
db.refresh(dl)
|
||||
return dl
|
||||
38
backend/src/models.py
Normal file
38
backend/src/models.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Float, DateTime, Enum as SAEnum, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .database import Base
|
||||
|
||||
|
||||
class DownloadStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
downloading = "downloading"
|
||||
done = "done"
|
||||
failed = "failed"
|
||||
transferring = "transferring"
|
||||
transferred = "transferred"
|
||||
|
||||
|
||||
class Download(Base):
|
||||
__tablename__ = "downloads"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
url: Mapped[str] = mapped_column(String(2000))
|
||||
provider: Mapped[str] = mapped_column(String(100))
|
||||
quality: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
language: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[DownloadStatus] = mapped_column(
|
||||
SAEnum(DownloadStatus), default=DownloadStatus.pending
|
||||
)
|
||||
progress: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
error: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
dest_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
48
backend/src/providers.py
Normal file
48
backend/src/providers.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Provider abstraction layer.
|
||||
|
||||
Providers supply search results from operator-configured, legitimate sources.
|
||||
No hardcoded piracy sources — each provider is loaded from runtime config.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from .schemas import SearchResult
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
...
|
||||
|
||||
|
||||
class MockProvider(BaseProvider):
|
||||
"""Stub provider for architecture validation. Returns fake results."""
|
||||
|
||||
name = "mock"
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(
|
||||
id=f"mock-{i}",
|
||||
title=f"{query} (Mock Result {i})",
|
||||
provider=self.name,
|
||||
url=f"https://example.com/mock/{i}",
|
||||
quality="1080p",
|
||||
language="de",
|
||||
size_bytes=4_000_000_000,
|
||||
year=2024,
|
||||
media_type="movie",
|
||||
)
|
||||
for i in range(1, 4)
|
||||
]
|
||||
|
||||
|
||||
_registry: dict[str, BaseProvider] = {
|
||||
"mock": MockProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_providers() -> list[BaseProvider]:
|
||||
return list(_registry.values())
|
||||
42
backend/src/schemas.py
Normal file
42
backend/src/schemas.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from .models import DownloadStatus
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
provider: str
|
||||
url: str
|
||||
quality: str | None = None
|
||||
language: str | None = None
|
||||
size_bytes: int | None = None
|
||||
year: int | None = None
|
||||
media_type: str = "movie"
|
||||
|
||||
|
||||
class DownloadCreate(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
provider: str
|
||||
quality: str | None = None
|
||||
language: str | None = None
|
||||
size_bytes: int | None = None
|
||||
|
||||
|
||||
class DownloadRead(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
url: str
|
||||
provider: str
|
||||
quality: str | None
|
||||
language: str | None
|
||||
size_bytes: int | None
|
||||
status: DownloadStatus
|
||||
progress: float
|
||||
error: str | None
|
||||
dest_path: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: kino
|
||||
POSTGRES_USER: kino
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-kino}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kino"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql://kino:${DB_PASSWORD:-kino}@db:5432/kino
|
||||
SECRET_KEY: ${SECRET_KEY:-changeme}
|
||||
JELLYFIN_URL: ${JELLYFIN_URL:-http://localhost:8096}
|
||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:-}
|
||||
MEDIA_PATH: /media
|
||||
volumes:
|
||||
- media_data:/media
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
worker:
|
||||
build: ./worker
|
||||
environment:
|
||||
DATABASE_URL: postgresql://kino:${DB_PASSWORD:-kino}@db:5432/kino
|
||||
MEDIA_PATH: /media
|
||||
JELLYFIN_URL: ${JELLYFIN_URL:-http://localhost:8096}
|
||||
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:-}
|
||||
volumes:
|
||||
- media_data:/media
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
media_data:
|
||||
10
frontend/Dockerfile
Normal file
10
frontend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>project-kino</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
frontend/nginx.conf
Normal file
13
frontend/nginx.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "project-kino-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.12"
|
||||
}
|
||||
}
|
||||
154
frontend/src/App.tsx
Normal file
154
frontend/src/App.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { search, createDownload, listDownloads, retryDownload } from "./api";
|
||||
import type { SearchResult, Download } from "./api";
|
||||
|
||||
function formatBytes(b?: number) {
|
||||
if (!b) return "–";
|
||||
const gb = b / 1e9;
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1e6).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [downloads, setDownloads] = useState<Download[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchDownloads = useCallback(async () => {
|
||||
try {
|
||||
setDownloads(await listDownloads());
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDownloads();
|
||||
const id = setInterval(fetchDownloads, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchDownloads]);
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResults(await search(query));
|
||||
} catch {
|
||||
setError("Suche fehlgeschlagen");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(r: SearchResult) {
|
||||
try {
|
||||
await createDownload(r);
|
||||
await fetchDownloads();
|
||||
} catch {
|
||||
setError("Download konnte nicht gestartet werden");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(id: number) {
|
||||
try {
|
||||
await retryDownload(id);
|
||||
await fetchDownloads();
|
||||
} catch {
|
||||
setError("Retry fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "1rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<h1 style={{ marginBottom: "1.5rem" }}>🎬 project-kino</h1>
|
||||
|
||||
<form onSubmit={handleSearch} style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Film oder Serie suchen…"
|
||||
style={{ flex: 1, padding: "0.5rem", fontSize: "1rem" }}
|
||||
/>
|
||||
<button type="submit" disabled={searching} style={{ padding: "0.5rem 1rem" }}>
|
||||
{searching ? "…" : "Suchen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
|
||||
{results.length > 0 && (
|
||||
<section style={{ marginBottom: "2rem" }}>
|
||||
<h2>Suchergebnisse</h2>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f0f0f0" }}>
|
||||
<th style={th}>Titel</th>
|
||||
<th style={th}>Qualität</th>
|
||||
<th style={th}>Sprache</th>
|
||||
<th style={th}>Größe</th>
|
||||
<th style={th}>Provider</th>
|
||||
<th style={th}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={td}>{r.title}</td>
|
||||
<td style={td}>{r.quality ?? "–"}</td>
|
||||
<td style={td}>{r.language ?? "–"}</td>
|
||||
<td style={td}>{formatBytes(r.size_bytes)}</td>
|
||||
<td style={td}>{r.provider}</td>
|
||||
<td style={td}>
|
||||
<button onClick={() => handleDownload(r)}>↓ Download</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2>Download-Queue</h2>
|
||||
{downloads.length === 0 ? (
|
||||
<p style={{ color: "#888" }}>Keine Downloads</p>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ background: "#f0f0f0" }}>
|
||||
<th style={th}>Titel</th>
|
||||
<th style={th}>Status</th>
|
||||
<th style={th}>Fortschritt</th>
|
||||
<th style={th}>Fehler</th>
|
||||
<th style={th}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{downloads.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td style={td}>{d.title}</td>
|
||||
<td style={td}>{d.status}</td>
|
||||
<td style={td}>
|
||||
<progress value={d.progress} max={1} style={{ width: "100%" }} />
|
||||
</td>
|
||||
<td style={{ ...td, color: "red", fontSize: "0.85em" }}>{d.error ?? ""}</td>
|
||||
<td style={td}>
|
||||
{d.status === "failed" && (
|
||||
<button onClick={() => handleRetry(d.id)}>Retry</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { padding: "0.4rem 0.6rem", textAlign: "left", borderBottom: "1px solid #ccc" };
|
||||
const td: React.CSSProperties = { padding: "0.4rem 0.6rem", borderBottom: "1px solid #eee" };
|
||||
64
frontend/src/api.ts
Normal file
64
frontend/src/api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
provider: string;
|
||||
url: string;
|
||||
quality?: string;
|
||||
language?: string;
|
||||
size_bytes?: number;
|
||||
year?: number;
|
||||
media_type: string;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
provider: string;
|
||||
quality?: string;
|
||||
language?: string;
|
||||
size_bytes?: number;
|
||||
status: "pending" | "downloading" | "done" | "failed" | "transferring" | "transferred";
|
||||
progress: number;
|
||||
error?: string;
|
||||
dest_path?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
export async function search(q: string): Promise<SearchResult[]> {
|
||||
const r = await fetch(`${BASE}/search?q=${encodeURIComponent(q)}`);
|
||||
if (!r.ok) throw new Error("Search failed");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function createDownload(result: SearchResult): Promise<Download> {
|
||||
const r = await fetch(`${BASE}/downloads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
provider: result.provider,
|
||||
quality: result.quality,
|
||||
language: result.language,
|
||||
size_bytes: result.size_bytes,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error("Failed to create download");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function listDownloads(): Promise<Download[]> {
|
||||
const r = await fetch(`${BASE}/downloads`);
|
||||
if (!r.ok) throw new Error("Failed to fetch downloads");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function retryDownload(id: number): Promise<Download> {
|
||||
const r = await fetch(`${BASE}/downloads/${id}/retry`, { method: "POST" });
|
||||
if (!r.ok) throw new Error("Failed to retry download");
|
||||
return r.json();
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://backend:8000",
|
||||
"/health": "http://backend:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
8
worker/Dockerfile
Normal file
8
worker/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src/ ./src/
|
||||
|
||||
CMD ["python", "-m", "src.worker"]
|
||||
4
worker/requirements.txt
Normal file
4
worker/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
sqlalchemy==2.0.30
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic-settings==2.2.1
|
||||
httpx==0.27.0
|
||||
0
worker/src/__init__.py
Normal file
0
worker/src/__init__.py
Normal file
105
worker/src/worker.py
Normal file
105
worker/src/worker.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Download worker — polls for pending jobs and simulates job execution.
|
||||
No real downloads in this skeleton; proves the state machine and DB integration.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import String, Integer, Float, DateTime, Enum as SAEnum, func
|
||||
import enum
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql://kino:kino@db:5432/kino"
|
||||
media_path: str = "/media"
|
||||
poll_interval: int = 5
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
engine = create_engine(settings.database_url)
|
||||
|
||||
|
||||
class DownloadStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
downloading = "downloading"
|
||||
done = "done"
|
||||
failed = "failed"
|
||||
transferring = "transferring"
|
||||
transferred = "transferred"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Download(Base):
|
||||
__tablename__ = "downloads"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
url: Mapped[str] = mapped_column(String(2000))
|
||||
provider: Mapped[str] = mapped_column(String(100))
|
||||
quality: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
language: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[DownloadStatus] = mapped_column(
|
||||
SAEnum(DownloadStatus), default=DownloadStatus.pending
|
||||
)
|
||||
progress: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
error: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
dest_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
created_at: Mapped[object] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[object] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
def process_job(db: Session, dl: Download) -> None:
|
||||
log.info("Processing job %d: %s", dl.id, dl.title)
|
||||
dl.status = DownloadStatus.downloading
|
||||
dl.progress = 0.0
|
||||
db.commit()
|
||||
|
||||
# Simulate download progress
|
||||
for step in range(1, 6):
|
||||
time.sleep(1)
|
||||
dl.progress = step / 5
|
||||
db.commit()
|
||||
log.info("Job %d progress: %.0f%%", dl.id, dl.progress * 100)
|
||||
|
||||
dl.status = DownloadStatus.done
|
||||
dl.progress = 1.0
|
||||
dl.dest_path = f"{settings.media_path}/{dl.title}"
|
||||
db.commit()
|
||||
log.info("Job %d done: %s", dl.id, dl.dest_path)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
log.info("Worker started, polling every %ds", settings.poll_interval)
|
||||
while True:
|
||||
with Session(engine) as db:
|
||||
pending = db.execute(
|
||||
select(Download).where(Download.status == DownloadStatus.pending).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if pending:
|
||||
try:
|
||||
process_job(db, pending)
|
||||
except Exception as exc:
|
||||
log.error("Job %d failed: %s", pending.id, exc)
|
||||
pending.status = DownloadStatus.failed
|
||||
pending.error = str(exc)
|
||||
db.commit()
|
||||
time.sleep(settings.poll_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user