Backend API for TorrentExplorer.
It stores .torrent files (either locally or in any S3-compatible bucket) together with MediaInfo metadata, and exposes them through a small REST API.
bun install
cp config.example.json config.json
# edit config.json
bun run startBy default, the server listens on http://0.0.0.0:3000.
All configuration lives in config.json.
At startup, environment variables can override values from the config file:
HOSTPORTPROXYTOKENXMRFRONTEND_URLDATABASE_URLRELEASE_GROUPSTORAGE_DRIVERSCRAPER_ENABLEDSCRAPER_INTERVAL_MINUTESSCRAPER_UDP_TIMEOUT_MSREQUESTS_ENABLEDREQUESTS_RATE_LIMIT_WINDOW_MINUTESCOMMENTS_ENABLEDCOMMENTS_MAX_LENGTHCOMMENTS_RATE_LIMIT_BURSTCOMMENTS_RATE_LIMIT_REFILL_MINUTESNTFY_ENABLEDNTFY_SERVERNTFY_TOPICNTFY_TOKENNTFY_USERNAMENTFY_PASSWORDENCODERS_ENABLEDENCODERS_POLL_SECONDSENCODER{n}_NAME-{n}can be any number between 1 and 50ENCODER{n}_URL-{n}can be any number between 1 and 50ENCODER{n}_PASSWORD-{n}can be any number between 1 and 50
Example:
Host interface to bind to.
Default:
"0.0.0.0"Port to listen on.
Default:
3000Bearer token required for authenticated API endpoints such as uploads.
Clients must send it as:
Authorization: Bearer <your_token>Controls how the server extracts the real client IP address.
This is important for IP-based rate limiting. If the server is behind a reverse proxy or CDN, you must configure this correctly so rate limiting is applied to the actual client IP instead of the proxy IP.
Supported presets:
directcloudflareawsgcpazurevercelnginxdevelopment
Use:
directwhen the server is exposed directly to the internet and not behind a proxycloudflarewhen traffic passes through Cloudflareawswhen deployed behind AWS proxy or load balancer infrastructuregcpwhen deployed behind Google Cloud infrastructureazurewhen deployed behind Azure infrastructurevercelwhen deployed on or behind Vercelnginxwhen using Nginx as a reverse proxydevelopmentfor local development setups where forwarded headers may be inconsistent
Example:
{
"server": {
"proxy": "cloudflare",
},
}If this value is set incorrectly, rate limiting may group all requests under the proxy IP instead of the real client IP.
donation.xmr Optional Monero donation address exposed by the API for frontend display.
{
"donation": {
"xmr": "8BmrgB8NGWhe8TSjNJDNMKgHrvxEQP1ZUDTWMNWA8CnKMpQjBjZhje1DPMmkbdNyMZESZDvHgMyufe5KPtLgy41Q8MTWnBE"
}
}frontend.url Public URL of the user-facing frontend (the TorrentExplorer web UI).
When set, this URL is used in the Torznab/RSS feed for <guid>, <link>, <comments>, and the channel <link>, so RSS readers and Prowlarr's "Open page" links land on the public site rather than on the backend API. The .torrent <enclosure> URL still points at the backend, since the frontend doesn't serve the binary itself.
Leave empty to fall back to the backend URL (useful for local development).
Example:
{
"frontend": {
"url": "https://torrents.example.com",
},
}If you change this value after items have been indexed by Prowlarr/Sonarr/Radarr, those tools may treat existing releases as new, since they deduplicate by <guid>.
The scraper periodically contacts the trackers announced in each stored .torrent and updates seeder, leecher, and completed counts, plus the last_scraped_at timestamp returned by GET /api/{category}/:id.
Whether the background scraper runs.
Set to false if you do not want the server to make outbound tracker requests, for example in an offline or mirror-only deployment.
Default:
trueHow often, in minutes, to scrape tracker stats for every release.
Lower values give fresher numbers at the cost of more outbound traffic and more load on the trackers. Most public trackers dislike very aggressive scraping, so keep this conservative.
Default:
30Per-request timeout, in milliseconds, for UDP tracker scrapes.
If a UDP tracker does not answer within this window, it is considered unreachable for the current cycle and the scraper moves on to the next one.
Default:
5000Example:
{
"scraper": {
"enabled": true,
"intervalMinutes": 30,
"udpTimeoutMs": 5000,
},
}Visitors can ask for a title to be added by submitting its TheTVDB ID to POST /api/requests with a JSON body like { "kind": "series", "id": 359274 }. kind is one of anime, series, or movies (anime and series both use a TheTVDB Series ID; movies use a Movies ID).
Each unique (kind, id) is stored once, with a counter, the time of the first request, and the time of the most recent request. Submitting the same ID again just increments the counter and refreshes the timestamp.
The endpoint is heavily rate limited per client IP (by default, one request per hour). This limit is held in memory only and is not persisted, so it resets whenever the server restarts. Because the limit is IP-based, server.proxy must be set correctly (see above) or all requests may be grouped under the proxy IP.
Whether the request endpoint is available.
Set to false to disable it entirely. When disabled, the route is not registered and the frontend hides the request page.
Default:
trueHow long, in minutes, each client IP must wait between requests.
For example, 60 allows one request per hour per IP; 1440 would allow one per day.
Default:
60Example:
{
"requests": {
"enabled": true,
"rateLimitWindowMinutes": 60,
},
}Visitors can comment under any release. There is no registration and no login. Anyone can post, and comments with no Authorization header are attributed to Anonymous.
Threads are limited to a single level of nesting: you can reply to a top-level comment, but you cannot reply to a reply. Top-level comments and the replies beneath each one are both shown oldest-first. Deleting a top-level comment also deletes its replies.
Posting is heavily rate limited per client IP using a token bucket. Each IP starts with a small burst of tokens (comments.rateLimit.burst) and regains one token every comments.rateLimit.refillIntervalMinutes. Normal back-and-forth in a thread is unaffected, but anyone posting rapidly drains their bucket and is then throttled to one comment per refill interval while tokens slowly return. This limit is held in memory only and is not persisted, so it resets when the server restarts. Because it is IP-based, server.proxy must be set correctly (see above) or all comments may be grouped under the proxy IP.
If a request includes a valid Authorization: Bearer <token> header (the same token used for uploads), the comment is attributed to the configured brand.releaseGroup instead of Anonymous. Owner comments skip the rate limit and the character limit.
Token handling is strict:
- No
Authorizationheader -> posted as Anonymous. - Valid token -> posted as the release group (owner).
- Present but wrong/malformed token -> rejected with
401; nothing is posted.
The web UI never shows a login or token field. If a bearer token is present in the browser's localStorage under the key token, the UI posts and deletes as the owner, shows a "responding as owner" indicator, and renders a delete control under each comment. Otherwise it behaves as an anonymous visitor.
Whether the comment endpoints are available.
Set to false to disable them entirely. When disabled, the routes are not registered and the frontend hides the comments section.
Default:
trueMaximum number of characters allowed in an anonymous comment.
This limit does not apply to owner comments (authenticated with a valid bearer token); a separate, very large hard cap protects the database regardless.
Default:
1000Token-bucket capacity per client IP - the number of comments that can be posted in quick succession before throttling kicks in.
Default:
3How long, in minutes, it takes to regain one token. At steady state this is the minimum time between comments from a single IP.
For example, 5 allows roughly one comment every five minutes (after the initial burst is spent).
Default:
5Example:
{
"comments": {
"enabled": true,
"maxLength": 1000,
"rateLimit": {
"burst": 3,
"refillIntervalMinutes": 5,
},
},
}The server can publish push notifications to an ntfy topic so you find out immediately when something happens, without polling the API.
A notification is sent when:
- a visitor requests a title (
POST /api/requests) - the notification includes the kind (movie/series/anime), the TheTVDB ID, and how many times that title has been requested in total. Tapping it opens the title on thetvdb.com. - a visitor posts a comment or reply on any release - the notification includes the release title, the author (Anonymous or the release group), and the start of the comment. When
frontend.urlis set, tapping it opens the release's detail page. Notifications are fire-and-forget: a slow or unreachable ntfy server never delays or fails the API request that triggered the notification. Failed publishes are logged as warnings.
You can use the public https://ntfy.sh service (pick a hard-to-guess topic name - topics are essentially passwords) or any self-hosted ntfy instance.
Whether notifications are sent at all. When false (the default), the feature is completely inert and no outbound requests are made.
Default:
falseBase URL of the ntfy server to publish to.
Default:
"https://ntfy.sh"The topic to publish to. Subscribe to the same topic in the ntfy app (Android/iOS) or web app to receive the notifications.
On the public ntfy.sh server anyone who knows the topic name can subscribe to it, so treat the topic name like a secret.
Default:
"topicName"Access token for servers that require authentication, sent as Authorization: Bearer <token>. This is the recommended auth method and takes precedence over username/password when both are set.
{
"ntfy": {
"token": "tk_your_token_here",
},
}Username/password authentication, sent as HTTP Basic auth. Only used when ntfy.token is not set. Both values must be provided.
{
"ntfy": {
"username": "your_username",
"password": "your_password",
},
}Full example:
{
"ntfy": {
"enabled": true,
"server": "https://ntfy.sh",
"topic": "topicName",
// Optional: token authentication
//"token": "tk_your_token_here",
// Optional: username/password authentication
//"username": "your_username",
//"password": "your_password",
},
}database.url uses Bun's built-in SQL driver, so switching databases only requires changing the connection URL:
| Database | URL |
|---|---|
| SQLite | sqlite://data/torrents.db |
| PostgreSQL | postgres://user:pass@host:5432/db |
| MySQL | mysql://user:pass@host:3306/db |
The schema is migrated automatically on startup.
Supported values:
locals3
Torrent files are stored on disk using their original filenames.
Example:
{
"storage": {
"driver": "local",
"local": {
"path": "./torrents",
},
},
}Any S3-compatible provider can be used, including:
- AWS S3
- Cloudflare R2
- Backblaze B2
- MinIO
Example:
{
"storage": {
"driver": "s3",
"s3": {
"endpoint": "https://s3.example.com",
"region": "auto",
"bucket": "torrents",
"accessKeyId": "...",
"secretAccessKey": "...",
},
},
}Upload endpoints require bearer token authentication.
Send the configured token in the Authorization header:
Authorization: Bearer <your_token>Read-only endpoints do not require authentication unless you add your own external access control.
Returns the server name, release group, and a plain list of all available endpoints. Useful as a quick sanity check that the server is up and reachable.
Example response:
{
"name": "torrent-explorer-server",
"releaseGroup": "RabbitCompany",
"endpoints": ["GET /api/info", "GET /api/health", "..."]
}Lightweight health check for monitoring and container orchestration.
Example response:
{ "status": "ok" }Returns basic server branding and release counts by category.
Example response:
{
"releaseGroup": "RabbitCompany",
"stats": { "anime": 42, "movies": 7, "series": 3 },
"donation": {},
"requests": {
"enabled": true,
"rateLimitWindowMinutes": 60
},
"comments": {
"enabled": true,
"maxLength": 1000
}
}Lists releases for a category, grouped by title and year. Each group contains every season of the same title, sorted numerically (S2 before S10). Groups are ordered by the most recent upload within the group.
This endpoint returns summary rows only and does not include the full MediaInfo text.
Example response:
{
"groups": [
{
"title": "Tsugumomo",
"year": 2017,
"latest_uploaded_at": 1713571200000,
"tags": ["Bluray-1080p", "Opus 2.0", "AV1"],
"releases": [
{
"id": 1,
"category": "anime",
"title": "Tsugumomo",
"year": 2017,
"season": "S01",
"torrent_name": "[RabbitCompany] Tsugumomo (2017) - S01 [Bluray-1080p][Opus 2.0][AV1]",
"tags": ["Bluray-1080p", "Opus 2.0", "AV1"],
"uploaded_at": 1710571200000,
"seeders": 12,
"leechers": 1,
"completed": 87,
"last_scraped_at": 1713570000000
},
{
"id": 2,
"category": "anime",
"title": "Tsugumomo",
"year": 2017,
"season": "S02",
"torrent_name": "[RabbitCompany] Tsugumomo (2017) - S02 [Bluray-1080p][Opus 2.0][AV1]",
"tags": ["Bluray-1080p", "Opus 2.0", "AV1"],
"uploaded_at": 1713571200000,
"seeders": 25,
"leechers": 3,
"completed": 140,
"last_scraped_at": 1713570000000
}
]
}
],
"pagination": { "page": 1, "limit": 24, "total": 42, "pages": 2 }
}tags on the group is taken from the most recently uploaded release in that group.
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
number | no | Page number (default 1). Pagination counts groups, not individual releases. |
limit |
number | no | Groups per page (default 24, max 100) |
q |
string | no | Search query, matched against the title. A (YYYY) suffix in the query additionally filters by year. |
Responses are cached for 30 seconds.
Returns the full release record for a single item, including the raw MediaInfo text, the magnet link, tracker scrape stats, the file list inside the torrent, and the other seasons of the same title.
The frontend is expected to parse and render the MediaInfo content itself.
Example response:
{
"id": 2,
"category": "anime",
"title": "Tsugumomo",
"year": 2017,
"season": "S02",
"torrent_name": "[RabbitCompany] Tsugumomo (2017) - S02 [Bluray-1080p][Opus 2.0][AV1]",
"mediainfo": "General\nComplete name ...",
"tags": ["Bluray-1080p", "Opus 2.0", "AV1"],
"uploaded_at": 1713571200000,
"magnet": "magnet:?xt=urn:btih:...",
"seeders": 25,
"leechers": 3,
"completed": 140,
"last_scraped_at": 1713570000000,
"files": [{ "path": ["Tsugumomo S02", "Tsugumomo (2017) - S02E01.mkv"], "length": 412316860 }],
"group": [
{ "id": 1, "season": "S01" },
{ "id": 2, "season": "S02" }
]
}Field notes:
-
magnet,seeders,leechers,completed, andlast_scraped_atarenulluntil the background scraper has run for this release. -
filesis the file list parsed from the.torrent; it may be empty for releases whose torrent metadata could not be parsed. -
grouplists every release sharing the same title and year (all seasons), sorted numerically, including the current one. Use it to render season switchers. Responses: -
200 OKwith the release record -
400 Bad Requestif the id is not a number -
404 Not Foundif no release with that id exists in the category
Creates a new release by uploading a torrent file and its corresponding MediaInfo, optionally with per-episode MediaInfo files and screenshots.
Duplicate handling: if a release with the same category, title, year, and season already exists, the upload overwrites it instead of creating a new entry. The existing release keeps its id (so existing links stay valid), uploaded_at is refreshed, and scrape stats are reset. The previous torrent file, any per-episode media that were not re-uploaded, and all comments on the old release are deleted - comments usually report issues the re-upload fixes, so they would be misleading on the new version.
This endpoint requires bearer token authentication.
Include the token in the Authorization header:
Authorization: Bearer <your_token>Send the request as multipart/form-data.
| Field | Type | Required | Notes |
|---|---|---|---|
torrent |
file | yes | A .torrent file (max 10 MB). The original filename is preserved. |
mediainfo |
text, file, or files | yes | Either a single legacy MediaInfo text/file, or one file per episode named <episode filename without extension>.txt (max 1 MB each). |
screenshots |
files | no | Per-episode screenshots named <episode filename without extension>_<n>.<png|jpg|jpeg|webp|avif> where n is 1–6. Max 10 MB each, max 6 per episode. |
Per-episode mediainfo files and screenshots are validated against the file list inside the torrent. A file whose name does not match any episode in the torrent is rejected. Full filesystem paths in "Complete name" MediaInfo fields are redacted automatically, leaving only the filename.
The uploaded torrent filename must follow one of these formats:
-
Anime / Series
[ReleaseGroup] Title (Year) - S## [Tag1][Tag2]... -
Movies
[ReleaseGroup] Title (Year) [Tag1][Tag2]...The API parses the following metadata from the filename: -
release group
-
title
-
year
-
season (for anime/series)
-
tags Example (batch upload with per-episode media):
curl -X POST http://localhost:3000/api/anime \
-H "Authorization: Bearer <your_token>" \
-F "torrent=@[RabbitCompany] Tsugumomo (2017) - S02 [Bluray-1080p][Opus 2.0][AV1].torrent" \
-F "mediainfo=@Tsugumomo (2017) - S02E01.txt" \
-F "mediainfo=@Tsugumomo (2017) - S02E02.txt" \
-F "screenshots=@Tsugumomo (2017) - S02E01_1.png" \
-F "screenshots=@Tsugumomo (2017) - S02E01_2.png"Example response:
{
"id": 2,
"category": "anime",
"title": "Tsugumomo",
"year": 2017,
"season": "S02",
"torrent_name": "[RabbitCompany] Tsugumomo (2017) - S02 [Bluray-1080p][Opus 2.0][AV1]",
"tags": ["Bluray-1080p", "Opus 2.0", "AV1"],
"uploaded_at": 1713571200000,
"replaced": false,
"media": {
"mediainfo_episodes": ["Tsugumomo (2017) - S02E01", "Tsugumomo (2017) - S02E02"],
"screenshots": 2
}
}Responses:
201 Createdwhen a new release was created (replaced: false)200 OKwhen an existing release with the same title, year, and season was overwritten (replaced: true)400 Bad Requestfor unparseable filenames, missing fields, or media files that don't match the torrent contents401 Unauthorizedfor a missing or invalid token413 Payload Too Largewhen the torrent, a MediaInfo file, or a screenshot exceeds its size limit
This endpoint requires bearer token authentication.
Include the token in the Authorization header:
Authorization: Bearer <your_token>Send the request as multipart/form-data.
| Field | Type | Required | Notes |
|---|---|---|---|
torrent |
file | yes | A .torrent file. The original filename is preserved. |
mediainfo |
file or text | yes | MediaInfo text for the release. For batch uploads, use episode 1. |
The uploaded torrent filename must follow one of these formats:
-
Anime / Series
[ReleaseGroup] Title (Year) - S## [Tag1][Tag2]... -
Movies
[ReleaseGroup] Title (Year) [Tag1][Tag2]...
The API parses the following metadata from the filename:
- release group
- title
- year
- season (for anime/series)
- tags
Example:
curl -X POST http://localhost:3000/api/anime \
-H "Authorization: Bearer <your_token>" \
-F "torrent=@[RabbitCompany] Tsugumomo (2017) - S02 [Bluray-1080p][Opus 2.0][AV1].torrent" \
-F "[email protected]"Response:
201 Createdon success, with the newly created release in the response body
Streams the original .torrent file back to the client using its original filename.
This is intended for direct browser download or opening in a torrent client.
Returns a manifest of the per-episode media (MediaInfo and screenshots) available for a release. The manifest is derived from a storage listing; releases uploaded before per-episode media existed simply return an empty list.
Episodes are listed in torrent order. name is the original episode filename without its extension, and is the value to pass as ep to the mediainfo endpoint below. Entries in screenshots are the stored filenames to pass as file to the screenshot endpoint.
Example response:
{
"episodes": [
{
"name": "Tsugumomo (2017) - S02E01",
"mediainfo": true,
"screenshots": ["Tsugumomo (2017) - S02E01_1.png", "Tsugumomo (2017) - S02E01_2.png"]
},
{
"name": "Tsugumomo (2017) - S02E02",
"mediainfo": true,
"screenshots": []
}
]
}Responses are cached for 30 seconds.
Returns the MediaInfo for a single episode as text/plain.
| Parameter | Type | Required | Description |
|---|---|---|---|
ep |
string | yes | The episode name exactly as returned by the media manifest above. |
Responses:
200 OKwith the MediaInfo text (served with long-lived immutable caching)400 Bad Requestifepis missing or longer than 512 characters404 Not Foundif the release exists but has no MediaInfo for that episode
Streams a single screenshot image.
| Parameter | Type | Required | Description |
|---|---|---|---|
file |
string | yes | A filename exactly as returned in the manifest's screenshots array, e.g. My Show - S01E03_2.png. |
Responses:
200 OKwith the image (image/png,image/jpeg,image/webp, orimage/avif, served with long-lived immutable caching)400 Bad Requestiffileis missing or doesn't match the expected<episode>_<n>.<ext>pattern404 Not Foundif the screenshot doesn't exist
Lets visitors request a title by submitting its TheTVDB ID. No authentication is required, but the endpoint is heavily rate limited per client IP (default: one request per hour — see requests.rateLimitWindowMinutes). When requests.enabled is false in the config, this route is not registered at all.
Send the request as application/json.
| Field | Type | Required | Notes |
|---|---|---|---|
kind |
string | yes | One of anime, series, or movies (movie is accepted as an alias for movies). |
id |
number or string | yes | A positive TheTVDB ID, digits only. Anime and series use a Series ID; movies use a Movies ID. |
Submitting the same (kind, id) pair again increments its counter and refreshes the timestamp instead of creating a new record.
Example:
curl -X POST http://localhost:3000/api/requests \
-H "Content-Type: application/json" \
-d '{ "kind": "series", "id": 359274 }'Example response:
{
"id": 359274,
"kind": "series",
"counter": 3,
"created": 1713571200000,
"last_updated": 1713999999000
}Responses:
201 Createdwith the request record (counteris the total number of times this title has been requested)400 Bad Requestfor an invalidkindorid429 Too Many Requestsif the per-IP rate limit is exceeded, with aRetry-Afterheader
Lists every stored request, ordered by request count (most-requested first). Optional ?kind=anime|series|movies filter. No authentication required.
Example response:
{
"requests": [{ "id": 359274, "kind": "anime", "counter": 5, "created": 1781721033792, "last_updated": 1781721033792 }]
}Deletes a single request record. Owner only - requires a valid bearer token; any other request (including none) is rejected with 401. Use it to clear requests for already-released titles or invalid IDs.
Response: 200 OK with { "deleted": true }, or 404 Not Found if no such request exists.
Returns the comment thread for a release. Top-level comments are sorted oldest-first, each with its replies (also oldest-first) nested one level deep.
author is the resolved display name: "Anonymous" for anonymous comments, or the release group name for owner comments. author_type is "anonymous" or "owner".
Example response:
{
"comments": [
{
"id": 12,
"parent_id": null,
"author": "Anonymous",
"author_type": "anonymous",
"body": "Audio is completely out of sync.",
"created_at": 1713571200000,
"replies": [
{
"id": 15,
"parent_id": 12,
"author": "RabbitCompany",
"author_type": "owner",
"body": "I checked and this isn't the case. Can you try mpv?",
"created_at": 1713571320000
},
{
"id": 18,
"parent_id": 12,
"author": "Anonymous",
"author_type": "anonymous",
"body": "It works with mpv, thanks!",
"created_at": 1713571500000
}
]
}
]
}Posts a comment under a release.
Optional. Omit the header to post as Anonymous, or send a valid token to post as the release group:
Authorization: Bearer <your_token>A present-but-invalid token is rejected with 401 and nothing is stored.
Send the request as application/json.
| Field | Type | Required | Notes |
|---|---|---|---|
body |
string | yes | The comment text. Limited to comments.maxLength chars for anonymous posts. |
parent_id |
number | no | ID of the top-level comment being replied to. Omit for a top-level comment. |
parent_id must reference an existing top-level comment on the same release; replying to a reply is rejected.
Example (anonymous top-level comment):
curl -X POST http://localhost:3000/api/anime/1/comments \
-H "Content-Type: application/json" \
-d '{ "body": "Audio is completely out of sync." }'Example (owner reply):
curl -X POST http://localhost:3000/api/anime/1/comments \
-H "Authorization: Bearer <your_token>" \
-H "Content-Type: application/json" \
-d '{ "body": "Try mpv media player.", "parent_id": 12 }'Response:
201 Createdwith the created comment in the body.401 Unauthorizedif a token is supplied but invalid.429 Too Many Requestsif the per-IP rate limit is exceeded, with aRetry-Afterheader.
Deletes a comment. Owner only - requires a valid bearer token; any other request (including no token) is rejected with 401. Deleting a top-level comment also deletes its replies.
curl -X DELETE http://localhost:3000/api/anime/1/comments/12 \
-H "Authorization: Bearer <your_token>"Response:
200 OKwith{ "deleted": true }on success.404 Not Foundif the comment doesn't exist on that release.
Returns the public encoding queue, aggregated from the configured Rabbit Encoder instances. Jobs are grouped per title and season - no per-episode information is exposed. Returns "enabled": false with an empty encoders array when encoder polling is disabled in the config.
If an encoder cannot be reached, its last known data is kept and it is flagged "online": false, so the public queue page degrades gracefully instead of going blank.
Example response:
{
"enabled": true,
"pollIntervalSeconds": 30,
"encoders": [
{
"name": "encoder-01",
"online": true,
"paused": false,
"lastUpdated": 1713571200000,
"totals": { "total": 24, "done": 10, "encoding": 1, "queued": 13, "error": 0 },
"etaMs": 7200000,
"groups": [
{
"title": "Tsugumomo",
"season": "Season 2",
"queuePosition": 1,
"total": 12,
"done": 10,
"encoding": 1,
"queued": 1,
"error": 0,
"progress": 87,
"etaMs": 1800000,
"active": true,
"completed": false,
"finishedAt": null
}
]
}
]
}Responses are cached for 5 seconds.
The server exposes a Torznab-compatible feed at /api/torznab and a plain RSS alias at /api/rss. Both emit the same XML format and can be consumed by:
- Prowlarr as a Generic Torznab indexer (point it at
/api/torznab) - Sonarr / Radarr indirectly, via Prowlarr
- Any standard RSS reader (use
/api/rssfor simplicity)
Returns the indexer's capabilities (search modes, supported categories) as XML. Prowlarr calls this when adding the indexer to discover what searches are available.
Generic search across all categories. This is also the default when t is omitted.
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
q |
string | no | Free-text search query (matches against the torrent title) |
cat |
string | no | Comma-separated newznab category IDs (e.g. 2000,5070). Unknown IDs are silently ignored. |
offset |
number | no | Number of items to skip (default 0) |
limit |
number | no | Max items to return (default 50, max 100) |
Supported cat values:
| ID | Name | Internal category |
|---|---|---|
| 2000 | Movies | movies |
| 5000 | TV | series and anime |
| 5070 | TV/Anime | anime |
Same as search but constrained to TV-style content (series + anime). If cat is provided, it is intersected with the allowed set.
Same as search but constrained to movies.
Convenience alias for GET /api/torznab?t=search. Useful for plain RSS readers that don't need Torznab capabilities.
Each <item> includes:
<title>- original torrent name<guid>,<link>,<comments>- public detail page URL (usesfrontend.urlwhen configured, otherwise the backend URL)<pubDate>- RFC-822 upload time<enclosure>- direct.torrentdownload URL served from the backend. A second<enclosure>with the magnet URI is added when available.<torznab:attr>for:category,size,files,year,poster,team,seeders,leechers,peers,grabs,infohash,magneturl,downloadvolumefactor(always0- releases are freeleech),uploadvolumefactor(always1), andtag(freeleech,internal)- For movies:
imdbtitle,imdbyear - For series/anime:
tvtitle,season, andepisodewhen the torrent name contains anS##E##marker
- All categories:
GET /api/rss - Anime only:
GET /api/torznab?t=search&cat=5070 - Search by title:
GET /api/torznab?t=search&q=tsugumomo - TV search with pagination:
GET /api/torznab?t=tvsearch&offset=50&limit=50
In Prowlarr, add a new Generic Torznab indexer with:
- URL:
https://your-backend.example.com/api/torznab - API Key: leave blank (no auth required)
- Categories: tick Movies (2000), TV (5000), TV/Anime (5070)
Prowlarr hits ?t=caps to validate the indexer when you click Test, then begins polling ?t=search on its normal schedule.
Build a single-file executable:
bun run buildRun it:
./torrent-explorer-serverA multi-stage Dockerfile is included.
Build the image:
docker build -t torrent-explorer-server .Run the container:
docker run -d \
--name torrent-explorer-server \
-p 3000:3000 \
-e PROXY=direct \
-e TOKEN=replace-with-a-long-random-token \
-e RELEASE_GROUP=RabbitCompany \
-v $(pwd)/torrents:/app/torrents \
-v $(pwd)/data:/app/data \
torrent-explorer-serverIf the container is behind a reverse proxy or CDN, set PROXY to the matching preset such as cloudflare or nginx so client IPs are extracted correctly for rate limiting.
A docker-compose.yml example is also included.
Start the service:
docker compose up -dExample Compose configuration:
services:
torrent-explorer:
image: rabbitcompany/torrent-explorer:latest
container_name: torrent-explorer
restart: unless-stopped
ports:
- "3000:3000"
environment:
- TZ=UTC
- PROXY=direct
- TOKEN=hux23to2isshfuyttzlyy6dfn2m9vtfdpew6iyjUbRqxKtXhgx
- RELEASE_GROUP=RabbitCompany
- XMR=8BmrgB8NGWhe8TSjNJDNMKgHrvxEQP1ZUDTWMNWA8CnKMpQjBjZhje1DPMmkbdNyMZESZDvHgMyufe5KPtLgy41Q8MTWnBE
- FRONTEND_URL=https://torrents.example.com
- NTFY_ENABLED=true
- NTFY_SERVER=https://ntfy.sh
- NTFY_TOPIC=torrent-explorer
volumes:
#- ./config.json:/app/config.json
- torrent_explorer_torrents:/app/torrents
- torrent_explorer_media:/app/media
- torrent_explorer_data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
torrent_explorer_torrents:
driver: local
torrent_explorer_data:
driver: local
torrent_explorer_media:
driver: localIf you uncomment the config.json bind mount, values from that file are still overridden by supported environment variables.
When deploying behind Cloudflare, Nginx, or another proxy layer, change PROXY from direct to the correct preset. Otherwise all traffic may appear to come from the proxy, which breaks per-IP rate limiting.
{ "server": { "host": "0.0.0.0", "port": 3000, "proxy": "direct", "token": "hux23to2isshfuyttzlyy6dfn2m9vtfdpew6iyjUbRqxKtXhgx", }, "frontend": { "url": "https://torrents.example.com", }, "brand": { "releaseGroup": "RabbitCompany", }, "donation": { "xmr": "8BmrgB8NGWhe8TSjNJDNMKgHrvxEQP1ZUDTWMNWA8CnKMpQjBjZhje1DPMmkbdNyMZESZDvHgMyufe5KPtLgy41Q8MTWnBE", }, "scraper": { "enabled": true, "intervalMinutes": 30, "udpTimeoutMs": 5000, }, "requests": { "enabled": true, "rateLimitWindowMinutes": 60, }, "comments": { "enabled": true, "maxLength": 1000, "rateLimit": { "burst": 3, "refillIntervalMinutes": 5, }, }, "database": { "url": "sqlite://data/torrents.db", }, "storage": { "driver": "local", "local": { "path": "./torrents", }, "s3": { "endpoint": "https://s3.example.com", "region": "auto", "bucket": "torrents", "accessKeyId": "...", "secretAccessKey": "...", }, }, "ntfy": { "enabled": false, "server": "https://ntfy.sh", "topic": "topicName", }, "encoders": { "enabled": false, "pollIntervalSeconds": 10, "list": [ { "name": "Main", "url": "http://192.168.1.50:3000", "password": "rabbitencoder" }, { "name": "GPU Box", "url": "http://192.168.1.51:3000", "password": "secret2" }, ], }, }