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:
Paperclip Agent
2026-06-13 17:54:45 +02:00
parent 0745aa3c3e
commit 20be1d73be
24 changed files with 775 additions and 2 deletions

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@ node_modules/
.env
.env.*
.DS_Store
__pycache__/
*.pyc
dist/
.venv/

View File

@@ -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
View 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
View 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
View File

15
backend/src/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

105
worker/src/worker.py Normal file
View 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()