Reference

Architecture

Capacitarr is a single-container application that bundles a Go backend, a Nuxt 4 (Vue 3) frontend, and a SQLite database. The frontend is statically generated at build time and embedded into the Go binary via go:embed, producing a single self-contained executable.

High-Level Overview

Container Internals

The Docker container runs a Go backend that serves the embedded Nuxt frontend. Communication flows through REST API calls and real-time Server-Sent Events.

flowchart TB
    FRONTEND["Nuxt 4 Frontend<br/>Vue 3 + Tailwind CSS 4 + shadcn-vue"]

    subgraph GO_BACKEND["Go Backend"]
        direction LR
        BACKEND["Echo + GORM<br/>Service Layer"]
        ENGINE["Scoring Engine<br/>Weighted factors + protection rules"]
        POLLER["Engine Orchestrator<br/>Scheduled disk monitoring"]
        EVENT_BUS["Event Bus<br/>Typed pub/sub fan-out"]
        SSE["SSE Broadcaster<br/>Real-time event stream"]

        BACKEND --> ENGINE
        BACKEND --> POLLER
        BACKEND -.->|"publish"| EVENT_BUS
        EVENT_BUS -.->|"subscribe"| SSE
    end

    DB["SQLite Database<br/>/config/capacitarr.db"]

    FRONTEND -->|"REST API"| BACKEND
    BACKEND --> DB
    SSE -.->|"Server-Sent Events"| FRONTEND

External Integrations

The engine orchestrator fetches data from external services, and the scoring engine sends deletion requests back to the *arr apps.

flowchart LR
    subgraph CAPACITARR["Capacitarr"]
        direction LR
        POLLER["Engine Orchestrator"]
        ENGINE["Scoring Engine"]
    end

    subgraph ARR_APPS["*arr Apps"]
        direction LR
        SONARR["Sonarr"]
        RADARR["Radarr"]
        LIDARR["Lidarr"]
        READARR["Readarr"]
    end

    subgraph MEDIA_SERVERS["Media Servers"]
        direction LR
        PLEX["Plex"]
        JELLYFIN["Jellyfin"]
        EMBY["Emby"]
    end

    subgraph ENRICHMENT["Enrichment"]
        direction LR
        TAUTULLI["Tautulli"]
        JELLYSTAT["Jellystat"]
        SEERR["Seerr"]
        TRACEARR["Tracearr"]
    end

    POLLER -->|"Fetch media + disk space"| ARR_APPS
    POLLER -->|"Fetch watch data"| MEDIA_SERVERS
    POLLER -->|"Fetch requests + history"| ENRICHMENT
    ENGINE -->|"Delete lowest-scored items"| ARR_APPS

Technology Stack

LayerTechnologyPurpose
FrontendNuxt 4, Vue 3, Tailwind CSS 4, shadcn-vue, Lucide, EChartsDashboard UI, analytics visualizations, rule builder, score visualization, real-time updates via SSE
BackendGo, Echo, GORMREST API, authentication, integration clients, scheduling
Service LayerGo (custom)Business logic, event publishing, dependency injection
Event SystemGo (custom)Typed event bus, activity persistence, SSE broadcast, notification dispatch
DatabaseSQLiteConfiguration, approval queue, audit log, engine statistics
ContainerAlpine Linux, multi-stage Docker buildMinimal runtime image (~30 MB)

Backend Architecture

Service Layer

All business logic lives in the service layer (backend/internal/services/). Route handlers are thin — they parse requests, call services, and return responses.

flowchart TD
    ROUTES["Route Handlers<br/>Parse request, call service, return response"]
    SERVICES["Service Layer<br/>Business logic + validation"]
    DB["*gorm.DB<br/>Injected, not global"]
    BUS["Event Bus<br/>Typed pub/sub"]

    subgraph SUBSCRIBERS["Subscribers"]
        ACTIVITY["ActivityPersister<br/>activity_events table"]
        NOTIF["NotificationDispatcher<br/>Discord / Apprise"]
        SSE["SSE Broadcaster<br/>Browser tabs"]
    end

    ROUTES -->|"call"| SERVICES
    SERVICES -->|"query / persist"| DB
    SERVICES -.->|"publish"| BUS
    BUS -.->|"subscribe"| ACTIVITY
    BUS -.->|"subscribe"| NOTIF
    BUS -.->|"subscribe"| SSE
    ACTIVITY -.->|"persist"| DB

Service Registry

All services accept *gorm.DB and *events.EventBus in their constructor and are registered on services.Registry.

CategoryServiceResponsibilities
CoreApprovalServiceApprove, reject, unsnooze queue items
DeletionServiceExecute deletions, dry run, handle failures
DiskGroupServiceDisk group CRUD, threshold management
EngineServiceTrigger runs, get stats
SettingsServiceUpdate preferences and thresholds
DataAuditLogServiceCreate, upsert, dedup audit entries
BackupServiceSettings export/import and backup archive creation
DatabaseBackupServiceAutomatic VACUUM INTO backup with rotation and retention
DataServiceData reset operations
MetricsServiceHistory, rollup, lifetime stats
RulesServiceCustom rule CRUD, validation, and impact preview
PreviewServiceScored media preview cache, SSE-driven invalidation
MappingServiceMedia server TMDb ID → native ID mapping for cross-references
AnalyticsWatchAnalyticsServiceDead content, stale content analytics
SunsetSunsetServiceSunset queue CRUD, expiry processing, escalation, label management
PosterOverlayServicePoster overlay lifecycle (apply, restore, update all)
ExternalIntegrationServiceCRUD, test connections, sync data
RecoveryServiceIntegration recovery with exponential backoff
AuthServiceLogin, change password, generate API keys
NotificationChannelServiceCRUD for notification channels
NotificationDispatchServiceTwo-gate flush, digest, and alerts
VersionServiceUpdate check via GitHub releases
MigrationService1.x → 2.0 database migration

Service Registry

All services are instantiated in main.go and held in a services.Registry struct that is passed to route registration functions:

type Registry struct {
    DB  *gorm.DB
    Bus *events.EventBus
    Cfg *config.Config

    Approval             *ApprovalService
    Backup               *BackupService
    Deletion             *DeletionService
    AuditLog             *AuditLogService
    DiskGroup            *DiskGroupService
    Engine               *EngineService
    Preview              *PreviewService
    Settings             *SettingsService
    Integration          *IntegrationService
    Auth                 *AuthService
    NotificationChannel  *NotificationChannelService
    NotificationDispatch *NotificationDispatchService
    Data                 *DataService
    Rules                *RulesService
    Metrics              *MetricsService
    Version              *VersionService
    WatchAnalytics       *WatchAnalyticsService
    Migration            *MigrationService
    Sunset               *SunsetService
    PosterOverlay        *PosterOverlayService
    Mapping              *MappingService
    Recovery             *RecoveryService
    DatabaseBackup       *DatabaseBackupService
}

Each service receives a *gorm.DB and *events.EventBus via constructor injection — no global state.

Capability-Based Integration Interfaces

Integration clients implement only the capability interfaces they support, replacing a monolithic Integration interface:

InterfaceDescriptionImplementors
ConnectableConnection testingAll integrations
MediaSourceList managed media itemsSonarr, Radarr, Lidarr, Readarr
DiskReporterDisk usage reportingSonarr, Radarr, Lidarr, Readarr
MediaDeleterDelete media itemsSonarr, Radarr, Lidarr, Readarr
WatchDataProviderPlay counts and historyPlex, Jellyfin, Emby
RequestProviderMedia request dataSeerr
WatchlistProviderUser watchlists/favoritesPlex, Jellyfin, Emby
CollectionDataProviderCollection membershipsPlex, Jellyfin, Emby
CollectionResolverResolve collection members for deletionRadarr
RuleValueFetcherDynamic rule field valuesSonarr, Radarr, Lidarr, Readarr
CollectionNameFetcherFetch collection names for autocompletePlex, Jellyfin, Emby
LabelDataProviderLabel memberships for enrichmentPlex, Jellyfin, Emby
LabelManagerApply and remove labels on media itemsPlex, Jellyfin, Emby
LabelNameFetcherFetch label names for autocompletePlex, Jellyfin, Emby
PosterManagerUpload and restore poster imagesPlex, Jellyfin, Emby

Integration Registry

The IntegrationRegistry provides runtime discovery of available integrations by capability:

registry.WatchProviders()          // → [Plex, Jellyfin, Emby]
registry.MediaSources()            // → [Sonarr, Radarr, Lidarr, Readarr]
registry.DiskReporters()           // → [Sonarr, Radarr, Lidarr, Readarr]
registry.RequestProviders()        // → [Seerr]
registry.CollectionDataProviders() // → [Plex, Jellyfin, Emby]
registry.LabelDataProviders()      // → [Plex, Jellyfin, Emby]
registry.LabelManagers()           // → [Plex, Jellyfin, Emby]
registry.PosterManagers()          // → [Plex, Jellyfin, Emby]

Integration clients are created via a factory pattern (integrations.CreateClient(config)) and auto-registered. The poller and preview service use the registry to discover capabilities instead of hardcoded wiring.

Enrichment Pipeline

Media items pass through a composable enrichment pipeline after fetching:

flowchart LR
    FETCH["Fetch<br/>MediaSource.GetMediaItems()"]

    subgraph WATCH_DATA["Watch Data Enrichers"]
        direction LR
        BULK["BulkWatchEnricher<br/>Play counts + last played"]
        TAUTULLI_E["TautulliEnricher<br/>Per-user watch data"]
        JELLYSTAT_E["JellystatEnricher<br/>Per-user watch data"]
        TRACEARR_E["TracearrEnricher<br/>Unified watch data"]

        BULK --> TAUTULLI_E --> JELLYSTAT_E --> TRACEARR_E
    end

    subgraph METADATA["Metadata Enrichers"]
        direction LR
        REQUEST["RequestEnricher<br/>Seerr request status"]
        WATCHLIST["WatchlistEnricher<br/>Watchlist/favorites"]
        COLLECTION["CollectionEnricher<br/>Collection memberships"]
        LABEL["LabelEnricher<br/>Media server labels"]

        REQUEST --> WATCHLIST --> COLLECTION --> LABEL
    end

    subgraph XREF_GROUP["Cross-Reference"]
        XREF["CrossReferenceEnricher<br/>Requestor watched?"]
    end

    FETCH --> BULK
    TRACEARR_E --> REQUEST
    LABEL --> XREF

Each enricher implements the Enricher interface (Name(), Priority(), Enrich(items)) and is auto-discovered from the registry's capabilities. The pipeline currently includes 9 enrichers.

Pluggable Scoring Factors

The scoring engine uses a ScoringFactor interface for each scoring dimension:

FactorWeight KeyDescription
WatchHistoryFactorwatch_historyPlay count influence
RecencyFactorlast_watchedRecency of last watch
FileSizeFactorfile_sizeLarger files scored higher for deletion
RatingFactorratingCommunity/critic ratings
LibraryAgeFactortime_in_libraryOlder items scored higher
SeriesStatusFactorseries_statusEnded series scored higher
RequestPopularityFactorrequest_popularityRequested content is protected

New factors can be added by implementing the ScoringFactor interface and registering them — no changes to the evaluator loop.

Event Bus

The event bus uses a fan-out pattern with one goroutine per subscriber and buffered channels.

// Event is the interface all typed events implement.
type Event interface {
    EventType() string
    EventMessage() string
}

type EventBus struct {
    mu          sync.RWMutex
    subscribers map[chan Event]struct{}
    closed      bool
}

func (b *EventBus) Publish(event Event)
func (b *EventBus) Subscribe() chan Event
func (b *EventBus) Unsubscribe(ch chan Event)
func (b *EventBus) Close()

When a service performs an action (e.g., approving an item, completing an engine run), it publishes a typed event to the bus. Three subscribers react to every event:

  1. ActivityPersister — writes the event to the activity_events table for the dashboard feed
  2. NotificationDispatcher — filters events against notification channel subscriptions and delivers to Discord/Apprise
  3. SSEBroadcaster — serializes the event as an SSE message and pushes it to all connected browser tabs

Notification Dispatch

The NotificationDispatchService uses a two-gate flush pattern to ensure cycle digest notifications contain complete data from both the evaluation phase and the deletion phase of an engine run.

flowchart LR
    ENGINE_COMPLETE["EngineCompleteEvent<br/>Gate 1"]
    DELETION_BATCH["DeletionBatchCompleteEvent<br/>Gate 2"]
    FLUSH["Flush<br/>Build digest + dispatch"]
    CHANNELS["Discord / Apprise"]

    ENGINE_COMPLETE -.->|"evaluation stats"| FLUSH
    DELETION_BATCH -.->|"deletion stats"| FLUSH
    FLUSH -->|"deliver"| CHANNELS

Cycle digests are batched summaries sent once per engine run. They include evaluated count, flagged count, deleted count, freed bytes, duration, and disk usage. The digest is only dispatched after both gates fire, ensuring deletion results are included.

Instant alerts fire immediately when their trigger event occurs — they are not batched. Alert types include engine errors, mode changes, server started, threshold breaches, update available, approval activity, and integration status (failure + recovery).

See notifications.md for the full user-facing guide.

Event Types

CategoryEvents
Engineengine_start, engine_complete, engine_error, manual_run_triggered, enrichment_complete
Settingsengine_mode_changed, settings_changed, threshold_changed, threshold_breached, settings_exported, settings_imported
Authlogin, password_changed, username_changed, api_key_generated
Integrationintegration_added, integration_updated, integration_removed, integration_test, integration_test_failed, integration_recovered, integration_recovery_attempt
Approvalapproval_approved, approval_rejected, approval_unsnoozed, approval_bulk_unsnoozed, approval_orphans_recovered, approval_queue_cleared, approval_dismissed, approval_queue_reconciled, approval_returned_to_pending
Deletiondeletion_success, deletion_failed, deletion_dry_run, deletion_batch_complete, deletion_progress, deletion_queued, deletion_cancelled, deletion_grace_period
Rulesrule_created, rule_updated, rule_deleted
Notificationsnotification_channel_added, notification_channel_updated, notification_channel_removed, notification_sent, notification_delivery_failed
Sunsetsunset_created, sunset_cancelled, sunset_expired, sunset_rescheduled, sunset_escalated, sunset_misconfigured, sunset_saved, sunset_saved_cleaned, sunset_label_applied, sunset_label_removed, sunset_label_failed
Poster Overlayposter_overlay_applied, poster_overlay_restored, poster_overlay_failed
Previewpreview_updated, preview_invalidated, analytics_updated
Datadata_reset
Systemserver_started, update_available, version_check

SSE (Server-Sent Events)

The frontend connects to GET /api/v1/events (authenticated, long-lived HTTP connection) to receive real-time updates. This replaces the previous polling-based approach.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

id: 1
event: engine_start
data: {"message":"Engine run started in approval mode","executionMode":"approval"}

id: 2
event: engine_complete
data: {"message":"Engine run completed: evaluated 97, flagged 12","evaluated":97,"flagged":12}

Key features:

  • Auto-increment event IDs for replay support via Last-Event-ID header
  • In-memory ring buffer (last 100 events) for reconnection replay
  • Keepalive comments every 30 seconds to prevent proxy timeouts
  • Auto-reconnect with exponential backoff on the client side

Database Schema

The database uses two purpose-specific tables instead of a single overloaded table:

Approval Queue

Active approval queue items with a state machine (pendingapproved/rejected):

ColumnTypeDescription
idINTEGERPrimary key
media_nameTEXTItem title
media_typeTEXTmovie, show, season, episode, artist, book
reasonTEXTScore explanation
score_detailsTEXTJSON-encoded score breakdown
size_bytesINTEGERFile size
integration_idINTEGERFK to integration_configs (required)
external_idTEXTExternal ID in the integration
statusTEXTpending, approved, rejected
snoozed_untilDATETIMEWhen snooze expires (rejected items)
created_atDATETIMERow creation
updated_atDATETIMELast state transition

Audit Log

Permanent, append-only history of deletions and dry-runs:

ColumnTypeDescription
idINTEGERPrimary key
media_nameTEXTItem title
media_typeTEXTMedia type
reasonTEXTScore explanation
score_detailsTEXTJSON-encoded score breakdown
actionTEXTdeleted, dry_run, dry_delete
size_bytesINTEGERFile size
integration_idINTEGERFK to integration_configs (nullable — preserved on integration delete)
created_atDATETIMERow creation

Activity Events

Transient dashboard feed with 7-day retention:

ColumnTypeDescription
idINTEGERPrimary key
event_typeTEXTEvent type identifier
messageTEXTHuman-readable message
metadataTEXTOptional JSON payload
created_atDATETIMERow creation

Frontend Architecture

SSE Integration

The frontend uses a singleton useEventStream composable that maintains a single EventSource connection shared across all components:

flowchart LR
    SSE_ENDPOINT["GET /api/v1/events"]
    EVENT_SOURCE["EventSource<br/>(singleton)"]
    ENGINE_CTL["useEngineControl<br/>Engine state updates"]
    APPROVAL["useApprovalQueue<br/>Queue refresh"]
    DASHBOARD["index.vue<br/>Activity feed + stats"]
    BANNER["ConnectionBanner<br/>Connection state"]

    SSE_ENDPOINT --> EVENT_SOURCE
    EVENT_SOURCE --> ENGINE_CTL
    EVENT_SOURCE --> APPROVAL
    EVENT_SOURCE --> DASHBOARD
    EVENT_SOURCE --> BANNER
  • app.vue initializes the SSE connection on mount when authenticated
  • Components subscribe to specific event types and react accordingly
  • Engine state, approval queue, and activity feed update in real-time without polling
  • ConnectionBanner.vue uses SSE connection state as the primary health indicator

Page Structure

PageRoutePurpose
Dashboard/Disk groups, approval queue, activity feed, engine controls, sparklines
Library/libraryBrowse (smart filters, virtual scrolling) + History (audit log) — 2 tabs
Audit/auditFull deletion and dry-run history
Rules/rulesCascading rule builder, drag-and-drop sort, rule impact badges
Settings/settingsPreferences, integrations, notifications, auth
Help/helpScoring guide, FAQ, about section
Login/loginAuthentication
Migrate/migrateOptional 1.x → 2.0 database migration stepper

Project Structure

capacitarr/
├── backend/                        # Go backend
│   ├── main.go                     # Application entrypoint, wiring
│   ├── internal/
│   │   ├── config/                 # Environment variable loading
│   │   ├── cache/                  # Generic TTL cache
│   │   ├── db/                     # SQLite models, schema migrations
│   │   ├── engine/                 # Scoring + rule evaluation
│   │   ├── events/                 # Event bus, typed events, SSE broadcaster, activity persister
│   │   ├── integrations/           # *arr, Plex, Jellyfin, Emby, Seerr, Tautulli, Jellystat, Tracearr clients + registry + enrichment pipeline
│   │   ├── jobs/                   # Cron scheduling (retention cleanup, time-series rollups)
│   │   ├── notifications/          # Discord, Apprise notification senders + HTTP client
│   │   ├── poller/                 # Engine orchestrator (scheduled disk monitoring)
│   │   ├── migration/              # 1.x → 2.0 database migration detection + import
│   │   ├── services/               # Service layer (business logic)
│   │   ├── testutil/               # Shared test helpers (in-memory DB, fixtures)
│   │   └── logger/                 # Structured logging
│   └── routes/                     # REST API handlers + middleware
├── frontend/                       # Nuxt 4 frontend
│   ├── app/
│   │   ├── components/             # Vue components (shadcn-vue based)
│   │   ├── composables/            # Vue composables (useEventStream, useEngineControl, etc.)
│   │   ├── pages/                  # Nuxt pages (dashboard, library, audit, rules, settings, help, login, migrate)
│   │   ├── locales/                # i18n translations (22 languages)
│   │   ├── types/                  # TypeScript type definitions
│   │   └── assets/css/             # Tailwind CSS + theme variables
│   └── nuxt.config.ts              # Nuxt configuration
├── site/                           # Project marketing site (Nuxt UI Pro)
├── docs/                           # Documentation
│   └── reference/api/              # OpenAPI spec, examples, workflows
├── scripts/                        # Release utility scripts (Discord notify, Docker build/mirror)
├── Dockerfile                      # Multi-stage build (Node → Go → Alpine)
└── Makefile                        # CI/CD targets (lint, test, security, build)