-
Notifications
You must be signed in to change notification settings - Fork 0
Core Concepts
This page explains how MuxCore works — from the big ideas down to the details. If you're coming from Sonarr, Radarr, or Plex, start here. You don't need to be a programmer to understand this page. When we get into code examples, we'll mark them clearly.
Right now, most people run a stack of separate programs:
Sonarr (TV) + Radarr (Movies) + Lidarr (Music) + Bazarr (Subtitles)
↓ ↓ ↓ ↓
Prowlarr (search) → qBittorrent (download) → Plex/Jellyfin (playback)
Each program has its own:
- Database — the same movie exists in three places
- Settings — configure download paths six times
- Scheduler — six separate task timers running
- Update cycle — six things to keep current
This works fine for a single machine with a simple setup. It breaks down when you want to:
- Run transcoding on a separate GPU machine
- Store some media locally and some in cloud storage
- Have two downloaders active at the same time
- Add a new media type (books, audiobooks, comics)
- Survive a machine going down without manual intervention
MuxCore replaces the separate programs with a single platform where everything is a pluggable module:
MuxCore (the platform)
│
┌─────────────────────┼─────────────────────┐
│ │ │
Downloaders Media Managers Playback
(qBit, SABnzbd) (Movies, TV, Music) (Jellyfin, Plex)
│ │ │
└─────────────────────┼─────────────────────┘
│
Everything else:
Indexers, Transcoders, Notifications,
Metadata, Subtitles, Storage backends...
Instead of six programs that barely know about each other, you have one platform where every module can see what every other module is doing. A download finishes → the media manager imports it → the transcoder optimizes it → notification goes out. All without hardcoded paths or fragile integrations.
MuxCore uses a consistent metaphor to explain how it works. Once you understand this, everything else clicks into place.
MuxCore is a fabric — a woven system of independent parts working together.
| Concept | What it means | Real-world analogy |
|---|---|---|
| The Loom | Core platform — the fixed frame | The frame of a weaving loom that never changes shape |
| Threads | Modules — every feature is a thread | Individual threads woven into the fabric |
| Patterns | Contracts — the interfaces threads agree to | The weaving pattern that determines how threads cross |
| Signals | Events — how threads communicate | A pulse that travels through the fabric when something changes |
| Tapestries | Workflows — multi-step processes | A finished woven piece made from many threads |
This is the most important rule in MuxCore:
If adding a new media type requires changing the core platform, the architecture is wrong.
Core — the loom — knows nothing about movies, TV shows, music, books, or any specific media type. It provides eight basic services (event routing, storage, scheduling, module discovery, etc.) and nothing more. Everything specific to a media type lives in modules and their contracts.
This means:
- Adding support for VR180 video? Write a module, core doesn't change.
- Adding audiobook support? Write a module, core doesn't change.
- Adding a new streaming service? Write a module, core doesn't change.
These five patterns are how MuxCore stays flexible without becoming complicated. They're the engineering behind "the loom never changes."
Every task and filter in MuxCore carries a Meta field — a key-value bag that modules use for their own data. Core never looks inside it.
Why this matters: A download task can hold a torrent magnet link, an Apple Music track ID, a Spotify URI, or a YouTube video ID — all in the same Meta field. Core routes the task. The downloader module reads what it needs from Meta. Core never learns what Apple Music is.
// Core never interprets these fields — the module does
task := DownloadTask{
Label: "Download: Blinding Lights",
Meta: map[string]any{
"apple_music_track_id": "1495061978",
"stream_mode": true,
},
}Core provides eight essential services to every module (event bus, registry, storage, etc.). Everything else — secrets, databases, caches, metrics, tracing — modules discover at runtime by asking "who can do X?"
Module asks: "Who provides secrets?"
Registry answers: "vault-secrets can do that"
Module gets: a SecretsProvider it can use
If someone writes a new type of service tomorrow, modules discover it the same way. Core's service list never needs to grow.
In most systems, the platform defines every possible module type as a fixed list. In MuxCore, modules define their own roles as simple strings:
// A module can declare any roles it wants
Roles: []string{"downloader", "streaming_source", "cache_writer"}Core has no catalog of roles. A module can declare "vr180_processor" and other modules find it with FindByRole("vr180_processor"). The loom doesn't need to know what a VR180 processor is.
Everything specific to a media type lives in a contract repository — an independent, versioned package that defines interfaces:
github.com/Muxcore-Media/contracts-downloader → Downloader, DownloadTask
github.com/Muxcore-Media/contracts-playback → Playback, StreamSource
github.com/Muxcore-Media/contracts-media → MediaLibrary, MediaObject
github.com/Muxcore-Media/contracts-transcoder → TranscodeProfile, Transcoder
Core imports none of these. Modules import the ones they need. When streaming needs a new interface, contracts-playback gets updated — core stays untouched.
Modules openly declare which contracts they implement:
func (m *Module) Info() ModuleInfo {
return ModuleInfo{
ID: "apple-music",
Name: "Apple Music Provider",
Roles: []string{"playback", "media_manager"},
Contracts: []ContractDeclaration{
{Repo: "contracts-playback", Version: "v1.0.0", Interface: "StreamSource"},
{Repo: "contracts-media", Version: "v1.0.0", Interface: "MediaLibrary"},
},
}
}The marketplace checks these before showing install buttons. Consumer modules check them before connecting. Core stores and returns them — nothing more.
One of the most powerful design decisions in MuxCore: a single media item can own multiple files.
In Sonarr, if you want 1080p and 4K versions of a movie, you need two separate instances. In MuxCore, one movie object owns files at every quality:
MediaObject "The Terminator"
├── Assets:
│ ├── 2160p remux (72 GB, MKV)
│ ├── 1080p web (8 GB, AV1)
│ ├── 720p web (3 GB, H.264)
│ └── 360p web (1 GB, H.264)
The playback module asks "give me the best file this device can play," and the media library returns the matching asset. The loom just stores blobs and routes events — it never counts files or checks codecs.
- Downloader fetches the 2160p remux → adds an asset to the media object
- Transcoder creates 1080p, 720p, and 360p versions → adds three more assets
- Client requests playback → media library picks the right asset for that device
- Core never knows any of this happened — it just stored blobs and routed events
Modules in MuxCore never call each other directly. All communication goes through two channels:
When something changes — a download finishes, a transcode completes, a new node joins the cluster — the module publishes a signal on the event bus. Any module that cares about that signal subscribes to it:
Downloader publishes: "download.completed"
↓
Media Manager receives it → imports the file
Transcoder receives it → starts optimizing
Notification module receives it → sends a Discord message
The downloader doesn't know (or care) who's listening. It just sends the signal.
For request/response patterns — "what's the status of task X?" or "give me the metadata for movie Y" — modules make direct calls through the gRPC mesh. The mesh routes calls locally when the target module is on the same machine, or over the network when it's on another node.
Module-to-module calls are protected by policy — modules declare who they are, and the mesh enforces access rules.
When MuxCore runs as a cluster (multiple core instances across machines), every node tracks which modules are running where. This is what makes the mesh routing and module failover work.
-
Modules register locally. A module connects to its local core instance via gRPC and calls
Register(). The core adds it to its local registry. -
Heartbeats advertise module lists. Every 10 seconds, each core sends a heartbeat to every other core in the cluster. The heartbeat carries the node's current module list — every module ID registered on that node.
-
Peers sync module lists. When core-2 receives a heartbeat from core-1, it updates its copy of core-1's
NodeInfo.modules. Core-2 now knows thattranscoder-ffmpegis running on core-1. -
Mesh routing uses the module map. When a module on core-3 calls
transcoder-ffmpeg, the mesh checks which node hosts it. If core-1 has it, the call gets routed through the core-to-core gRPC connection to core-1, then delivered locally.
- Core-1 stops sending heartbeats (process killed, machine down, network partition).
- After 30 seconds without a heartbeat, core-2 and core-3 independently evict core-1 from their member lists.
- Core-1's module list is removed alongside its node entry. The mesh stops routing calls to it.
- A
TYPE_NODE_LEFTcluster event fires, notifying any watchers.
If transcoder-ffmpeg was running on core-1 and core-1 dies:
- Core-1 gets evicted after 30s.
transcoder-ffmpegdisappears from the cluster module map. -
transcoder-ffmpegitself detects its gRPC connection to core-1 is broken. - It queries
Members()on any surviving core to find a new home. - It connects to core-2, calls
Register(), and resumes operation. - Core-2's next heartbeat advertises
transcoder-ffmpegin its module list. - Core-3 receives the heartbeat and updates its routing table.
The mesh now routes transcoder-ffmpeg calls to core-2. Other modules never noticed the failover — they were calling "transcoder-ffmpeg" the whole time. The mesh handled the relocation transparently.
Any module can subscribe to cluster membership changes via DiscoveryService.Watch():
| Event | When |
|---|---|
TYPE_NODE_JOINED |
A new core instance joins the cluster |
TYPE_NODE_LEFT |
A core instance is evicted (dead) or gracefully leaves |
TYPE_NODE_DEGRADED |
A node reports reduced capability |
TYPE_LEADER_CHANGED |
Cluster leader election produces a new leader |
┌──────────────────────┐
│ Admin UI │
│ (HTMX + Go templates)│
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ API Gateway │
│ (REST + OpenAPI) │
└──────────┬───────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌──────▼──────┐ ┌───────▼───────┐ ┌───────▼──────┐
│ Event Bus │ │ Registry │ │ Scheduler │
│ (signals) │ │(thread dir) │ │ (tasks) │
└──────┬──────┘ └───────┬───────┘ └───────┬──────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌──────────▼───────────┐
│ Modules (threads) │
│ │
│ Downloaders │
│ Media Managers │
│ Playback Providers │
│ Transcoders │
│ Storage Backends │
│ Notification Senders │
│ ... and more │
└───────────────────────┘
- Just want to try it? → Getting Started
- Want to understand modules? → Module System
- Want to build a module? → Writing Modules
- Want to see every interface? → Contracts Reference