Architecture Overview¶
legendarr is a Python monorepo built with a uv workspace, packaged into a single Docker image with one shared uv.lock.
Modules¶
src/backend(legendarr_backend) — domain logic: Radarr/Sonarr connection management, media library sync, subtitle discovery, subtitle translation, language profiles, the scheduler that runs the media sync periodically, and an HTTP API (api.py) exposing that domain logic — currently/language-profiles/*and/arr-services/*.src/web(legendarr_web) — the web UI (FastAPI + Jinja2/HTMX): templates, static/JS, and per-slice "services" that calllegendarr_backend's API over HTTP (httpx). It has no Python dependency onlegendarr_backendand never imports its code.src/bootstrap(legendarr_bootstrap) — the entrypoint that brings the other two modules up together: it mountslegendarr_backend's API app at/apiandlegendarr_web's app at/behind one FastAPI instance, and owns the singlelifespanthat starts/stops the backend's scheduler. This ismake run/ the DockerCMD— a single process still serves the dashboard, the API, and the background sync job.
Screaming Architecture + Vertical Slice Architecture¶
Inside each module, code is organized by business capability, not technical layer. Top-level folders are named after what the code does, not what kind of code it is:
src/backend/src/legendarr_backend/
├── arr_services/ # Radarr/Sonarr connection CRUD + connection testing
├── language_profiles/ # language profile model + management
├── media_library/ # media library sync (business logic)
│ └── jobs.py # the APScheduler job that drives the sync
├── subtitle_acquisition/ # subtitle-provider registration (credentials, test connection)
│ └── providers/ # subdomain: SubtitleProvider protocol (interface only)
├── media_metadata/ # TheTVDB/IMDb/TMDb metadata provider registration + fetch-on-sync/refetch
│ └── providers/ # subdomain: MetadataProvider protocol + TVDB/OMDb/TMDb clients
├── subtitle_discovery/ # finding subtitle tracks (external + embedded)
├── subtitle_translation/ # translation providers and the translate step
│ └── providers/ # subdomain: translation-provider adapters
├── subtitle_timing_sync/ # manual, per-subtitle ffsubsync timing-correction pass
├── authentication/ # session-based login (AuthSession) + API key issuance (ROADMAP 0.16.0)
├── settings/ # task/translation-default/webhook runtime settings
├── statistics/ # translated/acquired subtitle counts, over time, per profile and provider
├── history/ # translation/acquisition attempts, successes and failures, merged into one feed
├── system/ # directory browsing, recent logs, running-task status
├── arr_clients/ # shared Radarr/Sonarr API clients (sync + connection test)
├── config/ # env Settings + on-disk config.yaml
├── database/ # SQLModel engine/session + Alembic migration trigger
├── http_client/ # shared outbound-HTTP conventions for provider clients
├── logging/ # logging setup
├── scheduling/ # shared APScheduler wrapper (scheduler, queues, retry)
├── security/ # secrets encryption at rest (Fernet key, encrypt/decrypt, EncryptedString column type)
└── api.py # the internal HTTP API app
src/web/src/legendarr_web/
├── dashboard/ # home page — profile-count stats, polls via htmx
├── arr_services/ # /settings/arr-services/ routes (CRUD, test, enable/disable)
├── subtitle_acquisition/ # /settings/subtitle-providers/ routes (enable, credentials, test)
├── media_metadata/ # /settings/metadata-source/ routes (enable, credentials, test, refetch)
├── language_profiles/ # /settings/ route
├── subtitle_translation/ # /settings/translation-providers/ routes (enable, credentials, test, default)
├── media_library/ # /media/movies, /media/series routes
├── history/ # /history/ route
├── statistics/ # /statistics/ route
├── system/ # /system/ route
├── authentication/ # /login, /logout routes (session cookie set/cleared)
├── settings/ # /settings/tasks/, /settings/general/ routes (retry/interval settings, display language + webhook URL + login toggle + API key)
├── subtitle_proxies/ # /settings/subtitle-proxies/ routes (CRUD, test, enable/disable)
├── config/ # env WebSettings
├── backend_client/ # httpx client for calling the backend API
└── templates/ # shared Jinja2Templates factory + base.html layout
Each slice contains what it needs to work end to end. A domain folder can hold its own subdomains — e.g. subtitle_translation/providers/ separates a domain's business logic from the raw external-API adapters it calls. Code that's truly shared across slices — configuration, database setup, logging, templates, the APScheduler wrapper, secrets encryption at rest, and the Radarr/Sonarr API clients (used by both media_library sync and arr_services connection testing) — lives in its own top-level folder (arr_clients/, config/, database/, http_client/, logging/, scheduling/, security/; web's config/, backend_client/, templates/), a sibling of the business-domain folders rather than nested under one shared-code wrapper.
When adding a new feature, create a new top-level slice folder named after the business capability, in whichever module owns it, rather than adding to an existing generic layer.
When to extract a subdomain¶
A slice that piles up loose top-level files is a candidate for grouping some of them into a subdomain folder, not for splitting into a whole new top-level slice. subtitle_acquisition/ is the fullest example: alongside its providers/ subdomain (the provider adapters), it also has blacklist/ (blacklist_subtitle.py, manage_subtitle_blacklist.py), audio_transcription/ (transcribe_audio.py, probe_embedded_audio.py), and candidate_evaluation/ (match_score.py, quality_gate.py, release_attributes.py, release_filters.py). Extract one when:
- Real coupling, not just a shared topic. At least 2 files that import each other, or that all revolve around the same model/concept — not "these files are kind of related."
- Name it after the concern, not the slice (
blacklist/, notsubtitle_blacklist_stuff/) — the same no-stutter conventionhttp_client/client.pyalready follows. models.py/schemas.py/router.py/jobs.pystay at the slice's top level, never inside a subdomain — a subdomain holds logic/adapters, not the slice's tables, DTOs, or routes.- A helper used by exactly one member of a subdomain moves in with it (e.g.
providers/napiprojekt_hash.py, used only byproviders/napiprojekt.py); a helper the whole slice depends on stays at the top (e.g.subtitle_acquisition/opensubtitles_hash.py, used bysearch_context.pyto build a search request before any specific provider is chosen). - Tests don't move. They stay flat under
tests/<slice>/with a descriptive name, same asproviders/'s own tests today — only the import path inside each test file changes. - It's a trigger to consider, not an obligation. A slice with 20+ loose top-level files and a genuinely cohesive group of 2+ is a candidate; don't force a one-file "subdomain" or group files that only share a topic in name — that's speculative structure, not a real fix.