This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Trailarr is a Docker-based application that automates downloading and managing trailers for Radarr and Sonarr media libraries. Plex is a first-class connection type that works alongside Arr connections. Emby and Jellyfin are also supported.
- Backend: Python 3.13 + FastAPI + SQLModel + Alembic + SQLite
- Frontend: Angular 22 (standalone components, Signals) + TypeScript strict mode
- Task Scheduler: Quiv (async task scheduler, replaced APScheduler)
- Dependency manager:
uvfor Python,npmfor frontend
# Backend (from /app/backend) - takes ~60s
uv sync
# Frontend (from /app/frontend) - takes ~90s
npm install# FastAPI backend (from /app/backend) - requires database setup first
PYTHONPATH=$(pwd) APP_DATA_DIR=/tmp/trailarr-config uvicorn main:trailarr_api --host 0.0.0.0 --port 7888
# Angular frontend (from /app/frontend) - proxies API to localhost:7888
npm run start # http://localhost:4200# Required before running the backend server
mkdir -p /tmp/trailarr-config/logs /tmp/trailarr-config/web
cd backend
APP_DATA_DIR=/tmp/trailarr-config uv run alembic upgrade headDon't rely on npm run build / npm run test alone to confirm a frontend change actually works — drive the real app:
# 1. Build the frontend (outputs to ../frontend-build/)
cd frontend && npm run build
# 2. From repo root, launch the real app (backs up DB, runs migrations, serves API + built
# frontend together on port 7890 — this is the same path a production install uses,
# see backend/frontend/router.py)
python3 scripts/launch.py
# 3. Drive http://localhost:7890 with headless Chromium (Playwright) and check console errorsscripts/launch.py uses the persistent dev config at config/.env / config/trailarr.db (gitignored; NOT config-dev/, which is a copy of a real library — never write to it). That .env does not disable auth, so launch with WEBUI_DISABLE_AUTH=true python3 scripts/launch.py to skip the login page (the shell env var wins because load_dotenv(override=False)), or sign in with admin/trailarr. Stop it with pkill -f "scripts/launch.py" and pkill -f "uvicorn main:trailarr_api" (it execs into uv run uvicorn, so both process names can exist). Prefer this over ng serve + src/proxy.conf.json for verification — the dev proxy is a different code path (Angular dev server proxying to the backend) than the production static-file serving in router.py.
# Backend tests (from /app/backend) - ~680 tests, ~35s
# Note: use `uv run python` — plain `python` is not in PATH
PYTHONPATH=$(pwd) uv run python -m pytest tests/ -v
PYTHONPATH=$(pwd) uv run python -m pytest tests/path/to/test_file.py -v # single file
# Frontend tests (from /app/frontend) - Vitest, ~2s
npm run test
npm run test:coverage# Frontend production build (from /app/frontend) - ~10s, output to ../frontend-build/
npm run build
# Docker image (~15-30 min; may fail in CI due to SSL/PyPI issues)
docker build --tag trailarr:latest .# Build the documentation site (from repo root, outputs to site/)
# NOT `mkdocs build` — mkdocs.yml references mkdocs-material extensions, but the
# `material` package isn't installed in the backend venv. This project actually
# builds with zensical instead.
uv run --project backend zensical build# From /app/backend
uv run alembic revision --autogenerate -m "Description of changes"
uv run alembic upgrade headLayered architecture:
main.py— FastAPI app entry point (trailarr_api)api/v1/— Route handlers (media, tasks, connections, settings, events, logs, websockets, etc.)services/— Business logicconnections/base.py—BaseConnectionManagerfor Arr connections (shared refresh, create/update/delete logic)connections/arr/— Base Radarr/Sonarr integration, withradarr/andsonarr/subpackagesconnections/plex/— Plex connection manager, API client, data parser, modelstrailers/— Trailer download orchestration via yt-dlpdiagnostics/— Connection Doctor, health checks, cookies status, report storefiles/— File management (files_handler.py) and the media scannerimages/,notifications/,updates/,binaries.py— image download, Apprise dispatch, Docker update check, startup binary-path checksprofiles.py,filters.py,satisfaction.py— profile and filter helpers
database/— Persistence layermodels/— SQLModel ORM modelsmanager/— Database access managers (one per model type)engine.py,init_db.py,version_guard.py
tasks/— Quiv scheduler setup (__init__.py), schedules (schedules.py), and task implementationsutils/— Pure helpers with no dependency on the other layers (path_utils.py,error_classify.py). Bothdatabase/andservices/may import these;utils/imports none of them.config/settings.py— Environment-based configuration; settings persisted to.envinAPP_DATA_DIRtests/— pytest test suite, mirroring the tree above
Layering rule: api/ → services/ → database/, with utils/ importable by
anything and importing nothing. database/ must not import from services/. Nine such
imports remain (connection validation calling the Arr/Plex clients, plus one
notification dispatch); Phase 7 Stage B removes them. Do not add more.
Key patterns:
- All endpoints and background tasks are async/await
- Database migrations auto-applied on startup via
init_db() - WebSocket broadcasting for real-time task/event updates
APP_DATA_DIRenv var required for all runtime operationsEventTypeis stored as VARCHAR (native_enum=False) — adding new enum values does not require an Alembic migration
Plex ↔ Arr media linking:
Plex and Arr connections can track the same physical media. The system merges by folder_path:
- Plex sync: before creating, checks
read_by_folder_path()— if an Arr row exists, updates onlyplex_*fields (firesPLEX_LINKED) - Arr sync: before creating, checks
_read_plex_only_by_folder_path()— if a Plex-only row (arr_id=0) exists at the same path, adopts it with Arr fields (firesARR_LINKED) - When an item is removed from Arr but still in Plex: demoted back to Plex-only (
connection_id → plex_connection_id,arr_id → 0) instead of deleted (firesARR_UNLINKED) media_manager.create_or_update_bulk()returns(MediaRead, created, updated, arr_linked)— 4-tuple
app.routes.ts— Top-level lazy-loaded routesapp.config.ts— Angular app configuration (standalone, zoneless)services/— API service wrappers (typed, using auto-generated client)media/— Media list/detail pages (primary feature UI)tasks/,settings/,logs/,events/— Feature pagesshared/— Shared UI componentsmodels/— TypeScript interfaces Key patterns:- Standalone components only (no NgModules)
- Angular Signals for reactivity (zoneless change detection)
- Dev server proxies
/apito backend on port 7888 (seesrc/proxy.conf.json)
The app uses a Material Design 3 (MD3) token system. Always use the established CSS custom properties — never hardcode colors or shadows.
Color tokens:
var(--color-primary) // accent / interactive
var(--color-on-primary) // text on primary bg
var(--color-secondary-container) // active tab/nav bg
var(--color-on-secondary-container) // text on secondary container
var(--color-surface) // base surface
var(--color-surface-container-low) // subtle background
var(--color-surface-container) // card / dialog background
var(--color-surface-container-high) // elevated surface
var(--color-surface-container-highest) // highest elevation surface
var(--color-on-surface) // primary text
var(--color-on-surface-variant) // secondary text / icons
var(--color-outline) // borders, meta labels
var(--color-outline-variant) // subtle dividers
var(--color-success) / --color-warning / --color-danger / --color-info
var(--shadow-level2) // sticky headers
var(--shadow-level3) // dialogs / popoversSticky floating headers (used in every page — logs, events, tasks, settings):
.page-header {
position: sticky;
top: calc(76px + 0.5rem); // 76px = topnav height
z-index: 99;
margin: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
background-color: var(--color-surface-container);
box-shadow: var(--shadow-level2);
}Card sections (tasks, settings details, about sections):
.card {
border: 1px solid var(--color-outline-variant);
border-radius: 0.75rem;
background-color: var(--color-surface-container);
overflow: hidden; // clips child border-radius
}
.card-header {
padding: 0.875rem 1rem;
background-color: var(--color-surface-container-high);
border-bottom: 1px solid var(--color-outline-variant);
}Button shapes:
- Action buttons (text + icon):
border-radius: 0.625rem(10px squircle) — used for Save, Delete, Duplicate, dialog confirm buttons - Icon-only buttons:
border-radius: 50%(circle) — used for refresh, close, edit pencil - Tab / nav items:
border-radius: 0.5reminside a pill container (border-radius: 0.75rem) - Never use
border-radius: 9999pxfor buttons — that's the pill container shape only
Button color patterns:
// Primary action
background-color: var(--color-primary);
color: var(--color-on-primary);
&:hover { background-color: color-mix(in srgb, var(--color-primary) 85%, black); }
// Secondary / neutral
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
&:hover { background-color: var(--color-surface-container-highest); }
// Danger (destructive)
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
&:hover { background-color: color-mix(in srgb, var(--color-danger) 20%, transparent); }
// Icon button hover (generic)
&:hover { background-color: color-mix(in srgb, var(--color-primary) 12%, transparent); }Dropdowns — use the CSS Popover API, never custom JS toggles:
<button popovertarget="myDropdown">Label</button>
<div id="myDropdown" popover="auto" class="popover">
<div class="dropdown-list" role="listbox">
<button role="option" [attr.aria-selected]="isSelected" (click)="select(item)">...</button>
</div>
</div>.popover {
border: 1px solid var(--color-outline-variant);
border-radius: 0.75rem;
margin: 0.25rem 0 0;
padding: 0.35rem;
background-color: var(--color-surface-container-high);
box-shadow: var(--shadow-level3);
}Dialogs — use native <dialog> with showModal() / .close():
dialog {
border: none;
border-radius: 0.75rem;
background-color: var(--color-surface-container);
color: var(--color-on-surface);
padding: 0;
box-shadow: var(--shadow-level3);
&::backdrop {
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(4px);
}
}State persistence (filters, search, selected options) — always persist to both URL and localStorage:
// URL: router.navigate([], { queryParams: { key: value ?? null }, replaceUrl: true })
// localStorage: localStorage.setItem('TrailarrFeatureKey', value)
// Priority: localStorage (low) → URL params (high, read with take(1) in ngOnInit)
// Default values are omitted from URL and localStorage (use null to remove param)Focus rings on inputs and selects:
input:focus, select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, transparent);
}Mobile breakpoints: 765px (bottom nav bar replaces sidenav), 1100px (icon-only rail replaces full sidenav).
Multi-stage: Stage 1 builds Angular frontend (Node.js), Stage 2 packages backend with Python/FFmpeg. Uses nandyalu/python-ffmpeg base image (Python 3.13 + uv + FFmpeg + yt-dlp).
Pre-configured in .vscode/tasks.json:
- Frontend build —
npm run build - Fastapi run — starts FastAPI dev server
- Generate OpenAPI Files — updates OpenAPI spec and generates frontend client
- Create Alembic Migration — runs
scripts/create_migration.sh - Upgrade Python Dependencies —
uv sync --upgrade
APP_DATA_DIR=/path/to/config # Config, database, logs location (REQUIRED)
PYTHONPATH=/path/to/backend # Required for backend dev/testing
LOG_LEVEL=Info # Logging level- Python: PEP-8, type hints everywhere, async/await, specific exception types, log errors where caught
- Angular: Standalone components, Signals for reactivity, SCSS for styles, service-based state
- API changes: Always regenerate OpenAPI client after backend API modifications
- Database changes: Always create Alembic migration after SQLModel model changes
- EventType: stored as VARCHAR — new enum values require no migration, just add to
EventTypeinmodels/event.pyand add atrack_*helper inmanager/event/helpers.py - GitHub: use the
ghCLI for all GitHub operations (PRs, issues, releases, API) — it is already authenticated in this environment - Raw endpoints are a deliberate design, not a cleanup target:
/media/all_raw,/media/downloads_raw,/files/files_rawreturn raw dicts from SQL on purpose. Data is validated when WRITTEN to the database, so re-validating and building typed Python objects on every read would only add memory pressure and latency on large libraries. Do not "fix" them into typed responses; new list-scale endpoints may follow the same pattern when hot. - Docs prose style: write each sentence/paragraph as ONE continuous line — never hard-wrap prose across multiple lines in
docs/markdown. Zensical renders continuous lines correctly, but a paragraph broken into separate lines can break formatting. (Code blocks and lists are fine as usual.) - Simplified Technical English: when writing or updating log lines, docs, and comments, follow ASD-STE100 Simplified Technical English. Short sentences, one instruction/idea per sentence, active voice, approved/simple vocabulary, present tense, no strung-together noun clusters. Applies to new/changed content only — no need to rewrite untouched text just to comply.
- Log messages: a whole sentence, active voice, naming Trailarr as the actor, ending in a period — "Trailarr downloaded the trailer for 'Inception'." No "!" and no "...". Say the title, not the id.
- Linking a log line to a media item: pass the id, never write it into the message:
logger.info("Trailarr downloaded the trailer.", **logger.media(media.id)). The Logs page links a line from themediaidcolumn, whichlogger.media()fills. Never put a number that is not a media id in square brackets —config/logs/db_handler.pystill falls back to reading the first[123]in a message as the media id, so a bracketed profile, download, channel or connection id links the line to the wrong title.tests/test_log_message_safety.pyenforces this. - A log message is only evaluated when its line runs. An f-string naming something that does not exist passes every test and raises in production, on the error path where the message mattered most. Phase 7 did this three times. The same test file checks that enum members and the fields read from annotated arguments actually exist.
The path to v1.0.0 is an 11-phase roadmap (public summary: docs/references/roadmap.md).
Detailed per-phase execution plans live in plans/ — before working on any roadmap
phase (download engine, dynamic status, TMDB, video types, reorg, Issues, notifications…),
read plans/README.md and the relevant phase file first. Design decisions recorded there
are settled; follow the phase's wargame scenarios, verification protocol, and exit
criteria. Update the phase file's Status line when work starts/finishes.
After completing any bug fix, feature, or notable change, always ask the user:
- Release notes — "Should this be added to the release notes? If so, which version?" Release notes live in
docs/release-notes/2026.md. Add entries under the appropriate version heading using the existing format (Bug Fixes / What's New / Other Changes sections with emoji). - Docs update — "Do any documentation pages need to be updated for this change?" Docs live under
docs/.
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run
graphify query "<question>"when graphify-out/graph.json exists. Usegraphify path "<A>" "<B>"for relationships andgraphify explain "<concept>"for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run
graphify update .to keep the graph current (AST-only, no API cost).