Skip to content

Architecture

github-actions[bot] edited this page Aug 21, 2026 · 9 revisions

Architecture

Applies to the current main implementation. Last source review: 2026-08-19.

Nooklet has one supervised application container with separate Node.js processes for the Next.js web server and background worker. The default Compose deployment also starts an internal YouTube proof-of-origin provider sidecar. SQLite is the durable system of record and the cross-process coordination boundary; media and download files remain on operator-controlled filesystems. This separation keeps the UI available when an unhealthy Docker Desktop bind mount blocks a worker-side filesystem call.

For repository metrics and deeper implementation evidence, see the engineering dossier. For the decisions that shaped this design, see Architecture Decisions.

System context

flowchart LR
  Person["User browser"]
  Proxy["TLS reverse proxy\noperator managed"]
  App["Nooklet web\nNext.js request process"]
  Worker["Nooklet worker\njobs + downloads + imports"]
  DB[("SQLite\n/app/data/nooklet.db")]
  Stage["Download work + completed staging\nDOWNLOAD_ENGINE_WORK_DIR / DOWNLOAD_ENGINE_DIR"]
  YouTubeWork["YouTube work\nYOUTUBE_WORK_DIR"]
  Media["Movie, TV, and YouTube libraries\napproved roots"]
  AI["OpenAI-compatible provider"]
  Metadata["TMDB / TVDB"]
  History["Plex / Tautulli / Trakt"]
  Indexers["Newznab indexers"]
  News["Usenet server"]
  YouTube["Public YouTube"]
  Pot["Internal PO-token provider"]
  Notify["Discord / Apprise / webhook"]

  Person --> Proxy --> App
  App <--> DB
  Worker <--> DB
  Worker <--> Stage
  Worker <--> YouTubeWork
  Worker <--> Media
  App --> AI
  App --> Metadata
  App --> History
  App --> Indexers
  Worker --> AI
  Worker --> Metadata
  Worker --> History
  Worker --> Indexers
  Worker --> News
  Worker --> YouTube
  Worker --> Pot
  Worker --> Notify
Loading

The web and worker do not use a direct in-process or HTTP control channel: web requests persist state and job/control intent in SQLite, and the worker claims it there. Both processes can call remote services for the workflows they own. The reverse proxy is not included in the shipped container. Operators exposing Nooklet beyond loopback are responsible for TLS, ingress restrictions, and correct proxy-header handling. See Security Model.

Internal dependency direction

flowchart TD
  UI["App Router pages and components"]
  Boundary["Server actions and API route handlers"]
  Domain["Domain commands, queries, and workflows"]
  Ports["Repositories and typed adapter capabilities"]
  DB[("Drizzle + SQLite")]
  External["Remote services"]
  Files["Approved filesystems"]
  Worker["Job worker and download runner"]

  UI --> Boundary
  Boundary --> Domain
  Worker --> Domain
  Domain --> Ports
  Ports --> DB
  Ports --> External
  Ports --> Files
Loading

The architectural rule is that UI code delegates work through a server action or route boundary and does not call vendor adapters directly. Workflows own orchestration, validation, persistence, and failure semantics. Cross-module production code must not import another module's repositories/ or adapters/ internals; modules with a public.ts facade expose narrow capabilities there. npm run boundaries:check enforces that focused private-folder rule, but does not prove every possible module cycle or internal boundary. The governing intent is recorded in ADR-0001.

Request execution

sequenceDiagram
  actor User
  participant UI as App Router UI
  participant Boundary as Server action / route
  participant Auth as Auth and validation
  participant Workflow as Domain workflow
  participant Data as SQLite / durable job intent
  participant Adapter as External adapter

  User->>UI: Submit a task
  UI->>Boundary: Typed task input
  Boundary->>Auth: Authenticate and authorize
  Auth-->>Boundary: User and policy context
  Boundary->>Workflow: Validated command
  Workflow->>Data: Read or persist workflow state
  opt Remote capability required
    Workflow->>Adapter: Typed provider request
    Adapter-->>Workflow: Validated response
  end
  Workflow->>Data: Persist outcome and audit data
  Workflow-->>Boundary: Domain result
  Boundary-->>UI: User-safe result
Loading

Most application behavior uses Next.js server actions. The stable HTTP surface is intentionally small and documented in HTTP API.

Durable season coordination

A season request is modeled above the physical transfer lifecycle. download_fulfillments records the user outcome and active strategy; download_requests records each pack or episode release attempted to reach it.

flowchart LR
  UI["Season request"] --> Plan["Durable fulfillment"]
  Plan --> Pack["Season-pack search"]
  Pack --> Attempt["Physical download request"]
  Attempt --> Import["Import + coverage reconciliation"]
  Import -->|Complete| Done["Succeeded"]
  Import -->|Missing coverage| Episodes["Per-episode fallback"]
  Pack -->|No usable pack| Episodes
  Pack -->|Pack larger than staging filesystem| Episodes
  Episodes --> Child["Independent episode attempts"]
  Child --> Import
  Worker["15-second worker"] -->|Resume nextAttemptAt| Plan
  Plan -->|Storage/configuration failure| Blocked["Blocked with corrective message"]
Loading

The fulfillment owns plan-scoped release exclusions, an eight-probe search-pass ceiling, three submitted transfers per pack or episode cycle, retry timing, and grouped Activity presentation. download_requests.submittedAt keeps preflight rejects out of the transfer budget while retaining them as exclusions. Release failures and packs proven larger than the staging filesystem can advance to another pack or episode fallback. Capacity held by active downloads waits without consuming the release; current free-space/mount, destination, downloader, credential, and compatible-indexer failures block fan-out with a corrective action. A successful pack is reconciled against current monitored, aired episode coverage instead of being assumed complete.

This boundary keeps user intent separate from acquisition evidence and makes recovery restart-safe without adding an external queue service. See ADR-0003 and Downloads and Import.

Runtime composition

  • Next.js App Router supplies React Server Components, server actions, and route handlers.
  • Auth.js uses a credentials provider, encrypted JWT cookies, and an auth_sessions validity registry that provides revocable, absolute-lifetime 24-hour sessions.
  • Drizzle maps the normalized schema to SQLite. Migrations run during database readiness initialization.
  • A Node supervisor runs database migration once, then starts distinct web and worker children. Next.js instrumentation deliberately does not load the worker graph in the production web child.
  • The persisted job worker polls every 15 seconds. Its pass state is written atomically beside SQLite so the web process can evaluate progress without importing worker code.
  • The built-in downloader uses a worker-process async runner and an NNTP connection pool for the active transfer. Control intent and speed are persisted because process-local signals cannot cross the web/worker boundary.
  • The YouTube module owns discovery, source synchronization, a durable one-at-a-time download runner, yt-dlp/ffmpeg subprocesses, restart-safe work under YOUTUBE_WORK_DIR, and containment-checked publication into approved YouTube library paths.
  • The default Compose deployment starts a separate, internal youtube-pot-provider service. It is not published to the host; yt-dlp in the application container reaches it over the Compose network.
  • Storage capacity is sampled by a disposable probe child. Pages read SQLite snapshots and never probe bind mounts directly.
  • tini is PID 1 in the Docker image; the supervisor forwards termination and applies bounded child shutdown.

Primary sources: instrumentation, database client, job worker, engine runner, YouTube runner, Compose services, and Dockerfile.

Domain ownership

The physical modules under src/modules are:

Module Primary responsibility
admin Administrative queries and operational views
discover TMDB-backed discovery rails and title search
download-engine NZB parsing, NNTP transfer, repair, extraction, and engine queue
downloads Durable season fulfillment, request association, enqueue, built-in import, retry, and cancellation reconciliation
identity-access Login, authorization, and first-admin bootstrap
indexers Indexer configuration, search, normalization, and protected result storage
instance-config Persisted stable ownership for shared instance configuration
jobs Persisted schedules, claims, leases, and job history
media-library Libraries, folders, titles, episodes, scans, monitoring, and release selection
notifications Channel configuration, dispatch, and delivery audit
preferences Per-user recommendation and history defaults
readiness Capability-level setup and health evaluation
recommendations AI runs, enrichment, history, feedback, and analytics
service-connections External connection configuration, verification, and encrypted secrets
storage Persisted staging/destination snapshots and isolated capacity inspection
users Accounts, roles, password hashing, and recovery state
watch-history Manual, Plex, Tautulli, and Trakt history synchronization
youtube Public discovery, monitors, source sync, durable transfer, cookie access, and safe library publication

credential-vault and metadata appear in older design documents as conceptual ownership areas, but they are not physical module directories in the current tree. Secret handling is implemented across src/lib/security, service-connections, and indexer repositories; metadata adapters currently live primarily in service-connections and media workflows.

Deployment topology

flowchart TB
  Host["Docker host"]
  subgraph Container["Nooklet container"]
    Tini["tini"] --> Supervisor["Node.js supervisor"]
    Supervisor --> Web["Next.js web process"]
    Supervisor --> Jobs["Background worker process"]
    Supervisor --> Probe["Disposable storage probe"]
    Jobs --> Engine["Download-engine runner"]
    Jobs --> YouTubeRunner["YouTube runner + yt-dlp / ffmpeg"]
  end
  Provider["youtube-pot-provider sidecar"]
  Volume[("nooklet-data volume")]
  DownloadMount["Host download folder"]
  MediaMount["Host media folders"]

  Host --> Container
  Web <--> Volume
  Jobs <--> Volume
  Probe <--> Volume
  Engine <--> DownloadMount
  YouTubeRunner <--> Volume
  YouTubeRunner --> Provider
  YouTubeRunner <--> MediaMount
  Jobs <--> MediaMount
  Probe <--> MediaMount
Loading

The shipped Compose configuration persists /app/data in a named volume, publishes only application port 42021 on loopback by default, and keeps the provider sidecar internal to the Compose network. Media and download bind mounts are operator configuration. See Storage and Path Mapping.

Architectural constraints

  • One supervised application container and its internal provider sidecar are the supported Compose topology. The web and worker are separate OS processes, not horizontally scalable replicas.
  • SQLite and durable control rows coordinate the two children. Process-local import locks remain worker-only implementation details and are not distributed coordination primitives.
  • A process restart reclaims persisted jobs through leases, but an interrupted native download is parked. Explicit Resume restarts it from its stored NZB rather than resuming individual segments.
  • Season recovery schedules and renewable per-plan work leases survive restart in SQLite. The maintenance loop is serialized inside the single supported worker process.
  • The worker drains an active pass during normal termination. A stale persisted heartbeat degrades health and is logged once, but does not terminate the worker; only an actual exit is restarted. Explicit shutdown retains a bounded force-kill ceiling.
  • Shared service, indexer, library, and path configuration resolves through one persisted instance owner and does not change merely because that user's role or enabled state changes.
  • Media-mount failures are contained from ordinary web navigation, but Nooklet cannot repair a damaged host disk or cancel a filesystem syscall already in uninterruptible kernel sleep.
  • A single Usenet service connection is currently resolved. Multi-server priority and block-account scheduling described in ADR-0002 are not implemented.
  • Current authentication is local credentials only. Trakt accepts an OAuth access token as connection data, but Nooklet does not expose Trakt as an Auth.js sign-in provider.
  • Jellyfin is not a current service-connection or watch-history source despite references in older planning documents.

Source index

Related: Data and Background Jobs | Downloads and Import | Security Model

Clone this wiki locally