All notable changes to Marauder will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- arm64 images now contain arm64 binaries β the
backendandcfsolverDockerfiles hardcodedGOARCH=amd64, so the publishedlinux/arm64images shipped an amd64 binary that failed withexec format erroron realaarch64hosts. They now cross-compile to BuildKit'sTARGETARCH. The release workflow now verifies each published platform's binary architecture (and execs it under QEMU as a liveness check), failing the build if a binary is the wrong architecture. (#74)
- Duplicate qBittorrent deliveries are now idempotent β when a torrent's
infohash is already present, qBittorrent rejects a re-submit (with
409 Conflict, or200 "Fails."on older versions like 5.1.4), which previously drove the topic into theerrorstate even though the torrent was correctly present and downloading. Marauder now verifies the payload's infohash is actually present and treats such a rejection as a successful delivery; genuine failures (infohash absent) still surface as errors. (#76)
- qBittorrent category is now set on the torrent β a topic's category was
folded into the save path but never sent as qBittorrent's native
categoryfield, so completed torrents had an empty category and tools like Sonarr (which discover downloads by category) could not import them. The category is now sent on/api/v2/torrents/add. (#75)
- Per-topic notifier override β a topic can route its new-release
notifications to one specific notifier instead of the global notifier set,
via an optional
Notifierselector on the Add/Edit Topic form. No override keeps the existing behaviour (all subscribed notifiers fire). (#51)
- qBittorrent is now validated end-to-end and promoted from
alphatovalidatedon marauder.cc. Verified against a real qBittorrent container: login, magnet/.torrent submit, and live download status by infohash.
- fix(rutracker): prefer authenticated .torrent over hash-only magnet
- fix(ci): stop suppressing release.yml on the release-cut commit
- The "Marauder vs monitorrent" comparison page (
/vs/monitorrent) advertised a stale "Latest release v1.0.0"; it now renders the current version from the shared site config so it stays in sync with releases (#55).
- Kinozal metadata resolution (
WithMetadata): the AddTopic form now resolves a real title + poster from a Kinozal topic URL (cp1251-decoded<title>+og:image), matching the RuTracker/LostFilm experience.
- Kinozal is now validated end-to-end and promoted from
alphatovalidatedon marauder.cc. Verified against a live account: login, infohash resolution, metadata, and download β torrent-client delivery.
- RuTracker delivered a hash-only magnet that left qBittorrent stuck on
"Downloading metadata" forever (#52). RuTracker page magnets carry no
announce URLs, so on a private tracker the client could never find peers.
Downloadnow prefers the authenticated.torrentfromdl.php(which carries the announce list and full info dict), validates it is a real bencoded torrent before submitting, and falls back to the page magnet only when credentials are absent or the.torrentis unavailable. The fallback is now logged so a dead session no longer silently re-triggers the stuck state, and the bencode parser gained a nesting-depth bound (the dl.php response is the first remote, untrusted input it validates). - Kinozal checks reported
no infohash found in topic page(#48): the plugin scraped the details page, which doesn't carry the hash. It now reads the infohash from the authenticatedget_srv_details.php?id=<id>&action=2endpoint and tolerates bothΠΠ½ΡΠΎ Ρ Π΅Ρ/ΠΠ½ΡΠΎ Ρ ΡΡspellings. - AddTopic display name kept the previous tracker's title when the URL was changed; an auto-filled name now refreshes on URL change while a user-typed name is preserved.
deploy/docker-compose.test-clients.yml: a client-only integration test matrix that spins up every supported download client across versions β qBittorrent5.2.1and5.1.4, Transmission4.1.2and4.0.6, Deluge2.2.0, and a profile-gated Β΅Torrentv2.1.0β each with a healthcheck and a pinned image tag, host ports bound to127.0.0.1in the 34xxx range. It composes standalone (poke clients on their host ports) or layered onto the base stack so the backend reaches each client by Docker service DNS. All five client plugins were verified end-to-end against their real containers via the Marauder API (create-client β pluginTest()login/connect).- Client acceptance CI (
.github/workflows/client-acceptance.yml+deploy/acceptance/acceptance.sh): nightly matrix that creates every supported client through the Marauder API against a real container, on a pinned baseline (blocking, also runs on tags to gate releases) and on each client's latest image (non-blocking canary that auto-files a deduped issue when an upstream release breaks a client β the early warning that issue #38 lacked).
- qBittorrent client setup failing against qBittorrent 5.2.x with
client test failed: login failed: status=204 body=""(#38). qBittorrent 5.2.0 changed the WebUI/api/v2/auth/loginsuccess response from200 "Ok."to204 No Content; the plugin previously accepted only the legacy form and rejected valid logins. Login success is now decided by aloginSucceededhelper that accepts both contracts while still rejecting failures β including a204carrying an unexpected body. Verified empirically against linuxserver/qbittorrent5.1.4(200Ok.) and5.2.1(204, empty).
- Prebuilt container images published to GitHub Container Registry
(
ghcr.io/artyomsv/marauder-{backend,frontend,cfsolver}) and a no-clonedeploy/docker-compose.ghcr.ymlpull stack that ships the gateway nginx config inline via Composeconfigs:. This is the first release to publish images β v1.0.0 was tagged before the release pipeline existed. docs/getting-started.md: a full guide covering both the prebuilt-image and build-from-source install paths, version pinning, and troubleshooting.
release.ymlnow stamps the build version, commit, and date into the published images via build args; previously the released binary reported0.0.0-devfrom/api/v1/system/info.
Marauder previously exposed the gateway on host port 6688, the dev
overlay used 8679 / 8680 / 55432 / 6611 / 9191, and the SSO
overlay used 8643. All of these violate
~/.claude/rules/local-port-ranges.md, which requires host-exposed
ports to live in the 30000-49999 range to avoid colliding with
gcloud emulators, IDE debuggers, framework defaults, and other dev
tools that compete for low-numbered ports.
This phase migrates every host-exposed port to the 34xxx namespace
(Marauder = project number 4 in the 3Xxxx mnemonic). Container-
internal ports stay unchanged per the rule's scope clarification β
only the host side of the ports: mapping moves.
| Service | Old | New | Container-internal |
|---|---|---|---|
| Gateway (prod) | 6688 |
34080 |
6688 |
| Vite dev server | 5174 |
34000 |
n/a |
| Backend (dev overlay) | 8679 |
34081 |
8679 |
| Frontend container (dev overlay) | 8680 |
34001 |
8081 |
| Postgres (dev overlay) | 55432 |
34432 |
5432 |
| qBittorrent (dev overlay) | 6611 |
34611 |
6611 |
| Transmission (dev overlay) | 9191 |
34091 |
9091 |
| Keycloak (sso overlay) | 8643 |
34643 |
8643 |
55432 was actually the worst offender β completely out of the
allowed range (above 49999). The old 6611 for qBittorrent and
9191 for Transmission were in forbidden 6xxx/9xxx zones.
deploy/docker-compose.ymlβ gateway port mapping now${MARAUDER_HOST_PORT:-34080}:6688. Defaults forMARAUDER_PUBLIC_BASE_URLandMARAUDER_CORS_ORIGINSupdated tohttp://localhost:34080.deploy/docker-compose.dev.ymlβ every published port wrapped in env vars with34xxxdefaults:MARAUDER_DEV_DB_PORT,MARAUDER_DEV_BACKEND_PORT,MARAUDER_DEV_FRONTEND_PORT,MARAUDER_DEV_QBIT_PORT,MARAUDER_DEV_TRANSMISSION_PORT.deploy/docker-compose.sso.ymlβ Keycloak host port β34643(still listens on 8643 internally so the docker-network DNS linkhttp://keycloak:8643/realms/marauderfrom the backendMARAUDER_OIDC_ISSUERstill works).MARAUDER_OIDC_REDIRECT_URLdefault is the browser-facing host porthttp://localhost:34080/....deploy/.env.exampleβ everylocalhost:6688reference andMARAUDER_HOST_PORT=34080documented at the top of the Server section.deploy/keycloak/realm-marauder.jsonβredirectUris,webOrigins, andpost.logout.redirect.urisall updated tolocalhost:34080.backend/internal/config/config.goβCORSOriginsandPublicBaseURLenvDefault tags now point at34000(Vite dev) and34080(gateway).frontend/vite.config.tsβ dev serverport: 34000(was 5174). The/apiproxy default target is nowhttp://localhost:34081(the dev compose backend host port)..github/workflows/e2e.ymlβ everycurltolocalhost:6688βlocalhost:34080, andlocalhost:6611βlocalhost:34611.README.md,CONTRIBUTING.md,docs/PRD.md,docs/oidc.md,docs/test-e2e-magnet.mdβ all quick-start instructions, sample curl commands, and infrastructure tables updated.site/src/pages/{features,index,install}.astroβ marketing copy and code blocks updated.CLAUDE.mdβ Ports section rewritten with the full new allocation table including the env var override knobs.
The previous 6688 port worked fine in isolation but was a ticking
time bomb on a developer machine running multiple services
simultaneously. The user's docker ps showed:
projectr-xalready owns the entire31xxxbandtest-me-aiuses45xxxgotenbergandclamavsquat on3100and3310keycloak-shared-devsits on9080
Marauder's 6688 happened not to collide but 8679 / 8680 /
6611 / 9191 from the dev overlay all could have. Pushing every
host port into the 34xxx band gives Marauder its own isolated
namespace and aligns with the global rule.
A user reported entering an intentionally wrong username, clicking the "Test login" button on the Credentials page, and seeing the UI report success. Root cause analysis found three layered bugs across the handler and all 8 forum-tracker plugins:
credentials.gohandler discardedVerify's bool return. TheTesthandler didif _, err := wc.Verify(...); err != nilβVerifyreturns(bool, error)where the bool is "is the session still alive". When the user wasn't really logged in, Verify returned(false, nil)and the handler treated the missing error as success. TheCreateandUpdatehandlers didn't callVerifyat all, relying onLoginreturning nil as the success signal.rutracker.goLogin ALWAYS succeeded due to an|| resp.StatusCode == 200escape hatch in its success check. The login page always returns 200 (with a "wrong password" form on failure), so Login never failed regardless of credentials.tapochek.goLogin had no check at all β it just did the POST, closed the body, and setsess.LoggedIn = true.Verifyreturned(true, nil)unconditionally. Tapochek was effectively a "trust whatever the user typed" credential sink.lostfilm_session.goLogin was fragile β only checked for the substring"error"in the response body, with no HTTP status check and no positive success indicator.Verifychecked for the literal word"logout"which could false-positive on any page that happened to mention it (meta descriptions, cookie banners, etc).- Every other forum tracker (
kinozal,nnmclub,anidub,toloka,unionpeer,hdclub,freetorrents) did only negative-indicator detection: "does the response body contain the phrase for 'wrong password' in the target language?". Fragile, dependent on upstream wording staying stable. - 11
body, _ := io.ReadAll(...)discards across these 8 plugins violated~/.claude/rules/go-conventions.md(never assign to_without justification). I caught and fixed this pattern inscheduler.goduring the Phase 5 refactor but missed the tracker plugins.
loginAndVerifyhelper incredentials.go: runs Login + Verify and fails if EITHER step fails, explicitly treatingVerify(...) == (false, nil)as failure with a clear error message. Used uniformly byCreate,Update(password-rotation branch), andTest. This is the primary fix β even if a plugin's Login has a false-positive, Verify's positive-indicator check (authenticated page contains a logged-in marker) catches it.rutracker.go: removed the|| resp.StatusCode == 200escape hatch. Login now requires the positiveid="logged-in-username"marker in the response body.tapochek.go:Verifynow hits/index.phpand looks for alogout.php?sid=nav link (phpBB authenticated-only pattern). A permissive "always true" was the worst possible default.lostfilm_session.go:Loginnow requires HTTP 200 and a non-empty body in addition to the negative-indicator check. Real LostFilm success returns a JSON user object; empty or non-200 is explicitly rejected.Verifynow requires BOTH a specifichref="/logout"anchor AND the absence of the login form (type="password"/name="mail"). Belt-and-suspenders β a site redesign would have to flip both signals at once to silently break the check.
credentials_test.go(new): table-driven regression test forloginAndVerify. Theverify returns (false, nil) β the regression the user reportedcase is pinned closed forever.- All 8 forum plugin bodies now propagate
io.ReadAllerrors with%wwrapping instead of discarding them with_. tapochek_e2e_test.gofake server updated to serve an/index.phppage with thelogout.php?sid=marker that the new Verify expects.
The 7 forum plugins with only-negative-indicator Login checks
(kinozal, nnmclub, anidub, toloka, unionpeer, hdclub,
freetorrents) are still somewhat fragile in isolation β a wording
change to the login page would break them. But loginAndVerify in
the handler makes this defense-in-depth: the handler always calls
Verify after Login, and Verify uses positive indicators. A regression
would require BOTH the negative-indicator AND the Verify marker to
drift at the same time. If any plugin's Verify also turns out to be
too loose, the same helper makes it easy to audit β it's one call
site for all 8 plugins.
A four-agent code review (security-officer + code-reviewer + rules-
compliance + qa) of the Phase 4 commits surfaced ~25 findings ranging
from a real correctness bug (mid-loop submit failure marking
updated=false) to an SSRF in the LostFilm redirector chain to a
brittle stringly-typed cross-package error contract. This phase fixes
all of them. Work was split into 8 tracks across 7 parallel agents
plus an orchestrator, with file ownership designed to avoid edit
conflicts.
backend/internal/plugins/registry/errors.go(new) β exportsvar ErrNoPendingEpisodes = errors.New("no pending episodes"). Per- episode trackers (currently only LostFilm) wrap this via%wfromDownloadwhen the pending list is empty; the scheduler matches it viaerrors.Is. Replaces the previousstrings.Containssubstring match that was the brittle inter-package contract flagged by every reviewer.backend/internal/extra/(new package) β exportsextra.Int(m, key),extra.StringSlice(m, key),extra.String(m, key, fallback). Reads values out of the untypedmap[string]anyblobs thatdomain.Topic.Extraanddomain.Check.Extracarry, handling JSON-roundtrip shape drift ([]stringβ[]any,intβfloat64). Bothlostfilm.goandscheduler.gohad near-identical local copies of these helpers before; both now import the shared package and the local copies are deleted.
runChecksplit into a thin orchestrator +loadCredentials+downloadAllPending+recordResult.runCheckis now ~30 lines (was 125) and each piece is independently testable.- C-1 fix β mid-loop submit failure now records progress. Previously
a submit failure on iteration
i > 0calledRecordCheckResultwith hardcodedupdated=false, forgetting the timestamp of the last topic-updated event. Now passesupdated || anySubmitted. - H-5 fix β per-iteration context. The download loop previously
shared a single
TrackerHTTPTimeout + 5sdeadline across up to 25 Download+Check round-trips; a 12-episode series could trip the deadline mid-loop and loseRecordCheckResultfor the successful hash. Each iteration now gets its owncontext.WithTimeout(ctx, TrackerHTTPTimeout); persistence calls use the parent ctx so they survive iteration deadline expiry. - H-6 fix β dropped redundant
tr.Check. The loop previously refetched the entire LostFilm series page after every single episode download (12 episodes = 13 series-page fetches per tick). Remaining episodes are now derived locally fromcheck.Extra["pending_episodes"][1:]. - H-7 fix β atomic persistence.
markEpisodeDownloadedno longer silently swallows persistence failures. Uses the new atomicTopics.MarkEpisodeDownloaded(ctx, id, packed)repo method (Phase 5d) when the topics repo implements the optional interface; bubbles errors out so the next tick retries. - H-8 fix β
maxPerTickis now configurable viacfg.SchedulerMaxEpisodesPerTick(envMARAUDER_SCHEDULER_MAX_EPISODES_PER_TICK, default 25). Cap-hit logs a Warn and increments the newmarauder_scheduler_episodes_per_tick_capped_total{tracker_name}counter so operators can see when a runaway tracker is losing progress every tick. - R-1 fix β discarded errors. All seven
_ = s.topics.RecordCheckResult(...)call sites inrunCheckgo through a newrecordResultwrapper that logs persistence failures at Warn level. No more silent swallows in the hot path. - Testability seams: introduced 5 small consumer-side interfaces
(
topicsRepo,markEpisodeDownloader,clientsRepo,credentialsRepo,decryptor) plus 2 lookup-fn fields (trackerLookupFn,clientLookupFn) on*Scheduler. The exportedNew(...)constructor signature is unchanged socmd/server/main.gostill compiles. backend/internal/scheduler/scheduler_test.go(new) β 10 test functions covering: hash unchanged, single-payload happy path, 3-pending-episodes loop, first-iteration error, mid-loop error preserves progress, persistence failure mid-loop, max-per-tick cap enforcement, fmt-wrapped sentinel matching, table-driven backoff curve (7 cases across 0β20 consecutive errors with cap at 6h), and the typed sentinel matcher itself. The scheduler package previously reported[no test files]; this closestechdebt/2-3-scheduler-no-unit-tests.md(file removed in this phase).
- HIGH-1 fix β SSRF allowlist.
fetchTorrentByPackedpreviously followed anyLocation/meta-refresh URL through external hosts with the user's authenticated session cookies attached, with no host allowlist. A compromised redirector could have pointed at internal addresses or exfiltrated cookies. NewvalidateRedirectURL:- parses the URL and rejects non-
http(s)schemes, - rejects any host not in
allowedRedirectHosts(lostfilm.tv + its known redirector chain hosts), - resolves the host via
net.LookupIPand rejects loopback, private (RFC 1918), link-local, and unspecified addresses. Applied to BOTH the v_search next-hop AND the final.torrentURL. A test seam (plugin.redirectValidatorfield) lets unit tests install a permissive validator since httptest uses 127.0.0.1.
- parses the URL and rejects non-
- H-9 fix β
userAgentreverted to project convention. Was"Mozilla/5.0 (Marauder; +https://marauder.cc) AppleWebKit/537.36"β the worst of both worlds (still trivially a bot, inconsistent with every other plugin). Now"Marauder/1.1 (+https://marauder.cc)"with an explanatory comment. - LOW-4 fix β body preview leakage gone. The
v_search returned no redirecterror previously embedded up to 200 bytes of upstream HTML in its message, which the scheduler persisted intotopics.last_errorand the UI displayed. If the upstream login page contained a CSRF token, it ended up in the DB. Replaced with a stable string:"likely not authenticated, please re-add credentials". Downloadreturns the typed sentinel whenpending_episodesis empty, viafmt.Errorf("lostfilm Download: %w (...)", registry.ErrNoPendingEpisodes).- Adopted shared extra package β local
extraInt,extraStringSlice,stringFromAnydeleted; all call sites useextra.Int/extra.StringSlice/extra.String. - 4-file split β
lostfilm.gowas ~685 lines (over the ~300 ceiling). Now split across:lostfilm.go(~245 lines) β package doc, plugin struct, registry registration,CanParse,Parse,Check,Downloadlostfilm_session.goβ constants,urlPattern,Login,Verify,session,fetch,fetchURLlostfilm_parse.goβ episode regexes,episodeRef,parseEpisodes(now usingsort.Slice)lostfilm_redirector.goβallowedRedirectHosts,validateRedirectURL,fetchTorrentByPackedorchestrator,resolveVSearchRedirect,pickQualityLink,qualityMatches,sanitiseQuality
- Read errors no longer silently dropped β
Login,Verify,fetch, andfetchURLpreviously didbody, _ := io.ReadAll(...). IO errors now propagate with%wwrapping. qualityMatchesunknown-quality fallback removed β previously fell through tostrings.Containsfor unknown tiers, which would re-introduce the 1080p/1080p_mp4 trap for any future quality. Now returnsfalsefor unknown qualities, forcing callers to add new tiers explicitly.parseEpisodesnow usessort.Slice(clearer for typical 100+-episode series) and the_ = strconv.Atoi(...)discards have comments noting the regex guarantees digit-only matches.- New tests:
TestValidateRedirectURL(table-driven, 7 cases including allowlist + scheme + loopback + private IP rejection),TestDownloadEmptyPendingReturnsTypedSentinel(3 subtests assertingerrors.Is(err, registry.ErrNoPendingEpisodes)for nil-Extra, empty-slice, and missing-key cases), 2 newTestQualityMatchercases for unknown qualities. ExistingTestE2EandTestRedirectorFlowupdated to install a permissive validator.
Topics.MarkEpisodeDownloaded(ctx, id, packed) errorβ new atomic method that appends a packed episode ID toextra["downloaded_episodes"]via a single SQLjsonb_set+||expression. UnlikeUpdateExtra(which read-modify-writes the entire blob) this:- cannot wipe other extras keys,
- is safe under concurrent updates,
- returns
repo.ErrNotFoundif the topic was deleted between Check and the loop iteration.
UpdateExtrano longer silently no-ops when the topic is gone β capturesRowsAffected()and returnsErrNotFound. Marked// Deprecated:in the doc comment in favor of the new atomic method.scanTopicno longer swallows malformed JSON in theextrablob. Previously_ = json.Unmarshal(extraRaw, &t.Extra)returned an empty map silently; now returns a wrapped error so the row is rejected and the caller can log it.- Testability refactor:
Topics.poolfield changed from*pgxpool.Poolto a small unexportedtopicsPoolinterface (Exec/Query/QueryRow) sopgxmockcan slot in. TheNewTopicsconstructor still takes*pgxpool.Poolso callers don't change. backend/internal/db/repo/topics_test.go(new) β first tests in thedb/repopackage. 10 test functions covering happy path / not-found / DB error / nil-map serialization for bothUpdateExtraandMarkEpisodeDownloaded, plus a regression test that drivesGetByIDthrough a row with a malformedextrablob and asserts the new error path. Usespgxmock/v3β no Docker Postgres needed.
frontend/src/lib/queryKeys.ts(new) βQKconstant exporting every React Query key used in the codebase asas consttuples:QK.clients,QK.client(id),QK.topics,QK.credentials,QK.notifiers,QK.systemInfo,QK.trackerMatch(url),QK.audit,QK.systemStatus. Replaces 20+ inline string-literal keys across the page files. A typo in anyinvalidateQueriescall is now a TypeScript error instead of a silent no-op.frontend/src/lib/hooks/useSystemInfo.ts(new) β wraps the/system/infoquery (5-min stale time, no auth) so the AppShell version chip and the Settings About card and the Clients, Credentials, Notifiers pages all share one cache entry instead of five duplicateuseQueryblocks.frontend/src/lib/hooks/useLogout.ts(new) β extracts the refresh-token revoke + auth-store clear + navigate-to-login sequence that was duplicated between AppShell and Settings. Standardizes both surfaces on SPA navigation (useNavigate) β Settings previously did a hardwindow.location.href = "/login"reload.frontend/src/lib/hooks/useDebouncedValue.ts(new) β genericuseDebouncedValue<T>(value, delayMs): Tfor feeding text-input state into React Query'senabledflag without firing a request on every keystroke. Used by Topics'/trackers/matchlookup.AppShell.tsxandSettings.tsxrefactored to use the new hooks, dropping the duplicated query/logout code.
frontend/src/components/shared/ResourceCard.tsx(new) β slot- based card chrome for list pages. Props:title,icon?,badges?,actions?,children?,glow?(primaryoraccent),onClick?. Preserves the sameframer-motionanimation,group-hover:opacity-100actions reveal, and Tailwind blur/glow background that the three pages used inline.Clients.tsx,Credentials.tsx,Notifiers.tsxall migrated to render their list cards via<ResourceCard>. AdoptedQK.*query keys and theuseSystemInfo()hook from Phase 5e. Per-page Test connection / Edit / DeleteConfirm action rows preserved unchanged.
frontend/src/lib/hooks/useArmedConfirm.ts(new) β extracts the idleβarmed state machine thatDeleteConfirmand the TopicsBulkActionBarhad implemented separately. Returns{ armed, arm, disarm, confirmAndDisarm }with an auto-disarm timer (default 4000 ms).DeleteConfirm.tsxinternaluseState+useEffect+useRefmachinery replaced withuseArmedConfirm({ timeoutMs }). External API unchanged. Hardcodedaria-label="Delete?"now derives from thelabelprop (Delete topic?,Delete client?, etc) for future i18n.Topics.tsxBulkActionBarrewritten to useuseArmedConfirminstead of its inlineuseState(false)+setTimeoutclone.Topics.tsxAddTopicCard/trackers/matchlookup β the hand-rolleduseEffect+setTimeoutdebounce (with aneslint-disable-next-line react-hooks/exhaustive-depssmell) replaced withuseDebouncedValue(url, 350)+useQuery({ queryKey: QK.trackerMatch(debounced), enabled: debounced.length >= 8 }). React Query owns the cache; the localuseState<TrackerMatch | null>anduseState<string | null>(matchError)are gone.- All
["topics"]literal query keys in Topics.tsx replaced withQK.topics.
frontend/vitest.config.ts(new) β Vitest config withenvironment: 'jsdom', the@vitejs/plugin-reactplugin, and the@/*path alias.frontend/src/test/setup.ts(new) β wires@testing-library/jest-dom/vitestmatchers and runs RTLcleanup()after each test.frontend/src/components/shared/DeleteConfirm.test.tsx(new) β 7 tests against theDeleteConfirmpublic API: idle render, arming, confirm firesonConfirm, cancel returns to idle, default 4 s auto-disarm viavi.useFakeTimers({ shouldAdvanceTime: true }), customtimeoutSeconds,isPendingdisables the button. UsesuserEvent(notfireEvent) for higher-fidelity interactions.frontend/package.jsonβ addedvitest@^2.1.8,@testing-library/react@^16.1.0,@testing-library/user-event@^14.5.2,@testing-library/jest-dom@^6.6.3,jsdom@^25.0.1to devDependencies; addedtestandtest:watchscripts. The frontend previously had no test runner at all.
deploy/docker-compose.ymldev marker bumped from0.4.0-alpha(which was both stale post-v1.0.0 AND inconsistent with the previous commit's CHANGELOG/ROADMAP that already claimed1.1.0-dev) to1.1.0-devfor backend / frontend / cfsolver. Added the newMARAUDER_SCHEDULER_MAX_EPISODES_PER_TICKenv var to the backend service block.- Scheduler
isNoPendingErrorcleaned up β the transitionalstrings.Containsfallback that Phase 5b left in place during the cross-track parallel refactor is now deleted, since LostFilm (Phase 5c) wraps the typed sentinel everywhere it returns the empty-pending error. Thestringsimport is gone fromscheduler.go. Test case renamed fromlegacy_substringtountyped substring no longer matches(asserting the inverse). techdebt/2-3-scheduler-no-unit-tests.mdremoved β the newscheduler_test.go(Phase 5b) closes this debt. TheTestRunCheck_NonAtomicFallbacktest still exists to cover the graceful-degradation path on themarkEpisodeDownloaderoptional interface, in case a future repo implementation lacks the atomic method.CLAUDE.md(new, project-level) β structural snapshot of the repo for future Claude sessions per~/.claude/rules/documentation-maintenance.md. Catalogs the backend package layout, the newextrapackage, the new shared frontend hooks and queryKeys, the scheduler design (post-Phase 5b), the per-episode tracker contract, and the conventional dev commands.
The previous draft of Phase 4fβh posted c/s/e form fields to
/v_search.php and "picked the latest episode per Check", which was
a plausible-but-wrong reverse-engineering of LostFilm. This entry
replaces that draft with the actual flow extracted from
main.min.js:
lostfilm.gorewritten end-to-end around the realPlayEpisode(a) { window.open("/v_search.php?a=" + a) }JS shape:- Series page parsing now reads two redundant attributes per
episode:
data-code="<show>-<season>-<episode>"(canonical, hyphens not colons) anddata-episode="<show><sss><eee>"(the packed integer used byPlayEpisode). Both forms are decoded;data-episodeis the fallback whendata-codeis missing. Checkis now stateful: it parses every episode, applies thestart_season/start_episodefloor, subtracts thetopic.Extra["downloaded_episodes"]set, and returns the pending list incheck.Extra["pending_episodes"](packed IDs) plus a deterministic hash of the formeps:N/done:M/pending:K. The hash flips both when new episodes appear AND when the user catches up, so the scheduler always re-evaluates.Downloadis now per-episode: it pulls the oldest pending packed ID fromcheck.Extra, GETs/v_search.php?a=<packed>(it is a GET, not a POST), captures the 302Location(or meta-refresh body fallback), follows the redirect through external hosts (retre.org/tracktor.in/lf-tracker.io), parses the destination's per-quality.torrentbuttons, picks the one matchingtopic.Extra["quality"], and GETs the bencode bytes. Failure when the redirect lands on/loginis surfaced explicitly as "session expired".
- Series page parsing now reads two redundant attributes per
episode:
qualityMatcheshelper locks down a sharp footgun: the naiveContains(label, want)test would have false-matched1080p_mp4for a user asking for plain1080p. The helper hard-codes the three known LostFilm tiers (SD,1080p,1080p_mp4/mp4) so each maps to a distinct, non-overlapping label substring. NewTestQualityMatchertable-test pins all 11 cases.- Per-episode state tracking lives in
topic.Extra["downloaded_episodes"](slice of packed IDs). The scheduler appends to this list after every successful submit and the nextChecksubtracts it from the pending set. JSON-roundtrip through the JSONB column produces[]anyinstead of[]string, so a newextraStringSlicehelper handles both shapes. TestRedirectorFlowrewritten to drive the new flow end-to-end against anhttptest.Server: 3 episodes parsed, all 3 pending β Download fetches the oldest first β asserts?a=791001005hits the server with method=GET β asserts the resulting torrent path contains1080pbut not1080p_mp4β marks the first episode downloaded β reruns Check β asserts pending shrinks to 2 β applies start_season=3 filter β asserts pending is empty.backend/internal/db/repo/topics.gogainsTopics.UpdateExtra(ctx, id, extra)so the scheduler can persist the growingdownloaded_episodeslist without touching the rest of the topic row.
scheduler.runCheckper-episode loop. The scheduler now drains every pending episode in one tick instead of one-per-tick. The inner loop callstr.Download(ctx, t, check, creds)repeatedly until either:- Download returns the
"no pending episodes"sentinel error (matched viaisNoPendingError) β graceful exit, all submitted work counted as success. - A real download/submit error occurs mid-loop β record the progress made so far AND record the error+backoff so the next tick retries.
- The
maxPerTick = 25safety guard fires β stops a misbehaving plugin from burning unbounded download bandwidth per tick.
- Download returns the
Scheduler.markEpisodeDownloadedpops the first pending packed ID offcheck.Extra["pending_episodes"], appends it totopic.Extra["downloaded_episodes"], and persists viaTopics.UpdateExtra. After persisting, the scheduler re-runstr.Checkso the next loop iteration sees the shrunken pending list (and a fresh hash).- Backwards-compatible: every existing tracker plugin returns
one payload from
Downloadand then errors on the second call ("nothing to download"). The loop'si > 0 + isNoPendingErrorbranch breaks cleanly after one iteration, so single-payload plugins keep their old semantics for free. - Tech debt logged:
techdebt/2-3-scheduler-no-unit-tests.mdnotes that the scheduler still has zero unit-test coverage and that the "no pending" contract is currently a stringly-typederrors.Is-incompatible sentinel.
frontend/src/components/shared/DeleteConfirm.tsx(new) β a one-click trash icon that swaps in place to aDelete? β βrow on the first click and only firesonConfirmafter the second. Auto-cancels after 4s of inactivity. No modal, no portal, no layout shift β the confirm row replaces the trash icon at the same position. Click events are stopped from bubbling so it can live inside row-level click handlers. Spinner state viaisPending.- Wired into 4 pages (
Topics.tsx,Clients.tsx,Credentials.tsx,Notifiers.tsx), replacing the previous<Button onClick={() => del.mutate(id)}>patterns. Removes the accidental-deletion footgun without adding a modal dialog β consistent with the "no JS confirm() / no blocking modals" rule.
AppShell.tsxandSettings.tsxnow render the live build version fromGET /api/v1/system/infoinstead of hardcodedv0.1/v0.4.0-alphastrings. Settings additionally renders the build commit and date when present (suppressed if either is literally"unknown"). 5-minute React Query stale time.deploy/docker-compose.ymldev marker bumped from0.1.0-devβ1.1.0-devfor backend / frontend / cfsolver, so local images built offmainpost-1.0.0 no longer wear the pre-v0.1 label.
.gitignorenow excludes.claude/(per-machine Claude Code settings). Stopssettings.local.jsonfrom leaking into commits.
The tracker_credentials table existed in the schema since v0.1
but had no REST handler and no frontend UI β it was unreachable.
This phase wires the entire surface end-to-end so users can finally
add LostFilm / RuTracker / Kinozal accounts.
backend/internal/db/repo/tracker_credentials.go(new) β Create / GetByID / GetForTracker / ListForUser / Update / Delete methods. Mirrors the existing Clients repo pattern with a unique(user_id, tracker_name)constraint enforced at the DB layer.backend/internal/api/handlers/credentials.go(new) β REST handler exposing five endpoints under/api/v1/credentials:GET /(list, no secrets),POST /(validates by calling the plugin'sLoginbefore saving),PUT /{id}(rotate username and optionally password),DELETE /{id},POST /{id}/test(decrypts the secret and re-runsLogin+Verify).Credentialshandler struct wired intorouter.goDeps and constructed incmd/server/main.gowith the existing master key- audit logger. Every create / update / delete is audit-logged.
- Scheduler now passes credentials into
Check/Download. The scheduler atbackend/internal/scheduler/scheduler.gogains acreds *repo.TrackerCredentialsfield. Before each topic check, if the tracker implementsWithCredentialsand the user has a stored credential, the scheduler decrypts the secret, callsLogin, and passes the in-memory credential into bothCheckandDownload. Login failures are recorded asauth_errorin the metric and as a backoff-retry on the topic. frontend/src/pages/Credentials.tsx(new) β/accountspage. Lists existing accounts grouped by tracker (display name, username, test/edit/delete buttons). Add form filters out trackers that already have a credential to honour the unique constraint. Validates by attempting Login on submit; if Login fails the credential is not stored.- New nav entry "Accounts" in
AppShell.tsxand corresponding i18n keys inen.ts/ru.ts. - Plugin contract clarified:
WithCredentials.Loginreceives a*TrackerCredentialwhoseSecretEncfield carries the plaintext password in-memory after decryption. The persisted blob is the AES-256-GCM ciphertext.
WithEpisodeFiltercapability interface inbackend/internal/plugins/registry/registry.go. Tracker plugins that implementSupportsEpisodeFilter() boolpromise to honourtopic.Extra["start_season"]/topic.Extra["start_episode"]in theirCheck/Downloadmethods.GET /api/v1/trackers/match?url=<encoded>β new endpoint inbackend/internal/api/handlers/trackers.go. Looks up the tracker plugin that claims a URL viaregistry.FindTrackerForURL, then type-asserts every optional capability and returns the snapshot:tracker_name,display_name,qualities,default_quality,supports_episode_filter,requires_credentials,uses_cloudflare. 404 if no plugin matches. Used by the AddTopic form.POST /api/v1/topicsnow accepts three optional fields:quality,start_season,start_episode. They are validated against the plugin'sWithQuality.Qualities()list (where applicable) and overlaid onto the Extra map the plugin'sParse()returned, then persisted in the existingtopics.extraJSONB column. No DB schema change.- AddTopic form is now capability-driven
(
frontend/src/pages/Topics.tsx). After the user pastes a URL the form debounces 350 ms then calls/trackers/match. If the response includesqualities, a quality<select>appears (defaulting todefault_quality). Ifsupports_episode_filteris true, two number inputs ("Start season", "Start episode") are rendered. Ifrequires_credentialsis true, a yellow notice invites the user to add a tracker account. The detected tracker display name is shown inline as a green confirmation.
GET /api/v1/clients/{id}inbackend/internal/api/handlers/clients.goβ returns the client row with the decrypted config blob, scoped to the calling user. Audit-logged on every read. Used by the frontend Edit Client form so the user can see (and rotate) what they previously saved.PUT /api/v1/clients/{id}β overwrites the mutable fields (display_name,is_default,config) on an existing client. Callsplugin.Test()before persistence, so a bad config never overwrites a good one. Plugin name (client_name) cannot be swapped via PUT β delete and re-add to switch from Transmission to qBittorrent. Audit-logged.Clients.Update(ctx, id, userID, displayName, isDefault, configEnc, configNonce)inbackend/internal/db/repo/clients.go.- Frontend Edit button on every client card in
frontend/src/pages/Clients.tsx. Opens a newEditClientCardcomponent that fetches the decrypted config via the new GET, hydrates every field (URL, username, password), and PUTs the result on save. - Inline help text under every URL field β
Fieldtype gains an optionalhelpText. Transmission's URL field now reads "Use the full RPC URL ending in /transmission/rpc. Default Transmission Web UI port is 9091; some packages (e.g. transmission-daemon) use 8083 or 9091. Example: http://192.168.2.65:8083/transmission/rpc". Same treatment for qBittorrent, Deluge, Β΅Torrent, and the download-folder plugin. docs/clients.mdβ new per-client setup guide. One section per supported client showing the exact URL format, default port, required fields, and the most common gotchas. The Add Client form now links to this doc inline.api.put<T>(path, body)added tofrontend/src/lib/api.tsβ the wrapper previously only hadget / post / patch / del.
frontend/src/pages/Settings.tsxreplaces the v0.4 placeholder with a real Settings page. Three sections, single column:- Appearance β segmented controls for theme (light/dark),
language (English/Π ΡΡΡΠΊΠΈΠΉ), and table density
(comfortable/compact). All three are persisted in
marauder-prefslocalStorage via the existingusePrefsZustand store. Server-side persistence is deferred. - Account β username + email read-only, plus a three-field change-password form (current / new / confirm) wired to the new backend endpoint. Sign-out button revokes the refresh token and clears the auth store.
- About β version (
v0.4.0-alpha), license, links to marauder.cc, GitHub, CHANGELOG, ROADMAP.
- Appearance β segmented controls for theme (light/dark),
language (English/Π ΡΡΡΠΊΠΈΠΉ), and table density
(comfortable/compact). All three are persisted in
POST /api/v1/auth/me/passwordinbackend/internal/api/handlers/auth.goβ change-password handler for local accounts. Verifies the current password with Argon2id, enforces an 8-char minimum on the new password, hashes with Argon2id, persists via the newUsers.UpdatePasswordHash(ctx, id, hash)repo method, audit-logs every attempt (success and failure). OIDC-only accounts are rejected with 400 because they have no local password to change.- The route registration in
frontend/src/App.tsxnow points/settingsat<SettingsPage>instead of the generic placeholder. - New i18n keys under
settings.*in bothen.tsandru.ts.
- Brand palette switched from violet/cyan to blue/amber/slate. Only
CSS tokens were touched; every component reads
hsl(var(--primary))so no JSX changes were needed.frontend/src/index.css:--primary265β217 (Tailwind blue),--accent192β38 (Tailwind amber),--ringmirrors primary, body radial gradients + glass-card shadow rebalanced.site/src/styles/global.css: same tokens swapped to keep the marketing site brand-consistent with the app.
- Dark/light mode toggle now actually works. Previously
frontend/index.htmlhardcodedclass="dark"on<html>and the header showed a static Moon icon labelled "dark" with no handler.- Added
theme: "light" | "dark"+setThemeto the existingusePrefsZustand store atfrontend/src/lib/prefs.ts. The setter toggles.darkondocument.documentElementandonRehydrateStoragere-applies the persisted theme on store rehydrate. - Removed the hardcoded
class="dark"fromfrontend/index.htmland added an inline boot script that readslocalStorage["marauder-prefs"]synchronously and applies the.darkclass before React mounts β no FOUC flash. AppShell.tsxheader now renders a real Sun/Moon toggle button next to the locale switcher.
- Added
- Language switcher dropdown rewritten at
frontend/src/components/layout/LocaleSwitcher.tsx. The bare native<select>(whose<option>styling is browser-controlled and ignores Tailwind) is replaced with a small custom popover: trigger button + click-outside handler + Escape-to-close + glass-card panel- Check icon on the active locale. ~85 LOC, no new dependency.
- Sitewide alpha disclaimer banner on marauder.cc. Inserted a
warning-tinted banner immediately after
<Header />insite/src/layouts/Page.astroso every page shows it. New.alpha-bannerrule insite/src/styles/global.css. Banner text: "Alpha release. Marauder is in early alpha. Most plugins are structurally complete but have not been validated against live services yet β expect rough edges. See plugin status β" - Version label dropped from
1.0.0to0.4.0-alphainsite/src/data/seo.ts(the home hero pill picks this up automatically). Hero pill recoloured from green-pulse to warning-pulse to match the alpha framing. README badges updated fromviolet.svgtoblue.svg. PRD Β§9.1 design language paragraph rewritten to describe the new palette.
- Replaced emoji icons with inline lucide SVG icons via a new
site/src/components/Icon.astrocomponent. Six feature-card icons on the home page (radio-tower, globe, send, shield-check, activity, blocks), the install warning callout (triangle-alert), and inline arrow-right / github icons all render as zero-JS inline<svg>β noastro-iconor@iconify-json/lucidedependency added. - Dialled back the violet color usage across the site. The
brand violet remains on the primary CTA buttons and the Marauder
logo gradient, but is no longer used for section header labels,
hover borders, link underlines, step number circles, or
background radial glows. Section labels now use
text-muted-foreground, hover borders useforeground/30, and link underlines useforeground/40. The body background is a single subtle violet ellipse instead of two stacked violet/cyan glows. - Removed all
monitorrentmentions from the marketing site and internal documentation except for one credits line in the README. Deletedsite/src/pages/vs/monitorrent.astroanddocs/migrating-from-monitorrent.md. Reworded copy indocs/VISION.md,docs/COMPETITORS.md,docs/PRD.md,docs/ROADMAP.md, andCONTRIBUTING.mdto describe the forum-tracker monitoring niche on its own terms. Cleaned up the same comments inbackend/internal/plugins/trackers/lostfilm/lostfilm.goandbackend/internal/plugins/registry/registry.go. The single remaining mention is inREADME.mdunder "License & credits".
- New
site/directory containing the Astro 5 + Tailwind 4 + Shiki marketing site forhttps://marauder.cc. Designed for 100% Lighthouse SEO with zero React/JS hydration:- 8 routes: home (
/),/install,/features,/trackers,/integrations,/docs,/vs/sonarr,/legal, plus a friendly 404 - Per-page unique title, meta description, canonical URL
- Open Graph + Twitter Card on every page (8 OG tags + 4
Twitter Card tags) generated centrally by
BaseHead.astro - JSON-LD structured data on every page via
JsonLd.astrowith XSS-safe</script>escape:- sitewide:
Organization+WebSite - home:
SoftwareApplication(with version/license/category) +FAQPage(8 Q&A pairs) /install:HowTowith 5 numbered steps (triggers Google's "How to" rich result)- inner pages:
BreadcrumbList
- sitewide:
- Sitemap auto-generated at
/sitemap-index.xmlvia@astrojs/sitemap, excluding the 404 page robots.txtallowing all crawlers and pointing to the sitemapCNAMEfile withmarauder.ccfor GitHub Pages custom domain- Favicon SVG + Apple touch icon SVG matching the app's violet/cyan brand
- OG image at
/og/default.svg(1200Γ630) with brand text - One long-form comparison page for SEO long-tail:
/vs/sonarr(Sonarr-Radarr-Prowlarr feature matrix + explanation of why the *arr stack can't see forum trackers) - Performance budget: 0 JS frameworks shipped (Astro outputs pure HTML by default), only 2.25 KB of Astro's prefetch helper. Total HTML max 40 KB per page, single CSS bundle 35 KB
- Visual identity matching the app: dark-first slate base,
deep-violet primary, electric-cyan accent, glass cards, Inter
- JetBrains Mono fonts, generous spacing
- 8 routes: home (
.github/workflows/site.ymlβ Pages deploy workflow:- Triggers on push to main when
site/**or the workflow itself changes, plusworkflow_dispatch - Runs
npm ci && npm run buildinsite/with the Node 22 cache - Asserts
dist/index.html,dist/sitemap-index.xml,dist/robots.txt,dist/CNAME, the<title>tag, and the JSON-LD block are all present before deploying - Uploads the
dist/directory as a Pages artifact and deploys viaactions/deploy-pages@v4 concurrency: pagesensures only one deploy in flight at a time- Validated with
actionlint(clean)
- Triggers on push to main when
docs/site-deploy.mdβ full guide for the one-time setup (Pages source toggle + DNS records at the registrar, with the exact 4 A records and CNAME GitHub Pages requires) plus the ongoing edit workflow, troubleshooting matrix, and Lighthouse validation steps.
- Five GitHub Actions workflows under
.github/workflows/:ci.ymlβ fast-feedback PR pipeline (under 3 min budget):go vet, race-detector tests,golangci-lint,govulncheck, cfsolver build/vet, frontendtsc --noEmitandnpm run build, bundle-size summary. Cancels in-flight runs on the same ref.docker.ymlβ builds backend, frontend, and cfsolver images on every push to main and on every tag. Trivy scan with HIGH/ CRITICAL fail-on, SARIF uploaded to the GitHub Code Scanning view. Does NOT push images.e2e.ymlβ heavyweight nightly + on-tag end-to-end test that brings up the full compose stack (db + backend + frontend + gateway + qBittorrent), then runs the magnet β qBittorrent walkthrough fromdocs/test-e2e-magnet.mdend-to-end. Includes backend log capture on failure and a clean teardown step.release.ymlβ tag-pushed release pipeline. Multi-arch (amd64 + arm64) build via QEMU + buildx, push toghcr.io/ artyomsv/marauder-{backend,frontend,cfsolver}with semver tags, cosign keyless signing via OIDC, CycloneDX SBOM per image, GitHub Release with the auto-extracted CHANGELOG section. Pre-release detection from-rc/-alpha/-betatag suffixes.codeql.ymlβ GitHub CodeQL SAST for Go and TypeScript with thesecurity-extendedquery pack. Runs on PR + push + weekly.
.github/dependabot.ymlβ automated dependency updates across Go modules (backend + cfsolver), npm (frontend), GitHub Actions, and Docker base images. Weekly Monday cadence, minor/patch updates grouped per ecosystem to reduce PR noise. React 19 / Vite 8 / Tailwind 4 majors are pinned per the v1.0 tech-stack lock.- PR + Issue templates:
.github/PULL_REQUEST_TEMPLATE.mdβ checklist mirroring CONTRIBUTING.md.github/ISSUE_TEMPLATE/bug.ymlβ structured bug report.github/ISSUE_TEMPLATE/feature.ymlβ structured feature request.github/ISSUE_TEMPLATE/tracker_breakage.ymlβ special-case template for forum-tracker plugin breakage with HTML excerpt upload, scrubbing checkboxes, and a tracker dropdown
backend/.golangci.ymlβ golangci-lint v2 config covering 12 linters (errcheck, govet, ineffassign, staticcheck, unused, bodyclose, rowserrcheck, sqlclosecheck, errorlint, gosec, misspell, unconvert) plus gofmt + goimports formatters. Includes principled exclusions for test files, init-based plugin registration,defer .Body.Close()anddefer tx.Rollback()patterns, and SHA-1 used as a content hash (G401/G505) which is the same hash BitTorrent uses internally.docs/ci.mdβ full CI/CD documentation: per-workflow description, when each runs, what to do when it fails, how to cut a release, how to validate locally with the same Docker commands the workflows use.
internal/crypto/crypto_test.go: replace tautologicalHashToken("x") != HashToken("x")comparison with two assigned variables so staticcheck SA4000 stops (correctly) flagging it.internal/plugins/trackers/kinozal/kinozal_test.go: replaceif HasPrefix { TrimPrefix }with the unconditionalTrimPrefix(S1017).internal/plugins/clients/transmission/transmission_test.go: remove the unusedmu sync.Mutexfield onfakeServer.internal/crypto/crypto.go: bound-checklen(want)before the uint32 conversion inVerifyPassword, with a#nosec G115annotation explaining the bound is enforced.internal/plugins/clients/downloadfolder/downloadfolder.go: file permissions tightened from0o640to0o600per gosec G306, with a comment explaining the trade-off for shared-group setups.internal/plugins/e2etest/qbitfake.go: bound the test server's form-parsing body size withhttp.MaxBytesReaderto satisfy gosec G120 even on a fake server.gofmt -wapplied across the backend.
golangci-lint run --timeout=5m: 0 issues.go build ./...andgo vet ./...: clean.go test ./...: 29 packages, 0 failures.actionlintover all 5 workflow files: clean.
- Torznab and Newznab indexer plugins β opens Marauder up to
several hundred indexers without writing scrapers. Sonarr, Radarr,
Prowlarr, Jackett, and NZBHydra2 collectively cover 500+ indexers
via these two protocols, and Marauder now speaks both.
torznabβ for any Torznab indexer (Jackett, Prowlarr, NZBHydra2 in torrent mode, or a direct Torznab feed). Uses the explicittorznab+https://...URL prefix so CanParse never collides with forum-tracker plugins. The hash is the newest item'sinfohash(orguidfallback). New releases at the top of the feed trigger a Marauder "update" the same way a forum- thread re-upload does. Enclosure magnet URIs route directly to the user's torrent client.newznabβ for any Usenet indexer (NZBGeek, NZBPlanet, DOGnzb, NZBHydra2). Usesnewznab+https://...prefix. Marauder downloads the.nzband hands the bytes to adownloadfolderclient pointed at a SABnzbd / NZBGet watch directory β the Usenet handoff is unchanged from the *arr stack workflow.- Shared
torznabcommonparser package handles the common RSS+attr XML shape (both protocols share it). 4 parser unit tests cover the Torznab feed, the Newznab feed, empty input, and malformed XML.
- Per-plugin tests for both new plugins:
torznab: 7 tests (CanParse, Parse, Check happy path with infohash, Check fallback to GUID when no infohash, Check on empty feed, Check on HTTP 500, safeFilename helper) plus an E2E test that runs the full pipeline against a fake indexer and submits to a fake qBittorrent.newznab: 4 tests (CanParse, Parse, Parse rejects bad scheme) plus an E2E test that runs the full pipeline through a fake NZB indexer that serves both the RSS feed and the .nzb bytes.
- Bundled tracker count: 16 (was 14).
docs/torznab-newznab.mdβ full integration guide explaining the model fit, the URL prefix scheme, step-by-step Prowlarr and NZBGeek walkthroughs, category numbers, and the validation procedure.
- Two new tracker plugins completing the original monitorrent
catalog:
freetorrentsβ phpBB-derived Free-Torrents.org. Login form,viewtopic.phpscrape, magnet + dl.php fallback. Alpha (needs live-account validation).hdclubβ HD-Club.org TBDev/Gazelle-style private tracker.details.phpscrape,download.phptorrent fetch. Alpha.- Bundled tracker count: 14 (was 12 in v1.0.0).
internal/plugins/e2etestpackage β shared E2E test harness:QBitFakeβ httptest-backed stand-in for the qBittorrent WebUI v2 API that captures every torrent submission for assertionsRunFullPipeline(t, Case)β generic runner that drives a tracker plugin through CanParse β Parse β Login β Verify β Check β Download β submit-to-fake-qbit β assertionsHostRewriteTransportβhttp.RoundTripperthat rewrites a production hostname to a local httptest.Server host. Lets the plugin's regex URL patterns and CanParse keep matching against canonical hostnames while HTTP traffic transparently routes to the test server. Production code is unmodified between unit tests and E2E.
- End-to-end tests for all 14 trackers (one
<name>_e2e_test.goper package, in-package so it can construct the plugin with private fields). Every test exercises the complete pipeline including the fake-qBit submission step:genericmagnet,generictorrentfilerutracker,kinozal,nnmclublostfilm,anilibria,anidubrutor,toloka,unionpeer,tapochekfreetorrents,hdclub
lostfilmDownload is now wired to extract a magnet URI from the series page if one is present, instead of returning a stub error. The redirector flow for paid users is still pending live validation, but the magnet path is real and exercised in E2E.
freetorrentsandhdclubare wired intocmd/server/main.govia blank imports.
go build ./...andgo vet ./...clean.go test ./...: 26 test packages, all green, including 14 fresh tracker E2E tests.
1.0.0 β 2026-04-07
The initial production release. The full feature set landed across the v0.1 β v0.4 development branches and is collected here.
- Backend: Go 1.23,
chiHTTP router,pgxv5 connection pool,goose-managed embedded migrations,zerologstructured JSON logging, RFC 7807 problem-details error responses, security-headers middleware, request-id middleware, recovery middleware that turns panics into 500s with trace IDs. - Frontend: React 19.2 + Vite 8 + Tailwind CSS 4.2 + shadcn/ui 4.1.2, TanStack Query for server state, zustand for local UI state, framer-motion for entry animations, lucide-react for icons. Dark-first design language with deep-violet primary, electric-cyan accent, glass cards, and radial gradients.
- Database: PostgreSQL 18 (currently 18.3 alpine; rolls forward automatically when 18.4 publishes).
- Deployment: Docker + docker-compose, four-service production stack
(postgres + backend + frontend + nginx gateway),
cfsolverprofile for the optional Cloudflare-bypass sidecar,ssoprofile for the optional Keycloak realm,devoverlay for end-to-end testing with real qBittorrent and Transmission containers.
- Local accounts: Argon2id password hashing
(
time=3, memory=64 MiB, parallelism=4), ES256-signed JWT access tokens, opaque refresh tokens stored as SHA-256 hashes server-side, refresh-token rotation with reuse detection that revokes the entire token family on misuse. - OIDC: auth-code flow via
coreos/go-oidc/v3. Provisions new users on first sign-in. Pre-builtdocker-compose.sso.ymloverlay brings up Keycloak 26.0 with amarauderrealm and analice/maraudertest user. Documented indocs/oidc.md. - Master key: AES-256-GCM at-rest encryption for tracker
credentials, client configs, notifier configs, and JWT signing
keys, all keyed by
MARAUDER_MASTER_KEY(32-byte base64). - Audit log: async logger (256-buffered channel + background
drainer) that records login success/failure/logout to a
Postgres-backed audit_log table. Admin-only
GET /api/v1/system/audit- frontend page exposes recent entries.
A plugin is one Go file plus its tests. init() self-registers with
the global registry package on process start. Three kinds of plugin:
| Kind | Interface | Optional capabilities |
|---|---|---|
| Tracker | Tracker |
WithCredentials, WithQuality, WithCloudflare |
| Client | Client |
β |
| Notifier | Notifier |
β |
See docs/plugin-development.md for the
full guide.
Total bundled in v1.0: 11 trackers, 5 clients, 4 notifiers.
| Plugin | Site | Status |
|---|---|---|
genericmagnet |
any magnet URI | β E2E validated |
generictorrentfile |
any HTTP(S) .torrent URL |
β unit-tested |
rutracker |
RuTracker.org | π‘ alpha (fixture-tested, needs live validation) |
kinozal |
Kinozal.tv | π‘ alpha |
nnmclub |
NNM-Club.to (with WithCloudflare) |
π‘ alpha |
lostfilm |
LostFilm.tv (with WithQuality) |
π‘ alpha |
anilibria |
Anilibria.tv (uses public v3 API) | π‘ alpha |
anidub |
tr.anidub.com (with WithQuality) |
π‘ alpha |
rutor |
Rutor.org | π‘ alpha |
toloka |
Toloka.to | π‘ alpha |
unionpeer |
Unionpeer.org | π‘ alpha |
tapochek |
Tapochek.net | π‘ alpha |
Alpha means the plugin is structurally complete with fixture-based unit tests and follows the same patterns as the validated plugins, but has not been validated against a live site by the maintainer because doing so requires a real account on each site. The next release moves any plugin that a community member validates to "stable".
| Plugin | Status |
|---|---|
downloadfolder |
β unit-tested |
qbittorrent (WebUI v2) |
β E2E validated against real qBittorrent docker container |
transmission (RPC) |
β unit-tested with mocked-server |
deluge (Web JSON-RPC) |
β unit-tested with mocked-server |
utorrent (token-based WebUI) |
π‘ unit-tested with mocked-server, no live Β΅Torrent docker image to validate against |
| Plugin | Status |
|---|---|
telegram (Bot API) |
β unit-tested via custom RoundTripper |
email (SMTP, PLAIN auth) |
β unit-tested with injected sender |
webhook (POST JSON) |
β unit-tested with httptest |
pushover (form POST) |
β unit-tested with httptest |
A separate cfsolver/ Go service uses chromedp + Debian-slim
chromium to drive a target URL through any Cloudflare interstitial
and return the resulting cookies + user-agent. Runs as its own Docker
image and is gated behind the cfsolver compose profile so it doesn't
start unless the user opts in. Tracker plugins that opt into the
WithCloudflare capability automatically route through it via the
internal/cfsolver client package.
- Single dispatch goroutine on a configurable tick (default 60s)
- Bounded worker pool (default 8) draining a buffered job channel
- Per-topic check pipeline: load β call tracker
Checkβ compare hash β if changed, callDownloadβ decrypt client config with master key β call clientAdd - Exponential backoff on errors, capped at 6 hours
- Falls back to the user's default client if a topic has no explicit
client_id - In-memory ring buffer of the last 50 run summaries, exposed via
GET /api/v1/system/statusfor the live System page - Records detailed Prometheus metrics for every check, update, and client submit
/healthβ always 200 if the process is up/readyβ 200 only when the database is reachable/metricsβ Prometheus exposition, gated by a static bearer token (MARAUDER_METRICS_TOKEN). Includes:marauder_http_requests_total{method,route,status}marauder_http_request_duration_seconds{method,route}marauder_scheduler_runs_total{result}marauder_scheduler_topic_checks_total{tracker,result}marauder_scheduler_topic_check_duration_seconds{tracker}marauder_tracker_updates_total{tracker}marauder_client_submit_total{client,result}- default
go_*andprocess_*collectors
- System status page in the frontend showing the scheduler state, last-run summary, run history, and a Go runtime snapshot, all auto-refreshing every 5 seconds
- Login β animated card with local form + "Sign in with Keycloak" button (if OIDC is configured)
- Dashboard β four live status tiles + recent activity feed
- Topics β full CRUD with checkboxes, bulk pause/resume/delete, comfortable/compact density toggle, inline add card with auto-detect preview
- Clients β full CRUD with per-plugin field hints, Test-connection button per row, default-client toggle
- Notifiers β full CRUD with per-plugin field hints, Send-test button per row
- System (any user) β live scheduler + runtime status, run history
- Audit log (admin only) β append-only event table with action, actor, target, IP, user-agent, result
- OIDC callback β picks up tokens from the URL fragment and lands the user on the dashboard
Tiny zustand-backed module with English and Russian dictionaries plus
a useT() hook. Locale is persisted in localStorage and switchable
from a header dropdown.
- 18 unit-test packages covering crypto, auth, plugin registry, every bundled tracker (where fixtures are available), every bundled client, and every bundled notifier
- End-to-end magnet β qBittorrent walkthrough documented and
validated in
docs/test-e2e-magnet.md go build ./... && go vet ./...cleannpm run buildproduces ~470 KB / ~146 KB gzipped frontend bundle
- Multi-stage Dockerfiles for backend and frontend, both running as non-root users with healthchecks
deploy/docker-compose.ymlβ production stackdeploy/docker-compose.dev.ymlβ overlay that exposes ports and starts real qBittorrent + Transmission containersdeploy/docker-compose.sso.ymlβ overlay that adds Keycloak with a pre-imported realm- All host ports are non-standard to avoid colliding with other services on the developer machine: gateway 6688, backend 8679, frontend dev 8680, Vite HMR 5174, Postgres dev 55432, Keycloak 8643
README.mdβ top-level project overviewdocs/VISION.mdβ what we're building and whydocs/COMPETITORS.mdβ how Marauder relates to Sonarr/Radarr/Prowlarr/ Jackett/FlexGet/monitorrentdocs/PRD.mdβ full product requirements documentdocs/ROADMAP.mdβ phased plan with v1.0 statusdocs/plugin-development.mdβ guide to writing tracker / client / notifier pluginsdocs/oidc.mdβ Keycloak OIDC walkthroughdocs/test-e2e-magnet.mdβ reproducible end-to-end smoke testdocs/migrating-from-monitorrent.mdβ migration guideCONTRIBUTING.mdβ local dev, test running, PR checklistCHANGELOG.mdβ this file