Imported from jeangabrielrsf/Jellyfin-Automation-Program (
AGENTS.md). Install upstream withnpx skills add jeangabrielrsf/Jellyfin-Automation-Program. Copyright stays with the author.
AGENTS.md — Jellyfin Automation
Full-stack app (FastAPI + React) that automates media downloads for Jellyfin via TMDB search, Jackett torrent indexing, and qBittorrent management.
Monorepo layout
| Directory | Purpose |
|---|---|
backend/ |
FastAPI app, entry app/main.py |
frontend/ |
React 18 + Vite + Tailwind, entry src/main.tsx |
docs/ |
Installation guide (INSTALL.md) and plans/specs |
docker/ |
Docker configs (Avahi mDNS container) |
scripts/ |
build_windows.bat, open_firewall.ps1, start_jellyfin.bat |
Developer commands
Backend (run from backend/)
source venv/bin/activateuvicorn app.main:app --host 0.0.0.0 --port 8000 --reloadpytest tests/ -v— run all tests (uses SQLite in-memory, not PostgreSQL). Integration tests (real ffmpeg,@pytest.mark.integration) are skipped by default; run them withpytest -m integration.alembic upgrade head— run migrations
Frontend (run from frontend/)
npm run dev— dev server on port 3001 (Vite config overrides default 5173)npm run build—tsc && vite build; also serves as the frontend "test" stepnpm run lint— ESLint
Docker
docker-compose up --build -d— full stack- Frontend container exposes 80 (and 3001), backend 8000, Postgres 5432, qBittorrent 8082, Jackett 9117, FlareSolverr 8191
Backend architecture
- Config: Pydantic Settings in
app/config.pyloads.envfrom repo root (env_file=".env"). - DB: SQLAlchemy 2.0 + PostgreSQL in prod; Alembic migrations in
backend/alembic/. - Tests: pytest + pytest-asyncio.
conftest.pyoverridesget_dbwith SQLite in-memory (StaticPool). - Logging: Loguru + structlog. Logs written to
backend/logs/app.logwith rotation. - Models:
download.py,settings.py,tmdb.py,torrent.py,discover.pyinapp/models/. - Routers:
search,downloads,settings,logs,filesystem,discover,streaminapp/routers/. - Services:
PathResolver(app/services/path_resolver.py) computes save paths from torrent metadata;DownloadWorker(app/services/download_worker.py) monitors qBittorrent progress in a background loop;OrganizerService(app/services/organizer_service.py) moves completed downloads to library folders;DiscoverService(app/services/discover_service.py) provides TMDB browse sections;OMDBService(app/services/omdb_service.py) fetches Rotten Tomatoes ratings;PathConverter(app/services/path_converter.py) converts Windows↔WSL paths;ConfigService(app/services/config_service.py) providesget_config()with DB→.env priority chain;StreamService(app/services/stream_service.py) resolves playable files, decides direct-vs-transcode via ffprobe (cached by path+mtime), andStreamSessionManager(module singletonstream_manager) runs HLS transcode sessions — ffmpeg process + segments dir keyed by(download_id, episode), touch-on-request with 60s idle sweep (background thread started in lifespan), episode switch kills the previous session, capacity 3 with 503. - Scrapers:
JackettScraper(app/scrapers/jackett_scraper.py) withBaseScraperabstract interface. - Exceptions:
ConfigurationError,StreamLimitError,StreamTranscodeErrorinapp/exceptions.py. - WebSocket:
/wsendpoint inapp/main.pybroadcasts download updates. - Health:
/healthendpoint returns app status. - Static files:
main.pymounts../frontend/distat/for production serving; if missing, app starts without it. - Background worker:
DownloadWorkeris started in the FastAPI lifespan and syncs qBittorrent state every 10 seconds.
Configuration system
- Priority chain: DB →
.env→ error. All API keys and service URLs are stored in thesettingstable and read viaget_config(key, db, required=True)fromapp/services/config_service.py. - Seed on startup: On first boot,
_seed_config_from_env()inmain.pypopulates the DB with values from.envfor any missing keys. - Runtime updates: Settings can be changed via the Settings UI (
PUT /api/settings/{key}). Services pick up new values on their next instantiation (most services are created per-request; DownloadWorker creates fresh instances every 10s). @lru_cache()onget_settings(): The.envsettings object is cached for the process lifetime. DB values override cached.envvalues viaget_config().
Frontend architecture
- Path alias:
@/→src/(configured in bothvite.config.tsandtsconfig.json). - Proxy: Vite dev server proxies
/apiand/wstolocalhost:8000. - Stack: React Router, TanStack Query, Axios, shadcn/ui components in
src/components/ui/. - Strict TS:
noUnusedLocalsandnoUnusedParametersare enabled. - Pages:
Home,Search,Discover,Detail,Downloads,Settings,Logs. - Components:
Header,SearchBar,MediaCard,TorrentList,DownloadMonitor,DiscoverFilterBar,DiscoverRow,FolderPickerDialog,ThemeToggle,ui/(shadcn). - UI Components: Uses shadcn/ui components. New components should be added via
npx shadcn@latest add <component>.- Dialogs/modals must use the
Dialogcomponent from@/components/ui/dialog— no custom modal implementations. - Toast notifications use
sonner(toast.success()/toast.error()) — never use nativealert().
- Dialogs/modals must use the
Download flow
- User searches TMDB → selects media (and season/episode for TV) → sees torrent results from Jackett
- Frontend calls
POST /api/downloads/with:magnet_link(optional) — magnet URI if available from indexerdownload_url(optional) — Jackett proxy link for .torrent fileseason/episode(optional) — for series/anime episodes
- Backend uses
PathResolverto compute the save path from title, media type, torrent name, and season/episode - Backend saves to DB with status
PENDING, includingseason,episode, andsource_folder - Backend immediately tries to add to qBittorrent with the computed
save_path:- If
magnet_linkis present → sends magnet URI directly to qBittorrent - If only
download_urlis present → downloads .torrent file from Jackett and uploads to qBittorrent - If download fails (e.g., link expired) → attempts to refresh link via Jackett API
- If
- On success → status becomes
DOWNLOADING; on failure →FAILEDwitherror_message DownloadWorker(background task) monitors qBittorrent every 10 seconds:- Updates
progress,speed,eta, andstatusin the database - When a download reaches
COMPLETED, triggersOrganizerServiceto move files to the appropriate library folder (movies_path,series_path, oranimes_path)
- Updates
Docker services
| Service | Image | Ports | Notes |
|---|---|---|---|
| db | postgres:15-alpine |
5432 | PostgreSQL database |
| backend | Custom build | 8000 | FastAPI app |
| frontend | Custom build | 80, 3001 | React + nginx |
| avahi | Custom build | — | mDNS for jellyfin.local (host network) |
| qbittorrent | lscr.io/linuxserver/qbittorrent |
8082, 6881 | Torrent client (host port 8082) |
| jackett | lscr.io/linuxserver/jackett |
9117 | Torrent indexer gateway |
| flaresolverr | ghcr.io/flaresolverr/flaresolverr |
8191 | Cloudflare bypass for Jackett |
qBittorrent notes:
- Generates a temporary password on first run. Set a permanent password via Web UI → Settings → Web UI → Authentication.
- CSRF protection and host header validation are disabled to allow localhost access.
- Backend connects via Docker network (
http://qbittorrent:8080), not the host port. - qBittorrent v5 returns HTTP 204 (empty body) on successful login, not HTTP 200 with "Ok." —
QBittorrentService._authenticate()handles both.
Jackett notes:
- Comes with no indexers configured. User must add indexers via Web UI at
http://localhost:9117. - FlareSolverr must be configured in Jackett settings: URL =
http://flaresolverr:8191. - API key is generated on first run and displayed in the Web UI header.
Environment / gotchas
.envmust be at repo root;backend/alembic.inihardcodes a fallback DB URL but the app usesDATABASE_URLfrom.env.- Media paths (
MOVIES_PATH,SERIES_PATH,ANIMES_PATH) must be absolute and writable. - Jellyfin runs externally (typically on Windows host). Use
http://host.docker.internal:8096from Docker containers. dist/is in.gitignore; frontend must be built before backend can serve static files.jackett/config/andqbittorrent/config/are in.gitignore— they contain runtime state and credentials.- Trailing slashes matter: FastAPI routes are defined with trailing slashes (e.g.,
/api/downloads/). Frontend must use trailing slashes to avoid 307 redirects. - Jackett links expire:
download_urlfrom Jackett search results may expire after a few minutes. The backend implements fallback logic to refresh expired links. - DownloadWorker runs on startup: The background worker starts automatically with the FastAPI app and cannot be disabled without code changes.
- OrganizerService moves files on completion: Completed downloads are automatically organized into
MOVIES_PATH,SERIES_PATH, orANIMES_PATHbased on media type. Ensure these paths are writable. - ConfigError on missing settings: If a required config key is missing from both DB and
.env, the API returns HTTP 500 with{"error": "configuration_error", "key": "...", "message": "..."}. - nginx.conf uses Docker service names: The frontend nginx proxies to
http://backend:8000, notbackend-hostand serves plain HTTP on port 80 (published on both 80 and 3001). - Avahi mDNS broadcasts
jellyfin.localon the local network via host network mode — Debian-based container (Alpine avahi-daemon crashes). - WSL2 mirrored mode is required for Avahi mDNS to work. Configure
C:\Users\<user>\.wslconfigwith[wsl2] networkingMode=mirrored firewall=falseand runwsl --shutdown. NAT mode (default) blocks multicast from crossing the WSL2 boundary. - WSL2 mirrored mode self-to-self limitation: the Windows host cannot reach its own external IP (e.g.
192.168.10.100) on Docker-published ports from itself. LAN devices (phones, other PCs) work fine, but the host itself must use127.0.0.1. This is why the Windowshostsfile must mapjellyfin.localto127.0.0.1(not the LAN IP) for desktop access. - Windows mDNS resolver is unreliable —
EnableMDNS=1underHKLM:\SYSTEM\CurrentControlSet\Services\Dnscache\Parametersdoes not guarantee resolution. Use thehostsfile as the canonical solution. - PowerShell
Set-Contenton system files is destructive without admin — it truncates the file to 0 bytes before failing access denied. Always run elevated, or useAdd-Content/Out-File -Appendafter verifying the process is admin. - This project runs on WSL2.
Running a single test
cd backend
pytest tests/test_tmdb_service.py -v
pytest tests/test_scrapers.py::test_jackett_search -v
Agent skills
Issue tracker
GitHub Issues via gh CLI. See docs/agents/issue-tracker.md.
Triage labels
Five canonical roles with default names. See docs/agents/triage-labels.md.
Domain docs
Single-context layout (root CONTEXT.md + docs/adr/). See docs/agents/domain.md.