This guide walks through writing a new plugin for Marauder. Marauder
has three plugin kinds and they all follow the same pattern: a Go
package in backend/internal/plugins/<kind>/<name>/, a single struct
implementing the plugin interface, and an init() function that
self-registers with the global registry.
There are no proprietary plugin loaders, no YAML schemas to fight, no separate plugin manifests. A plugin is one Go file plus its test.
The shape is the same for trackers, clients, and notifiers:
package mytracker
import (
"context"
"github.com/artyomsv/marauder/backend/internal/domain"
"github.com/artyomsv/marauder/backend/internal/plugins/registry"
)
func init() {
registry.RegisterTracker(&plugin{})
}
type plugin struct{}
func (p *plugin) Name() string { return "mytracker" }
func (p *plugin) DisplayName() string { return "My Tracker" }
// ... implement the rest of registry.TrackerThe init() function runs at process start when cmd/server/main.go
blank-imports the package. There is no other registration step.
type Tracker interface {
Name() string
DisplayName() string
CanParse(rawURL string) bool
Parse(ctx context.Context, rawURL string) (*domain.Topic, error)
Check(ctx context.Context, topic *domain.Topic, creds *domain.TrackerCredential) (*domain.Check, error)
Download(ctx context.Context, topic *domain.Topic, check *domain.Check, creds *domain.TrackerCredential) (*domain.Payload, error)
}CanParsemust returntrueif-and-only-if your plugin can meaningfullyParsethe URL. The scheduler picks the first plugin whoseCanParsereturns true (in stable alphabetical order), so be precise. Use aregexpagainst the canonical URL form.Parseis called once when the user adds the topic. Extract the topic ID, the canonical URL, and any per-topic options intotopic.Extra. Don't make HTTP requests here unless you have to — it's also called from validation paths.Checkis called by the scheduler on every tick. Return a*domain.Checkwith a stableHashfield. The scheduler treats a changed hash as "topic was updated" and triggersDownload.Downloadis called only whenCheckreports an update. Return either aMagnetURIor aTorrentFilebyte slice in the*domain.Payload. Don't worry about clients — the scheduler decrypts the user's client config and routes the payload itself.
Implement any of these in addition to Tracker:
// The tracker requires user credentials.
type WithCredentials interface {
Tracker
Login(ctx context.Context, creds *domain.TrackerCredential) error
Verify(ctx context.Context, creds *domain.TrackerCredential) (bool, error)
}
// The tracker exposes per-topic quality variants (LostFilm, Anidub).
type WithQuality interface {
Tracker
Qualities() []string
DefaultQuality() string
}
// The tracker may return Cloudflare challenge pages — opt into the
// cfsolver sidecar's bypass.
type WithCloudflare interface {
Tracker
UsesCloudflare() bool
}
// The tracker can enumerate a series' released seasons/episodes from its
// URL (powers the AddTopic "start from" dropdowns). Implement it by
// fetching the catalog page and reusing the episode parser; see LostFilm's
// SeasonCatalog (fetches /series/<slug>/seasons, groups parseEpisodes by
// season). Exposed via GET /api/v1/trackers/seasons?url=.
type WithSeasonCatalog interface {
Tracker
SeasonCatalog(ctx context.Context, url string) ([]Season, error) // Season{Number int; Episodes []int}
}
// The tracker gates login behind a captcha the user solves in-app.
type WithInteractiveLogin interface {
Tracker
BeginLogin(ctx context.Context, creds *domain.TrackerCredential) (*LoginChallenge, SessionCookies, error)
CompleteLogin(ctx context.Context, challengeID, answer string) (SessionCookies, error)
RefreshChallenge(ctx context.Context, challengeID string) (*LoginChallenge, error)
}The registry detects these via type assertion at runtime — no separate registration needed.
For trackers that gate login behind a captcha, don't hand-roll the flow —
embed the shared captchalogin.Engine. Supply a captchalogin.Config
(LoginURL, CaptchaURL, CookieNames, a BuildForm that assembles the
POST body, and a Classify that maps the response to
Success/NeedCaptcha/WrongCaptcha/Failed) and delegate the three
WithInteractiveLogin methods to engine.Begin/Complete/Refresh.
Construct the engine lazily (e.g. behind sync.Once) with a newSess
that returns a fresh session per call — the engine holds one session
per pending challenge and must never share jars across challenges.
The user solves the captcha through
POST /api/v1/credentials/interactive/{begin,complete,refresh}; the
harvested cookie(s) named in CookieNames are persisted encrypted in
tracker_credentials.session_enc. Make the plugin's Login (from
WithCredentials) rehydrate that cookie into its session jar and
validate it via Verify, returning registry.ErrSessionExpired when the
cookie is absent or no longer authenticates so the user is re-prompted.
Advertise the capability to the credentials UI by ensuring it shows up in
GET /system/info (automatic — the type assertion drives the
supports_interactive_login flag). See plugins/trackers/lostfilm for
the reference implementation.
The interactive add flow also persists the password (encrypted
secret_enc) so an expired session can be re-established without
re-entering credentials. When Login returns ErrSessionExpired, the
scheduler fires a one-shot notification (via the notify dispatcher) and
the UI offers a captcha-only re-auth (/credentials/{id}/reauth/*) that
decrypts the stored password to fetch a fresh captcha. So a captcha
tracker should implement BOTH WithCredentials (cookie rehydration in
Login) and WithInteractiveLogin (the captcha flow).
Forum-style trackers should use internal/plugins/trackers/forumcommon
which provides a SessionStore keyed by (plugin_name, user_id).
Cookies persist for the lifetime of the process, so concurrent topic
checks for the same user reuse the same logged-in client.
Always use recorded HTML fixtures rather than live sites:
const fixtureTopicHTML = `<html>
<head><title>Some Show :: My Tracker</title></head>
<body>
<a href="magnet:?xt=urn:btih:0123456789ABCDEF...">Magnet</a>
</body>
</html>`
func newTestPlugin(t *testing.T) (*plugin, *httptest.Server) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/topic/") {
w.Write([]byte(fixtureTopicHTML))
}
}))
t.Cleanup(srv.Close)
host := strings.TrimPrefix(srv.URL, "http://")
return &plugin{
domain: host,
transport: &schemeRewrite{},
}, srv
}The trick: production code prepends https://, so tests inject a
custom http.RoundTripper that rewrites scheme to http before the
request leaves the test process.
See internal/plugins/trackers/rutracker/rutracker_test.go for the
canonical example.
type Client interface {
Name() string
DisplayName() string
ConfigSchema() map[string]any
Test(ctx context.Context, rawConfig []byte) error
Add(ctx context.Context, rawConfig []byte, payload *domain.Payload, opts domain.AddOptions) error
}ConfigSchemareturns a JSON Schema document that the frontend uses to render the configuration form. For v0.4 the frontend uses hard-coded field hints infrontend/src/pages/Clients.tsx, but the schema is the source of truth and v0.5 will switch to schema-driven rendering.Testis called when the user clicks "Test connection" or before the config is persisted. Return nil on success.Addreceives the decrypted raw config bytes from the scheduler — you don't see ciphertext.
Stand up a tiny net/http/httptest.Server that mimics the real
client's API. See qbittorrent_test.go for the qBittorrent WebUI v2
fake or transmission_test.go for the transmission RPC 409-dance.
For real-world validation, the dev compose overlay
(deploy/docker-compose.dev.yml) starts real qBittorrent and
Transmission containers — see docs/test-e2e-magnet.md for the
walkthrough.
type Notifier interface {
Name() string
DisplayName() string
ConfigSchema() map[string]any
Test(ctx context.Context, rawConfig []byte) error
Send(ctx context.Context, rawConfig []byte, msg domain.Message) error
}Test typically calls Send with a hard-coded "this is a test"
message. Mock the upstream with httptest.Server for the unit tests,
or substitute a function field (the email plugin's sender field is
the cleanest example).
Add a single blank import to backend/cmd/server/main.go:
import (
// ...
_ "github.com/artyomsv/marauder/backend/internal/plugins/trackers/mytracker"
)That's the entire wiring. The plugin is now visible in
GET /api/v1/system/info, can be configured through the UI, and is
called by the scheduler whenever a topic with tracker_name="mytracker"
is due.
Tracker plugins shipped without live-account validation should be marked alpha in their package doc comment. The convention is:
// Package mytracker implements a tracker plugin for example.com.
//
// **Validation status:** structurally complete with fixture-based unit
// tests. Live validation requires a real account, which was not
// available in the original implementation session — see CONTRIBUTING.md
// for the validation procedure.A first-time contributor with a real account can:
- Set the plugin's credentials via the UI.
- Add a known-good topic URL.
- Wait one scheduler tick.
- Verify in the System status page that the topic was checked without errors and that the hash matches what they see in their browser.
- File an issue with
validated: truein the title and the plugin moves out of alpha in the next release.
A plugin's Check is called on every scheduler tick. To stay polite
to upstream servers and to keep Marauder's footprint small:
- Aim for < 1 second per Check in the steady state. The default HTTP timeout is 30s, but real CIS forum trackers respond in under 500ms once the session is hot.
- Don't make extra HTTP calls in
Parseunless you absolutely have to. Parse should be a regex. - Cache the result of
CheckwithinDownloadif you can — the scheduler always calls them as a pair, and a re-fetch is wasted bandwidth. - Honour the context. Every
CheckandDownloadreceives a context with a deadline. Plumb it through to yourhttp.NewRequestWithContextcalls.
- Open a GitHub Issue with the
pluginlabel. - Look at existing plugins under
backend/internal/plugins/. They are all small, all follow the same pattern, and most are under 200 lines. - The shared
forumcommonandcfsolverpackages exist precisely to handle the cross-cutting concerns that would otherwise be repeated in every plugin.