SABnzbd API contract as consumed by Sonarr and Radarr (develop)
Source of truth: files fetched from raw.githubusercontent.com/{Sonarr/Sonarr,Radarr/Radarr}/develop/...
on 2026-08-22. Every line number below was read out of those files.
Kept here as the record of why internal/sabnzbd answers in the shapes it does. Nothing in that
package should be changed without the paragraph here that explains what the client does with the
field — a wrong struct tag does not fail, it leaves the client reading a zero, and a wrong enum
value throws inside its deserializer and takes the whole poll with it. For how to point Sonarr or
Radarr at zurg, see sabnzbd.md.
Unless a section says otherwise, the two repos' SAB files are byte-identical apart from a UTF-8 BOM
and the TvCategory → MovieCategory / RemoteEpisode → RemoteMovie rename, and line numbers match
one-for-one. Every Sabnzbd* citation therefore applies to both repos at the same line.
Paths are src/NzbDrone.Core/Download/Clients/Sabnzbd/... in both. Verified diffs are recorded in §6.
Nothing here is speculation about SAB itself: every claim is what the client does. Where I state Newtonsoft.Json or .NET runtime semantics (not Sonarr code) I say so explicitly.
0. Transport-level rules (these bite first)
0.1 URL shape
SabnzbdProxy.BuildRequest (SabnzbdProxy.cs:162-185):
private HttpRequestBuilder BuildRequest(string mode, SabnzbdSettings settings)
{
var baseUrl = GetBaseUrl(settings, "api"); // :164
var requestBuilder = new HttpRequestBuilder(baseUrl)
.Accept(HttpAccept.Json) // :167 -> Accept: application/json
.AddQueryParam("mode", mode); // :168
requestBuilder.LogResponseContent = true; // :170
if (settings.ApiKey.IsNotNullOrWhiteSpace())
requestBuilder.AddSuffixQueryParam("apikey", settings.ApiKey); // :174
else
{
requestBuilder.AddSuffixQueryParam("ma_username", settings.Username); // :178
requestBuilder.AddSuffixQueryParam("ma_password", settings.Password); // :179
}
requestBuilder.AddSuffixQueryParam("output", "json"); // :182
return requestBuilder;
}
-
Base URL:
HttpRequestBuilder.BuildBaseUrl(src/NzbDrone.Common/Http/HttpRequestBuilder.cs:56-66) ={http|https}://{Host}:{Port}{UrlBase};UrlBasegets a leading/if missing. The endpoint is that +/api(SabnzbdProxy.cs:38-44). -
Suffix params always land last, so
apikey(orma_username+ma_password) andoutput=jsonare the final query params (HttpRequestBuilder.cs:82—QueryParams.Concat(SuffixQueryParams)). -
Accept: application/jsonon every call.output=jsonon every call. There is nooutput=xmlpath anywhere. -
API key and username/password are mutually exclusive: an api key suppresses
ma_username/ma_password.
0.2 Always answer HTTP 200 — even for errors
HttpClient.Execute throws on any status >= 400 (src/NzbDrone.Common/Http/HttpClient.cs:106-121;
HttpResponse.cs:54 — HasHttpError => (int)StatusCode >= 400; 429 becomes TooManyRequestsException).
SabnzbdProxy.ProcessRequest (SabnzbdProxy.cs:199-215) converts that into
DownloadClientException("Unable to connect to SABnzbd, {0}").
HttpException's message is "HTTP request failed: [401:Unauthorized] [GET] at [url]"
(src/NzbDrone.Common/Http/HttpException.cs:18) — the body is not in the message. So a 401 with
{"error":"API Key Incorrect"} will not be recognised by TestAuthentication
(Sabnzbd.cs:424,429 match on ex.Message), and the connection test reports the generic
"Test was aborted due to an error" instead. Emulate SAB: HTTP 200 + {"status": false, "error": "..."}.
0.3 Deserializer settings (drives every field-name and type question)
All SAB payloads go through NzbDrone.Common.Serializer.Json
(src/NzbDrone.Common/Serializer/Newtonsoft.Json/Json.cs:21-37) — Newtonsoft, not System.Text.Json:
DateTimeZoneHandling = DateTimeZoneHandling.Utc,
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented,
DefaultValueHandling = DefaultValueHandling.Include,
ContractResolver = new CamelCasePropertyNamesContractResolver()
Converters: StringEnumConverter { NamingStrategy = CamelCaseNamingStrategy }, VersionConverter, HttpUriConverter
Consequences (Newtonsoft semantics, not Sonarr code):
Json.Deserialize<T> rethrows only JsonReaderException with a nicer message (Json.cs:39-50);
Json.TryDeserialize<T> swallows only JsonReaderException and JsonSerializationException
(Json.cs:99-117). Anything else (e.g. an ArgumentException from the timeleft converter, or an NRE
from the priority converter) escapes.
1. Every mode, every parameter, every field consumed
1.1 mode=version — SabnzbdProxy.GetVersion (SabnzbdProxy.cs:85-95)
Request: GET /api?mode=version&apikey=KEY&output=json
Response model SabnzbdVersionResponse (Responses/SabnzbdVersionResponse.cs:5):
Read via Json.TryDeserialize; a parse failure yields Version == null (SabnzbdProxy.cs:89-92).
Parsing (Sabnzbd.cs:334-366), regex at Sabnzbd.cs:38:
// patch can be a number (releases) or 'x' (git)
private static readonly Regex VersionRegex = new Regex(@"(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+|x)", RegexOptions.Compiled);
- Unanchored, so
4.5.0RC1and2.0.0beta1parse (fixtureSabnzbdFixture.cs:523,553). patchmay be the literalx(git builds), replaced with0(Sabnzbd.cs:351).- A two-component version (
"4.5") does not match →null→ validation failure. - The exact string
"develop"(case-insensitive) is special-cased to3.0.0(Sabnzbd.cs:355-362) and produces a warning, not a failure (Sabnzbd.cs:380-387).
Version is fetched again (a second mode=version round-trip) by HasVersion at
Sabnzbd.cs:294-332 — it is not cached.
1.2 mode=get_config — SabnzbdProxy.GetConfig (SabnzbdProxy.cs:97-104)
Request: GET /api?mode=get_config&apikey=KEY&output=json
Uses Json.Deserialize (not Try) — a malformed body throws out of GetConfig.
Top level must be {"config": {...}} (Responses/SabnzbdConfigResponse.cs:5). See §3 for the fields.
1.3 mode=fullstatus — SabnzbdProxy.GetFullStatus (SabnzbdProxy.cs:106-114)
Request: GET /api?mode=fullstatus&skip_dashboard=1&apikey=KEY&output=json
Top level {"status": {...}} (Responses/SabnzbdFullStatusResponse.cs:5). Only one field is read:
Called only when config.misc.complete_dir is not rooted and the SAB version is >= 2.0
(Sabnzbd.cs:220-226). With an absolute complete_dir this endpoint is never hit — implement it anyway.
Quirk worth knowing: because the real body is {"status": {...}} and SabnzbdJsonError.Status is a
string, CheckForError's TryDeserialize<SabnzbdJsonError> fails with a JsonSerializationException
and falls into the plain-text branch, which sets Status = "true" (SabnzbdProxy.cs:224-240). Benign,
but it means fullstatus can never report an error through the JSON path.
1.4 mode=queue (read) — SabnzbdProxy.GetQueue (SabnzbdProxy.cs:116-130)
Request: GET /api?mode=queue&start={start}&limit={limit}[&category={cat}]&apikey=KEY&output=json
-
categoryis added only if the configured category is non-blank (SabnzbdProxy.cs:122-125). -
Callers:
Sabnzbd.GetQueue()usesstart=0, limit=0(Sabnzbd.cs:57) — SAB readslimit=0as "all";Sabnzbd.GetCategories()usesstart=0, limit=1on the pre-2.0 path (Sabnzbd.cs:229);Sabnzbd.RemoveItem()calls it once more to decide queue-vs-history (Sabnzbd.cs:199). -
Extraction:
Json.Deserialize<SabnzbdQueue>(JObject.Parse(response).SelectToken("queue").ToString())(SabnzbdProxy.cs:129) — a missing top-levelqueuekey NREs.
SabnzbdQueue (SabnzbdQueue.cs):
SabnzbdQueueItem (SabnzbdQueueItem.cs):
1.4.1 timeleft format — SabnzbdQueueTimeConverter (JsonConverters/SabnzbdQueueTimeConverter.cs:15-28)
var split = reader.Value.ToString().Split(':').Select(int.Parse).ToArray();
switch (split.Length)
{
case 4: return new TimeSpan((split[0] * 24) + split[1], split[2], split[3]); // d:h:m:s
case 3: return new TimeSpan(split[0], split[1], split[2]); // h:m:s
default: throw new ArgumentException("Expected either 0:0:0:0 or 0:0:0 format, but received: " + reader.Value);
}
-
Exactly 3 or 4 colon-separated integers.
"0:08:32","40:12:14","1:16:12:14"all fine (fixtureSabnzbdQueueTimeConverterFixture.cs:14-20);"1","0:1","0:0:0:0:1"throw (:28-35). Leading zeros are fine (int.Parse). -
The thrown
ArgumentExceptionis not caught byJson.TryDeserializeand not byJson.Deserialize. -
An empty string
""splits to[""]→int.ParsethrowsFormatException. Never emit"". -
Omitting
timeleftentirely is safe: the converter is not invoked andTimeleftstaysTimeSpan.Zero.
1.5 mode=queue&name=delete — SabnzbdProxy.RemoveFromQueue (SabnzbdProxy.cs:64-72)
GET /api?mode=queue&name=delete&del_files={0|1}&value={nzo_id}&apikey=KEY&output=json
del_files = deleteData ? 1 : 0 (SabnzbdProxy.cs:68). No archive param on the queue path.
Response body is only run through CheckForError; nothing is deserialized.
1.6 mode=history (read) — SabnzbdProxy.GetHistory (SabnzbdProxy.cs:132-146)
Request: GET /api?mode=history&start={start}&limit={limit}[&category={cat}]&apikey=KEY&output=json
-
Caller:
Sabnzbd.GetHistory()withstart=0, limit=_configService.DownloadClientHistoryLimit(Sabnzbd.cs:110). Default 60 in both repos (Sonarrsrc/NzbDrone.Core/Configuration/ConfigService.cs:184, Radarr:236). -
Extraction:
SelectToken("history")(SabnzbdProxy.cs:145) — top level must be{"history": {...}}.
SabnzbdHistory (SabnzbdHistory.cs): paused (bool, unused), slots → Items.
SabnzbdHistoryItem (SabnzbdHistoryItem.cs):
No DateTime-typed member exists anywhere in the SAB models, so no date format is constrained.
completed / last_history_update (unix epoch in real SAB) are never read. The only time-shaped
values that matter are timeleft (§1.4.1) and download_time (an unread integer of seconds).
1.6.1 History status → DownloadItemStatus (Sabnzbd.cs:116-158)
Deleted -> skipped entirely (Sabnzbd.cs:116-119)
Failed + fail_message == "Unpacking failed, write error or disk is full?"
(InvariantCultureIgnoreCase) -> DownloadItemStatus.Warning (:140-144)
Failed (any other message) -> DownloadItemStatus.Failed (:147)
Completed -> DownloadItemStatus.Completed (:152)
anything else (Verifying/Moving/...) -> DownloadItemStatus.Downloading (:157)
Every history item gets RemainingSize = 0, RemainingTime = TimeSpan.Zero,
CanBeRemoved = true, CanMoveFiles = true (Sabnzbd.cs:129-135).
DownloadItemStatus itself (src/NzbDrone.Core/Download/DownloadItemStatus.cs:3-11):
Queued=0, Paused=1, Downloading=2, Completed=3, Failed=4, Warning=5.
1.6.2 storage → OutputPath (Sabnzbd.cs:160-176)
var outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(sabHistoryItem.Storage));
if (!outputPath.IsEmpty)
{
historyItem.OutputPath = outputPath;
var parent = outputPath.Directory;
while (!parent.IsEmpty)
{
if (parent.FileName == sabHistoryItem.Title)
{
historyItem.OutputPath = parent;
}
parent = parent.Directory;
}
}
The loop walks all the way to the root and keeps the highest ancestor whose last segment equals
name. Purpose: if storage points at a file inside the job folder, back out to the job folder
(fixture SabnzbdFixture.cs:376-391). If storage is already the job folder, no ancestor matches and
OutputPath == storage — which is what you want.
Recommendation for the emulator: set storage to the job folder path itself.
Empty storage ⇒ OutputPath stays empty ⇒ CompletedDownloadService.ValidatePath warns
"Download doesn't contain intermediate path, Skipping." (Sonarr CompletedDownloadService.cs:299-303).
1.7 mode=history&name=delete — SabnzbdProxy.RemoveFromHistory (SabnzbdProxy.cs:74-83)
GET /api?mode=history&name=delete&del_files={0|1}&value={nzo_id}&archive={0|1}&apikey=KEY&output=json
request.AddQueryParam("del_files", deleteData ? 1 : 0); // :78
request.AddQueryParam("value", id); // :79
request.AddQueryParam("archive", deletePermanently ? 0 : 1); // :80 <-- inverted
and the caller (Sabnzbd.cs:208):
_proxy.RemoveFromHistory(item.DownloadId, deleteData, item.Status == DownloadItemStatus.Failed, Settings);
So deletePermanently == (status is Failed), and therefore:
Yes, that reads backwards at first glance — it is what the code does.
1.8 mode=addfile — SabnzbdProxy.DownloadNzb (SabnzbdProxy.cs:46-62)
POST /api?mode=addfile&cat={category}&priority={int}&apikey=KEY&output=json
Content-Type: multipart/form-data; boundary=...
part: Content-Disposition: form-data; name="name"; filename="<CleanFileName(Release.Title)>.nzb"
Content-Type: application/x-nzb
-
The multipart field is literally
nameand the part carries afilename(SabnzbdProxy.cs:53,HttpRequestBuilder.cs:379-395, emitted at:180-195). -
Filename comes from
FileNameBuilder.CleanFileName(release.Title) + ".nzb"(Sonarrsrc/NzbDrone.Core/Download/UsenetClientBase.cs:43, Radarr:43). -
catignores the method'scategoryargument and reads settings directly (SabnzbdProxy.cs:50:request.AddQueryParam("cat", settings.TvCategory);— Radarr:settings.MovieCategory). Harmless, since the caller passes the same value. -
priorityis the rawintfromSabnzbdPriority(see §2), chosen by recency: SonarrSabnzbd.cs:43remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority; RadarrSabnzbd.cs:43remoteMovie.Movie.MovieMetadata.Value.IsRecentMovie ? RecentMoviePriority : OlderMoviePriority. -
Response
SabnzbdAddResponse(Responses/SabnzbdAddResponse.cs):
Read with TryDeserialize; on failure a synthetic {Status=true, Ids=[]} is used (SabnzbdProxy.cs:55-59).
Then Sabnzbd.cs:47-52:
if (response == null || response.Ids.Empty())
{
throw new DownloadClientRejectedReleaseException(remoteEpisode.Release, "SABnzbd rejected the NZB for an unknown reason");
}
return response.Ids.First();
nzo_ids must be a non-empty array of strings. "nzo_ids": null is equivalent to absent
(NullValueHandling.Ignore keeps the ctor's empty list) and fails the same way.
1.9 Queue status → DownloadItemStatus (Sabnzbd.cs:60-103)
Deleted -> skipped (Sabnzbd.cs:62-65)
(queue.paused && slot.priority != Force) || slot.status == Paused
-> Paused, and RemainingTime forced to null (:78-84)
Queued | Grabbing | Propagating -> Queued (:85-90)
everything else -> Downloading (:93)
Also Sabnzbd.cs:96-100: a title beginning with the literal "ENCRYPTED /" is stripped of those
11 characters and the item is flagged IsEncrypted = true.
Every queue item gets CanBeRemoved = true, CanMoveFiles = true (Sabnzbd.cs:75-76).
1.10 Client-side category filter (Sabnzbd.cs:186-195)
foreach (var downloadClientItem in GetQueue().Concat(GetHistory()))
{
if (downloadClientItem.Category == Settings.TvCategory ||
(downloadClientItem.Category == "*" && Settings.TvCategory.IsNullOrWhiteSpace()))
{
yield return downloadClientItem;
}
}
Ordinal string equality. An item in category * is only accepted when the configured category is blank.
1.11 Modes that exist in the proxy but are never called
-
mode=retry&value={id}(SabnzbdProxy.cs:148-160, response{status, nzo_id}→Responses/SabnzbdRetryResponse.cs).ISabnzbdProxy.RetryDownloadhas no caller in either repo. -
Responses/SabnzbdCategoryResponse.cs({"categories": [...]}, i.e.mode=get_cats) is entirely unreferenced in both repos. -
Sabnzbd.ValidatePath(Sabnzbd.cs:576-592) is private and never called (dead).
2. SabnzbdDownloadStatus and SabnzbdPriority — exact members, unknown/null handling
2.1 SabnzbdPriority.cs (complete file)
namespace NzbDrone.Core.Download.Clients.Sabnzbd
{
public enum SabnzbdPriority
{
Default = -100,
Paused = -2,
Low = -1,
Normal = 0,
High = 1,
Force = 2
}
}
JsonConverters/SabnzbdPriorityTypeConverter.cs:14-21:
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var queuePriority = reader.Value.ToString();
Enum.TryParse(queuePriority, out SabnzbdPriority output);
return output;
}
-
Enum.TryParse<T>(string, out T)is the case-sensitive overload (.NET semantics)."Normal"parses;"normal"does not — and on failureoutputisdefault(SabnzbdPriority) == 0 == Normal, so a bad case silently degrades to Normal."Force"matters (§1.9),"force"does not work. -
Numeric strings also parse:
"2"→Force,"-100"→Default. A JSON number2works too (reader.Value.ToString()=="2"). -
"priority": nullthrows aNullReferenceException(reader.Value.ToString()on null). This is not caught byJson.TryDeserialize. Omit the key or send a real value. -
Omitting
priorityis safe: the converter is not invoked, member staysNormal(0). -
The converter is registered only on
SabnzbdQueueItem.Priority(SabnzbdQueueItem.cs:21); history items have no priority. -
These same integers are what Sonarr/Radarr send back as
&priority=onmode=addfile(SabnzbdSettings.csdefaults bothRecent*PriorityandOlder*Priorityto(int)SabnzbdPriority.Default=-100).
2.2 SabnzbdDownloadStatus.cs (complete file), declaration order = implicit values 0..15
namespace NzbDrone.Core.Download.Clients.Sabnzbd
{
public enum SabnzbdDownloadStatus
{
Grabbing, // 0
Queued, // 1
Paused, // 2
Checking, // 3
Downloading, // 4
QuickCheck, // 5
Verifying, // 6
Repairing, // 7
Fetching, // 8 -- comment in file: "Fetching additional blocks"
Extracting, // 9
Moving, // 10
Running, // 11 -- comment in file: "Running PP Script"
Completed, // 12
Failed, // 13
Deleted, // 14
Propagating // 15
}
}
-
No per-property converter. The globally registered
StringEnumConverter { NamingStrategy = CamelCaseNamingStrategy }(Json.cs:32) handles it. -
An unknown name throws (Newtonsoft
EnumUtils.ParseEnum→JsonSerializationException), and becauseGetQueue/GetHistoryuseJson.Deserialize(not Try), the entire queue or history call fails. Never emit a status outside this list. -
Emit SAB's canonical spellings exactly as above (
"Downloading","QuickCheck","Completed","Failed","Propagating", ...). Newtonsoft's enum matching does fall back to case-insensitive, but exact case is the only thing worth relying on. -
Integers are accepted too (
AllowIntegerValuesdefault true), but don't use them. -
A JSON
nullforstatusis ignored byNullValueHandling.Ignoreand the member staysGrabbing(0) — a queue slot silently becomesQueued, a history slot silently becomesDownloading. Always send an explicit status.
3. get_config: exactly what is read, and the validation rules
3.1 The model — SabnzbdCategory.cs
This one file holds SabnzbdConfig, SabnzbdConfigMisc, SabnzbdCategory and SabnzbdSorter.
There is no SabnzbdConfig.cs in either repo (confirmed via the GitHub contents API listing of
src/NzbDrone.Core/Download/Clients/Sabnzbd).
public class SabnzbdConfig // SabnzbdCategory.cs:8
{
public SabnzbdConfig() // :10-15
{
Categories = new List<SabnzbdCategory>();
Servers = new List<object>();
Sorters = new List<SabnzbdSorter>();
}
public SabnzbdConfigMisc Misc { get; set; } // :17 <-- NOT initialised
public List<SabnzbdCategory> Categories { get; set; } // :18
public List<object> Servers { get; set; } // :19
public List<SabnzbdSorter> Sorters { get; set; } // :20
}
3.2 config.misc.* — the complete set of keys read (SabnzbdCategory.cs:23-37)
SabnzbdStringArrayConverter (JsonConverters/SabnzbdStringArrayConverter.cs:25-39) — applied only
to date_categories (SabnzbdCategory.cs:30):
if (reader.TokenType == JsonToken.String || reader.TokenType == JsonToken.Null)
{
return new string[] { JValue.Load(reader).ToObject<string>() };
}
else if (reader.TokenType == JsonToken.StartArray)
{
return JArray.Load(reader).ToObject<string[]>();
}
else
{
throw new JsonReaderException("Expected array");
}
A JSON string becomes a 1-element array; a null becomes [null] (a 1-element array containing
null); an array deserialises normally; anything else (a number, an object) throws. Doc comment at
:7-9: "On some properties sab serializes array of single item as plain string."
3.3 config.categories[] — SabnzbdCategory (SabnzbdCategory.cs:39-48)
FullPath (OsPath) is computed, not deserialized.
Path assembly — Sabnzbd.GetCategories (Sabnzbd.cs:216-244):
var completeDir = new OsPath(config.Misc.complete_dir);
if (!completeDir.IsRooted)
{
if (HasVersion(2, 0))
{
var status = _proxy.GetFullStatus(Settings);
completeDir = new OsPath(status.CompleteDir);
}
else
{
var queue = _proxy.GetQueue(0, 1, Settings);
var defaultRootFolder = new OsPath(queue.DefaultRootFolder);
completeDir = defaultRootFolder + completeDir;
}
}
foreach (var category in config.Categories)
{
var relativeDir = new OsPath(category.Dir.TrimEnd('*'));
category.FullPath = completeDir + relativeDir;
yield return category;
}
OsPath.operator+ (src/NzbDrone.Common/Disk/OsPath.cs:420-448) returns left when right.IsEmpty,
so dir: "" gives FullPath == complete_dir exactly. IsRooted for a Unix-kind path is
_path.StartsWith("/") (OsPath.cs:142-145); path kind is sniffed from the string
(OsPath.cs:43-60) — a leading / ⇒ Unix, a \ or drive letter ⇒ Windows. A Unix-kind path handed
to a Windows-hosted Sonarr fails CompletedDownloadService.ValidatePath (:305-310).
3.4 The two NRE landmines (and what is actually safe)
Rule: always emit misc as an object and every category with a string dir (use "").
3.5 ContainsCategory (Sabnzbd.cs:523-536)
private bool ContainsCategory(IEnumerable<string> categories, string category)
{
if (categories == null || categories.Empty())
{
return true; // <-- null/empty means "all categories"
}
if (category.IsNullOrWhiteSpace())
{
category = "Default";
}
return categories.Contains(category);
}
Confirmed by fixtures: enable_tv_sorting=true + tv_categories=null fails
(SabnzbdFixture.cs:636-645), and + tv_categories=[] also fails (:647-656).
TvCategory=null + tv_categories=["Default"] fails (:680-691).
3.6 Test() — the exact order and rules (Sabnzbd.cs:286-292)
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnectionAndVersion());
failures.AddIfNotNull(TestAuthentication());
failures.AddIfNotNull(TestGlobalConfig());
failures.AddIfNotNull(TestCategory());
}
Wrapped by DownloadClientBase.Test() (src/NzbDrone.Core/Download/DownloadClientBase.cs:148-163),
which catches any escaping exception and adds "Test was aborted due to an error: " + ex.Message.
TestConnectionAndVersion() (Sabnzbd.cs:368-414) — 1 × mode=version:
-
unparseable version → failure on field
Version(DownloadClientSabnzbdValidationUnknownVersion). -
rawVersion == "develop"(ignore case) → warning onVersion,IsValidstays true (fixtureSabnzbdFixture.cs:566-577). -
version.Major >= 1→ pass (:389-392). -
else
version.Minor >= 7→ pass (:394-397). -
else failure: required version
"0.7.0"(:399-404). Fixture table (SabnzbdFixture.cs:549-564):0.6.9→false,0.7.0/0.8.0/1.0.0/1.0.0RC1/1.1.x→true. -
Any exception → failure on field
Host("unable to connect"), detail =ex.Message(:406-413).
TestAuthentication() (Sabnzbd.cs:416-438) — 1 × mode=get_config. Catches, then matches
ex.Message with ContainsIgnoreCase:
Because CheckForError wraps the body as "Error response received from SABnzbd: {error}"
(SabnzbdProxy.cs:244), matching works when your error string contains API Key Incorrect /
API Key Required — SAB's own wording.
TestGlobalConfig() (Sabnzbd.cs:440-453) — 1 × mode=get_config, plus 1 × mode=version
only if pre_check is true:
var config = _proxy.GetConfig(Settings);
if (config.Misc.pre_check && !HasVersion(1, 1))
{
return new NzbDroneValidationFailure("", "…CheckBeforeDownload")
{
InfoLink = _proxy.GetBaseUrl(Settings, "config/switches/"),
…
};
}
Threshold: SAB >= 1.1.0 (HasVersion(major:1, minor:1, patch:0), Sabnzbd.cs:294-332).
pre_check: false avoids the whole branch, including the extra mode=version round-trip.
TestCategory() (Sabnzbd.cs:455-521) — 1 × mode=get_config (a third one), plus whatever
GetCategories needs. Checks run in this order, first hit wins:
-
matched category (
Name == Settings.TvCategory/MovieCategory) whosedirends with*→ warning onTvCategory/MovieCategory"enable job folders", InfoLinkconfig/categories/(:460-469). -
no matched category and the configured category is non-blank → warning
DownloadClientValidationCategoryMissing, InfoLinkconfig/categories/(:471-481). -
config.Sorters.Any(s => s.is_active && ContainsCategory(s.sort_cats, cat))→ warning, InfoLinkconfig/sorting/(:483-491). Code comment at:483: "New in SABnzbd 4.1, but on older versions this will be empty and not apply". -
misc.enable_tv_sorting && ContainsCategory(misc.tv_categories, cat)→ warning (:493-500). -
misc.enable_movie_sorting && ContainsCategory(misc.movie_categories, cat)→ warning (:502-509). -
misc.enable_date_sorting && ContainsCategory(misc.date_categories, cat)→ warning (:511-518).
SabnzbdSorter (SabnzbdCategory.cs:50-66) — read: sort_cats (List<string>, ctor-empty),
is_active (bool). Present in the model but never read: name, order, min_size,
multipart_label, sort_string, sort_type (List<int>).
Traffic note: all four Test* methods make their own calls, nothing is cached — a single
connection test issues roughly 2 × mode=version and 3 × mode=get_config (a 4th version call if
pre_check is true).
3.7 config.misc history retention → RemovesCompletedDownloads (Sabnzbd.cs:538-574)
var retention = config.Misc.history_retention;
var option = config.Misc.history_retention_option;
var number = config.Misc.history_retention_number;
switch (option)
{
case "all": return false;
case "number-archive": case "number-delete": return true;
case "days-archive": case "days-delete": return number < 14;
case "all-archive": case "all-delete": return true;
}
// TODO: Remove these checks once support for SABnzbd < 4.3 is removed
if (retention.IsNullOrWhiteSpace()) { return false; }
if (retention.EndsWith("d")) { int.TryParse(retention[..^1], out var daysRetention); return daysRetention < 14; }
return retention != "0";
true raises a health-check warning
(HealthCheck/Checks/DownloadClientRemovesCompletedDownloadsCheck.cs:44-53).
Fixture coverage SabnzbdFixture.cs:455-510: legacy "0"/""/null/"15d" → false;
"-1"/"15"/"3"/"3d" → true.
Cleanest emulator answer: history_retention_option: "all", history_retention_number: 0,
history_retention: "0".
3.8 SortingMode and its health check (Sabnzbd.cs:263-279)
GetStatus sets SortingMode to "TV", "Movie" or "Date" using the same
enable_*_sorting && ContainsCategory(...) predicates. Any non-blank value raises a warning in
HealthCheck/Checks/DownloadClientSortingCheck.cs:45-55. Keep all three enable_*_sorting false.
3.9 IsLocalhost (Sabnzbd.cs:260)
IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost"
Ordinal, case-sensitive; ::1, Localhost and any hostname do not count. This changes which
RemotePathMappingCheck message you get (see §6.2).
4. Error handling — CheckForError (SabnzbdProxy.cs:222-246)
private void CheckForError(HttpResponse response)
{
if (!Json.TryDeserialize<SabnzbdJsonError>(response.Content, out var result))
{
// Handle plain text responses from SAB
result = new SabnzbdJsonError();
if (response.Content.StartsWith("error", StringComparison.InvariantCultureIgnoreCase))
{
result.Status = "false";
result.Error = response.Content.Replace("error: ", "");
}
else
{
result.Status = "true";
}
result.Error = response.Content.Replace("error: ", ""); // :239 — assigned in BOTH branches
}
if (result.Failed)
{
throw new DownloadClientException("Error response received from SABnzbd: {0}", result.Error);
}
}
SabnzbdJsonError (SabnzbdJsonError.cs, complete):
public string Status { get; set; }
public string Error { get; set; }
public bool Failed => !string.IsNullOrWhiteSpace(Status) &&
Status.Equals("false", StringComparison.InvariantCultureIgnoreCase);
Rules, precisely:
-
CheckForErrorruns on every response, before any mode-specific deserialization (SabnzbdProxy.cs:217). -
Statusis typedstringbut SAB sends a JSON boolean. Newtonsoft coercesfalse→"False", whichFailedmatches case-insensitively. Bothfalse(boolean) and"false"(string) work. -
A response with no top-level
status(e.g.{"version": …},{"queue": {...}},{"history": {...}},{"config": {...}}) leavesStatusnull →Failedfalse → no error. -
status: true→"True"→ not failed. -
Non-JSON body:
TryDeserializereturns false (it catchesJsonReaderExceptionandJsonSerializationException,Json.cs:107-116) and the plain-text branch runs. A body starting witherror(case-insensitive) →Status = "false"→ throwsDownloadClientException("Error response received from SABnzbd: {body with 'error: ' stripped}"). Any other non-JSON body →Status = "true"→ treated as success, and the mode-specific deserialization then fails on its own terms. -
A JSON body whose
statusis an object (mode=fullstatus) also takes the plain-text branch, because aStartObjecttoken cannot fill astringproperty —Statusbecomes"true". -
Note the redundant
result.Error = …at:239:Erroris set even on the success branch. It has no effect (nothing readsErrorunlessFailed).
Exception mapping in ProcessRequest (SabnzbdProxy.cs:187-220):
The exact auth-failure strings matched (Sabnzbd.cs:424, 429, exact literals, matched with
ContainsIgnoreCase against the exception message):
"API Key Incorrect""API Key Required"
Nothing else is classified. Emit them verbatim as the error value of an HTTP 200 response.
Why a non-200 must never be returned: HttpClient.Execute throws before CheckForError ever runs
(HttpClient.cs:106-121), the resulting HttpException message contains only status/method/URL and
not the body (HttpException.cs:18), so the "API Key Incorrect" / "API Key Required" matching
cannot fire. The user sees "Unable to connect to SABnzbd" or "Test was aborted due to an error"
instead of an actionable message, and every health check treats the client as unreachable.
5. The post-import sequence
5.1 Who calls RemoveItem, and with what
DownloadEventHub (src/NzbDrone.Core/Download/DownloadEventHub.cs, identical in both repos except
the BOM):
-
Handle(DownloadCompletedEvent)(:48-69): callsMarkItemAsImported(SAB throwsNotSupportedException—DownloadClientBase.cs:189-192— logged at debug and ignored), then returns early if the item is alreadyRemoved,!CanBeRemoved, or stillDownloading, then returns early unlessdefinition.RemoveCompletedDownloads. -
Handle(DownloadFailedEvent)(:26-46): gated ondefinition.RemoveFailedDownloads. -
Handle(DownloadCanBeRemovedEvent)(:71-85): gated ondefinition.RemoveCompletedDownloads. -
All three funnel into
RemoveFromDownloadClient(:87-103):
_logger.Debug("[{0}] Removing download from {1} history", …);
downloadClient.RemoveItem(trackedDownload.DownloadItem, true); // :92 -- deleteData is ALWAYS true
trackedDownload.DownloadItem.Removed = true;
DownloadClientDefinition defaults (src/NzbDrone.Core/Download/DownloadClientDefinition.cs:17-18):
public bool RemoveCompletedDownloads { get; set; } = true;
public bool RemoveFailedDownloads { get; set; } = true;
The manual API path DELETE /api/v3/queue/{id}?removeFromClient=true also passes true
(Sonarr src/Sonarr.Api.V3/Queue/QueueController.cs:342). There is no caller anywhere that passes
deleteData: false.
5.2 Sabnzbd.RemoveItem (Sabnzbd.cs:197-214) — the exact sequence
public override void RemoveItem(DownloadClientItem item, bool deleteData)
{
var queueClientItem = GetQueue().SingleOrDefault(v => v.DownloadId == item.DownloadId); // extra mode=queue
if (queueClientItem == null)
{
if (deleteData && item.Status == DownloadItemStatus.Completed)
{
DeleteItemData(item); // rm -rf, see 5.3
}
_proxy.RemoveFromHistory(item.DownloadId, deleteData, item.Status == DownloadItemStatus.Failed, Settings);
}
else
{
_proxy.RemoveFromQueue(item.DownloadId, deleteData, Settings);
}
}
So the post-import wire sequence against the emulator is:
GET /api?mode=queue&start=0&limit=0&category=tv&apikey=…&output=json(to decide queue vs history)- local filesystem delete of the output folder (§5.3) — before any further HTTP call
GET /api?mode=history&name=delete&del_files=1&value={nzo_id}&archive=1&apikey=…&output=json
For a failed item: no local delete (status is Failed, not Completed), and archive=0 —
the inversion in SabnzbdProxy.cs:80 (archive = deletePermanently ? 0 : 1, where
deletePermanently == (status is Failed)).
5.3 DeleteItemData (src/NzbDrone.Core/Download/DownloadClientBase.cs:110-146)
protected virtual void DeleteItemData(DownloadClientItem item)
{
if (item == null) { return; }
if (item.OutputPath.IsEmpty)
{
_logger.Trace("[{0}] Doesn't have an outputPath, skipping delete data.", item.Title);
return;
}
try
{
if (_diskProvider.FolderExists(item.OutputPath.FullPath))
{
_logger.Debug("[{0}] Deleting folder '{1}'.", item.Title, item.OutputPath);
_diskProvider.DeleteFolder(item.OutputPath.FullPath, true); // :129 recursive == true
}
else if (_diskProvider.FileExists(item.OutputPath.FullPath))
{
_logger.Debug("[{0}] Deleting file '{1}'.", item.Title, item.OutputPath);
_diskProvider.DeleteFile(item.OutputPath.FullPath); // :135
}
else
{
_logger.Trace("[{0}] File or folder '{1}' doesn't exist, skipping cleanup.", item.Title, item.OutputPath);
}
}
catch (Exception ex)
{
_logger.Warn(ex, string.Format("[{0}] Error occurred while trying to delete data from '{1}'.", item.Title, item.OutputPath));
}
}
-
Yes, it recursively deletes the output folder.
DeleteFolder(path, recursive: true)at:129. -
OutputPathhere is the history item'sstorageafter remote-path remapping and the job-folder walk-up (§1.6.2). For a flat magic directory that meansrm -rf /mnt/zurg/__magic__/__all__/<Title>. -
The governing setting is the download client's Remove Completed Downloads toggle (
DownloadClientDefinition.RemoveCompletedDownloads, default true), not a SAB-side setting and notdel_files. There is no separate "delete data" switch in the UI. -
Failures are swallowed with a warning — a read-only mount logs noise but does not break the import.
-
Fixture coverage
SabnzbdFixture.cs:693-803: folder deleted recursively, file deleted, nothing when the path doesn't exist, and nothing at all (not even aFolderExistsprobe) whendeleteDatais false.
5.4 Transfer mode: is the file moved or copied?
Sabnzbd sets CanMoveFiles = true on every item, queue (Sabnzbd.cs:76) and history
(Sabnzbd.cs:135). The automatic-import path is
CompletedDownloadService.Import → ProcessPath(outputPath, ImportMode.Auto, …)
(Sonarr CompletedDownloadService.cs:148-151), and then:
Sonarr src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs:139-152
(Radarr src/NzbDrone.Core/MediaFiles/MovieImport/ImportApprovedMovie.cs:116-129, identical logic):
bool copyOnly;
switch (importMode)
{
default:
case ImportMode.Auto: copyOnly = downloadClientItem is { CanMoveFiles: false }; break;
case ImportMode.Move: copyOnly = false; break;
case ImportMode.Copy: copyOnly = true; break;
}
CanMoveFiles: true ⇒ copyOnly == false ⇒ TransferMode.Move
(src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs:91). A copyOnly import would instead use
TransferMode.HardLinkOrCopy when "Use Hardlinks instead of Copy" is on, else TransferMode.Copy
(:94-108).
TransferMode (src/NzbDrone.Common/Disk/TransferMode.cs:5-15) is [Flags]:
None=0, Move=1, Copy=2, HardLink=4, HardLinkOrCopy = Copy|HardLink.
5.5 Does a failed Move fall back to a copy? — No. A failed Move is a failed import.
src/NzbDrone.Common/Disk/DiskTransferService.cs (byte-identical between the two repos — verified).
TransferFile, :256-392; the TransferMode.Move branch at :360-389:
if (mode.HasFlag(TransferMode.Move))
{
if (isBtrfs || isZfs)
{
if (isSameMount && _diskProvider.TryRenameFile(sourcePath, targetPath)) { return TransferMode.Move; }
if (_diskProvider.TryCreateRefLink(sourcePath, targetPath)) { _diskProvider.DeleteFile(sourcePath); return TransferMode.Move; }
}
if (isCifs && !isSameMount) // :378
{
_logger.Trace("On cifs mount. Starting verified copy [{0}] to [{1}].", sourcePath, targetPath);
TryCopyFileVerified(sourcePath, targetPath, originalSize);
_logger.Trace("Copy successful, deleting source [{0}].", sourcePath);
_diskProvider.DeleteFile(sourcePath);
return TransferMode.Move;
}
TryMoveFileVerified(sourcePath, targetPath, originalSize); // :387
return TransferMode.Move;
}
-
The only copy fallback is the
isCifs && !isSameMountspecial case at:378-385, keyed ontargetMount.DriveFormat == "cifs"(:342). It is chosen up front from the drive format, never as a reaction to a failure. -
TryMoveFileVerified(:487-503) catches, callsRollbackPartialMove(unless the exception isFileAlreadyExistsException), and rethrows:
private void TryMoveFileVerified(string sourcePath, string targetPath, long originalSize)
{
try
{
_diskProvider.MoveFile(sourcePath, targetPath);
VerifyFile(sourcePath, targetPath, originalSize, "move");
}
catch (Exception ex)
{
if (ex is not FileAlreadyExistsException) { RollbackPartialMove(sourcePath, targetPath); }
throw;
}
}
-
VerifyFile(:505-524) compares destination size to source size, sleeps 3 s (WaitForIO,:467-471), re-checks, then throwsIOException("File move incomplete, data loss may have occurred. [{target}] was {n} bytes long instead of the expected {m}."). -
That
IOExceptionpropagates up throughEpisodeFileMovingService.TransferFile(:151) into the genericcatch (Exception e)inImportApprovedEpisodes.cs:218-222(Radarr:197-201), producingImportResult(importDecision, "Failed to import episode")/"Failed to import movie". No copy is attempted. -
Other pre-flight
IOExceptions in the same method: source == target (:270,:277), and "Destination cannot be a child of the source" (:312-315).
Caveat, and it is a .NET-runtime fact rather than Sonarr code: DiskProviderBase.MoveFileInternal
(src/NzbDrone.Common/Disk/DiskProviderBase.cs:293-301) is a plain File.Move, and .NET on Unix
falls back to copy+delete when rename() returns EXDEV. So a cross-filesystem move usually still
succeeds — as a runtime fallback, not a Sonarr one. What will not be tolerated is a destination
whose size differs from the source, or any exception File.Move itself surfaces (EACCES, EPERM,
EROFS on the source, …).
TransferFolder (:44-137) is used by other code paths; note :124-131 — after a folder Move it
refuses to delete the source if more than 100 MB of files remain
(IOException("Large files still exist in {sourcePath} after folder move, not deleting source folder")).
6. The two health checks
6.1 DownloadClientRootFolderCheck — the one place Sonarr and Radarr genuinely differ
Sonarr src/NzbDrone.Core/HealthCheck/Checks/DownloadClientRootFolderCheck.cs:51:
var folders = status.OutputRootFolders.Where(folder => rootFolders.Any(r => r.Path.PathEquals(folder.FullPath)));
Radarr src/NzbDrone.Core/HealthCheck/Checks/DownloadClientRootFolderCheck.cs:51:
var folders = rootFolders.Where(r => status.OutputRootFolders.Any(folder => r.Path.PathEquals(folder.FullPath) || r.Path.IsParentPath(folder.FullPath)));
IsParentPath (src/NzbDrone.Common/Extensions/PathExtensions.cs:123-140) walks up from the child:
public static bool IsParentPath(this string parentPath, string childPath)
{
var parent = new OsPath(parentPath);
var child = new OsPath(childPath);
while (child.Directory != OsPath.Null)
{
if (child.Directory.Equals(parent, true)) { return true; }
child = child.Directory;
}
return false;
}
So r.Path.IsParentPath(folder.FullPath) is true when the root folder is an ancestor of the
download output folder, i.e. the output folder lives inside a root folder. It is a strict
ancestor test (it starts from child.Directory); equality is handled separately by PathEquals.
Answering the predicate question directly:
With complete_dir = /mnt/zurg/__magic__/__all__ and dir: "" (so
OutputRootFolders == ["/mnt/zurg/__magic__/__all__"]):
- Sonarr warns only if a root folder is exactly
/mnt/zurg/__magic__/__all__. - Radarr warns if a root folder is
/mnt/zurg/__magic__/__all__,/mnt/zurg/__magic__,/mnt/zurg, or/mnt. - Neither warns if a root folder is
/mnt/zurg/__magic__/tv— the mirror and the root folders are siblings, so the pair is unrelated and neither rule fires. A root folder inside the output folder (/mnt/zurg/__magic__/__all__/anything) would also be quiet here, but the namespace refuses to store anything at that depth, so it is not an arrangement that can exist.
PathEquals (PathExtensions.cs:55-73) normalises Unicode, uses DiskProviderBase.PathStringComparison
(OrdinalIgnoreCase on Windows, Ordinal elsewhere — src/NzbDrone.Common/Disk/DiskProviderBase.cs:17-23),
and retries with CleanFilePath() (trailing-slash-insensitive).
Both versions re-run on ProviderUpdatedEvent / ProviderDeletedEvent / ModelEvent<RootFolder> /
ModelEvent<RemotePathMapping>; Radarr adds [CheckOn(typeof(ProviderAddedEvent<IDownloadClient>))]
at :17. Result is HealthCheckResult.Warning with anchor #downloads-in-root-folder. The message
interpolates folder.FullPath in Sonarr and folder.Path in Radarr (:60 in both). Both silently
swallow DownloadClientException and HttpRequestException (debug log) and log any other exception
as an error.
6.2 RemotePathMappingCheck — the local-client path (src/NzbDrone.Core/HealthCheck/Checks/RemotePathMappingCheck.cs)
Short-circuit at :51-54: if EnableCompletedDownloadHandling is off, the check is a no-op.
For every folder in client.GetStatus().OutputRootFolders:
-
!folder.IsValid(:68) —OsPath.IsValid=_path.IsPathValid(PathValidationType.CurrentOs)(OsPath.cs:228). On non-Windows that reduces topath.StartsWith("/")plus aPath.GetInvalidPathChars()scan (PathExtensions.cs:143-182,:403-411). Errors, in order:-
!status.IsLocalhost→RemotePathMappingWrongOSPathHealthCheckMessage(Radarr key:RemotePathMappingCheckWrongOSPath), anchor#bad-remote-path-mapping -
_osInfo.IsDocker→ …BadDockerPath, anchor#docker-bad-remote-path-mapping -
otherwise → …
LocalWrongOSPath, anchor#bad-download-client-settings
-
!_diskProvider.FolderExists(folder.FullPath)(:115):_osInfo.IsDocker→ …DockerFolderMissing,#docker-bad-remote-path-mapping!status.IsLocalhost→ …LocalFolderMissing,#bad-remote-path-mapping- otherwise → …
GenericPermissions,#permissions-error
For a local client (IsLocalhost true, not Docker) this reduces to exactly two requirements:
the reported output root folder must be an absolute path for the OS Sonarr/Radarr runs on, and it
must exist on the Sonarr/Radarr host as a directory. /mnt/zurg/__magic__/__all__ must be visible to the
*arr process, not just to the emulator. There is no writability check here.
The event-driven overload (:178-368) fires on EpisodeImportFailedEvent (Radarr:
MovieImportFailedEvent) and, notably, calls client.GetItems() again to look up the failed
download's OutputPath (:232).
No health check requires the output folder to be writable. FolderWritable
(DiskProviderBase.cs:126-143 Sonarr / :127-144 Radarr) — which writes and deletes
sonarr_write_test.txt / radarr_write_test.txt — is used by
RootFolderService.Add (Sonarr src/NzbDrone.Core/RootFolders/RootFolderService.cs:117-120) and by
DownloadClientBase.TestFolder (:167-187), and TestFolder is never called by the SAB client.
7. Sonarr vs Radarr — the complete diff
Every fetched file was diffed. Results:
Byte-identical apart from a UTF-8 BOM on the Sonarr copies (line numbers therefore match exactly):
SabnzbdCategory.cs (no BOM either side — truly identical), SabnzbdDownloadStatus.cs,
SabnzbdPriority.cs, SabnzbdFullStatus.cs, SabnzbdJsonError.cs, SabnzbdQueue.cs,
SabnzbdQueueItem.cs, SabnzbdHistory.cs, SabnzbdHistoryItem.cs,
JsonConverters/*.cs (all three), Responses/*.cs (all six),
src/NzbDrone.Common/Disk/DiskTransferService.cs, src/NzbDrone.Core/Download/DownloadEventHub.cs.
Differ only by the TvCategory → MovieCategory / RemoteEpisode → RemoteMovie rename
(no line-count change, same line numbers):
-
Sabnzbd.cs—:40-52(AddFromNzbFile; Radarr's recency test isremoteMovie.Movie.MovieMetadata.Value.IsRecentMovievs Sonarr'sremoteEpisode.IsRecentEpisode()),:190,:251,:265/269/273,:458, and everyNzbDroneValidationFailurefield name inTestCategory("TvCategory"→"MovieCategory"). -
SabnzbdProxy.cs—:50,:122-124,:138-140. -
SabnzbdSettings.cs— defaultsTvCategory = "tv"vsMovieCategory = "movies";RecentTvPriority/OlderTvPriorityvsRecentMoviePriority/OlderMoviePriority(both default to(int)SabnzbdPriority.Default). Validator is otherwise identical:Hostvalid,Port1–65535,ApiKeyrequired when username blank,Username/Passwordrequired when ApiKey blank, categoryNotEmptyas a warning. -
UsenetClientBase.cs— Radarr additionally setsrequest.AllowAutoRedirect = true(:51) and treats HTTP410 Gonelike404(:63) when fetching the NZB from the indexer. This is about the indexer, not about SAB. -
DownloadClientBase.cs— only the literal "Sonarr"/"Radarr" inTestFolder's detail text. -
RemotePathMappingCheck.cs— localization key names (RemotePathMapping*HealthCheckMessagevsRemotePathMappingCheck*),EpisodeImportFailedEventvsMovieImportFailedEvent, plus Radarr's extra[CheckOn(ProviderAddedEvent<IDownloadClient>)]. Predicates are identical. -
DiskProviderBase.cs—FolderWritableprobe filesonarr_write_test.txtvsradarr_write_test.txt(Sonarr:132, Radarr:133).
Real behavioural difference — exactly one:
DownloadClientRootFolderCheck.cs:51. Sonarr warns only onPathEquals; Radarr also warns when a root folder is an ancestor of the output folder. Full table in §6.1.
Everything else — modes, params, JSON shapes, enums, converters, CheckForError, RemoveItem,
DeleteItemData, transfer mode, DiskTransferService — is identical. One emulator serves both.
Config defaults identical in both: DownloadClientHistoryLimit = 60,
RemoveCompletedDownloads = true, RemoveFailedDownloads = true.
8. Minimal-but-complete example payloads
Given: complete_dir = /mnt/zurg/__magic__/__all__; categories *, tv, movies, all with dir: "".
Every response is HTTP 200 with Content-Type: application/json.
Resulting OutputRootFolders: dir: "" ⇒ FullPath == complete_dir ⇒ /mnt/zurg/__magic__/__all__
for Sonarr (category tv) and for Radarr (category movies).
8.1 mode=version
{
"version": "4.3.3"
}
Must be major.minor.(patch|x). 4 ≥ 1 satisfies TestConnectionAndVersion (Sabnzbd.cs:389-392);
≥ 2.0 also means GetCategories would use fullstatus rather than queue.my_home if complete_dir
were relative (it isn't).
8.2 mode=get_config
{
"config": {
"misc": {
"complete_dir": "/mnt/zurg/__magic__/__all__",
"pre_check": false,
"enable_tv_sorting": false,
"tv_categories": [],
"enable_movie_sorting": false,
"movie_categories": [],
"enable_date_sorting": false,
"date_categories": [],
"history_retention": "0",
"history_retention_option": "all",
"history_retention_number": 0
},
"servers": [],
"categories": [
{ "name": "*", "order": 0, "pp": "3", "script": "None", "dir": "", "newzbin": "", "priority": 0 },
{ "name": "tv", "order": 1, "pp": "3", "script": "None", "dir": "", "newzbin": "", "priority": 0 },
{ "name": "movies", "order": 2, "pp": "3", "script": "None", "dir": "", "newzbin": "", "priority": 0 }
],
"sorters": []
}
}
Why each value:
-
miscpresent as an object — required, else NRE (§3.4). -
every category has a string
dir— required, else NRE atSabnzbd.cs:238and:462. -
dirdoes not end with*— else the "enable job folders" warning (Sabnzbd.cs:462-469). -
all three
enable_*_sorting: false— kills both theTestCategorywarnings (Sabnzbd.cs:493-518) andDownloadClientSortingCheck. Note thattv_categories: []combined withenable_tv_sorting: truewould fail, becauseContainsCategoryreturns true for empty (Sabnzbd.cs:525-528, fixtureSabnzbdFixture.cs:647-656). -
sorters: []—config.Sorters.Any(s => s.is_active && …)is false (Sabnzbd.cs:484). -
pre_check: false— skips theHasVersion(1,1)round-trip inTestGlobalConfig(Sabnzbd.cs:443). -
history_retention_option: "all"→RemovesCompletedDownloads == false(Sabnzbd.cs:546-547) → no health-check warning. -
complete_dirabsolute →IsRootedtrue →mode=fullstatus/queue.my_homenever consulted (Sabnzbd.cs:220).
8.3 mode=fullstatus&skip_dashboard=1
{
"status": {
"completedir": "/mnt/zurg/__magic__/__all__",
"version": "4.3.3",
"uptime": "1d",
"color_scheme": "Auto",
"darwin": false,
"nt": false,
"pid": 1,
"new_release": "",
"new_rel_url": "",
"active_lang": "en",
"my_home": "/mnt/zurg",
"my_lcldata": "/config",
"webdir": "",
"loglevel": "1",
"folders": [],
"configfn": "/config/sabnzbd.ini",
"warnings": [],
"servers": [],
"localipv4": "127.0.0.1",
"publicipv4": "",
"ipv6": null,
"dnslookup": "ok"
}
}
Only completedir is read (SabnzbdFullStatus.cs:9-10). The wrapper key must be status
(Responses/SabnzbdFullStatusResponse.cs:5).
8.4 mode=addfile response
{
"status": true,
"nzo_ids": ["SABnzbd_nzo_abc12345"]
}
nzo_ids must be a non-empty array of strings (Sabnzbd.cs:47-52). status is parsed
(Responses/SabnzbdAddResponse.cs:13) but never read; keep it true so CheckForError sees "True".
8.5 mode=queue&start=0&limit=0&category=tv — one Downloading slot
{
"queue": {
"status": "Downloading",
"speedlimit": "0",
"speedlimit_abs": "",
"paused": false,
"noofslots_total": 1,
"noofslots": 1,
"limit": 0,
"start": 0,
"finish": 0,
"have_warnings": "0",
"cache_art": "0",
"cache_size": "0 B",
"finishaction": null,
"queue_details": "0",
"my_home": "/mnt/zurg",
"my_lcldata": "/config",
"mb": "1024.00",
"mbleft": "512.00",
"size": "1.0 GB",
"sizeleft": "512.0 MB",
"kbpersec": "1024.00",
"speed": "1.0 M",
"timeleft": "0:08:32",
"eta": "12:34 Fri 22 Aug",
"diskspace1": "1000.00",
"diskspace2": "1000.00",
"diskspacetotal1": "2000.00",
"diskspacetotal2": "2000.00",
"diskspace1_norm": "1000.0 G",
"diskspace2_norm": "1000.0 G",
"slots": [
{
"status": "Downloading",
"index": 0,
"password": "",
"avg_age": "2d",
"script": "None",
"direct_unpack": null,
"mb": "1024.00",
"mbleft": "512.00",
"mbmissing": "0.00",
"size": "1.0 GB",
"sizeleft": "512.0 MB",
"filename": "Some.Show.S01E01.1080p.WEB-DL.H264-GRP",
"labels": [],
"priority": "Normal",
"cat": "tv",
"timeleft": "0:08:32",
"percentage": "50",
"nzo_id": "SABnzbd_nzo_abc12345",
"unpackopts": "3"
}
]
}
}
Hard requirements:
-
top-level key
queue(SabnzbdProxy.cs:129,SelectToken("queue")). -
slots[].status∈ the 16SabnzbdDownloadStatusnames, exact case. -
slots[].mb/mbleftare megabytes, invariant-culture decimals. Strings or numbers both work (Newtonsoft coerces); real SAB sends strings. -
slots[].timeleftis exactlyh:m:sord:h:m:s— never"". -
slots[].priority—"Normal","High","Low","Force","Paused","Default"(case-sensitive) or the numeric equivalent. Nevernull(NRE). -
slots[].cat(notcategory) must equal the client's configured category, or the item is filtered out atSabnzbd.cs:190. -
queue.pausedmust befalseunless you actually mean paused —truemarks every non-Forceslot as Paused with a null ETA (Sabnzbd.cs:78-84).
Resulting DownloadClientItem: TotalSize = 1024 * 1024 * 1024, RemainingSize = 512 * 1024 * 1024,
RemainingTime = 00:08:32, Status = Downloading, CanBeRemoved = CanMoveFiles = true.
8.6 mode=history&start=0&limit=60&category=tv — one Completed and one Failed slot
{
"history": {
"noofslots": 2,
"ppslots": 0,
"day_size": "1.0 G",
"week_size": "7.0 G",
"month_size": "30.0 G",
"total_size": "1.0 T",
"last_history_update": 1755835200,
"version": "4.3.3",
"slots": [
{
"id": 1,
"completed": 1755834000,
"name": "Some.Show.S01E02.1080p.WEB-DL.H264-GRP",
"nzb_name": "Some.Show.S01E02.1080p.WEB-DL.H264-GRP.nzb",
"category": "tv",
"pp": "3",
"script": "None",
"report": "",
"url": "",
"status": "Completed",
"nzo_id": "SABnzbd_nzo_def67890",
"storage": "/mnt/zurg/__magic__/__all__/Some.Show.S01E02.1080p.WEB-DL.H264-GRP",
"path": "/mnt/zurg/__magic__/__all__/Some.Show.S01E02.1080p.WEB-DL.H264-GRP",
"script_log": "",
"script_line": "",
"download_time": 120,
"postproc_time": 3,
"stage_log": [],
"downloaded": 1073741824,
"completeness": 100,
"fail_message": "",
"url_info": "",
"bytes": 1073741824,
"meta": null,
"series": "",
"md5sum": "",
"password": "",
"action_line": "",
"size": "1.0 GB",
"loaded": false,
"retry": 0,
"has_rating": false
},
{
"id": 2,
"completed": 1755833000,
"name": "Some.Show.S01E03.1080p.WEB-DL.H264-GRP",
"nzb_name": "Some.Show.S01E03.1080p.WEB-DL.H264-GRP.nzb",
"category": "tv",
"pp": "3",
"script": "None",
"report": "",
"url": "",
"status": "Failed",
"nzo_id": "SABnzbd_nzo_ghi13579",
"storage": "",
"path": "",
"script_log": "",
"script_line": "",
"download_time": 0,
"postproc_time": 0,
"stage_log": [],
"downloaded": 0,
"completeness": 0,
"fail_message": "Article download failed, out of retention",
"url_info": "",
"bytes": 0,
"meta": null,
"series": "",
"md5sum": "",
"password": "",
"action_line": "",
"size": "0 B",
"loaded": false,
"retry": 0,
"has_rating": false
}
]
}
}
Hard requirements:
-
top-level key
history(SabnzbdProxy.cs:145). -
slots[].bytesis BYTES (a JSON number →long). Do not send MB here — the queue uses MB, the history uses bytes (Sabnzbd.cs:72-73vs:128). -
slots[].category(notcat). -
slots[].storage= the job folder itself; it becomesOutputPath, and for a Completed item it is whatDeleteItemDatarecursively deletes (§5.3). It must be an absolute path valid for the OS Sonarr/Radarr runs on and must exist there (RemotePathMappingCheck, §6.2). -
slots[].statusexact-case;Deleteditems are dropped entirely (Sabnzbd.cs:116-119). -
fail_messageexactly"Unpacking failed, write error or disk is full?"downgrades a Failed item toWarninginstead ofFailed(Sabnzbd.cs:140-144) — use something else for a genuine failure. -
Radarr:
categorymust bemovies(or whatever is configured) andnameshould parse as a movie.
8.7 Error response (any mode)
{
"status": false,
"error": "API Key Incorrect"
}
HTTP 200. status may be the boolean false or the string "false"; both satisfy
SabnzbdJsonError.Failed (SabnzbdJsonError.cs:10-11). The client raises
DownloadClientException("Error response received from SABnzbd: API Key Incorrect")
(SabnzbdProxy.cs:244), which TestAuthentication recognises (Sabnzbd.cs:424).
Use "API Key Required" when no key was supplied (Sabnzbd.cs:429). Any other error string is
reported verbatim but not classified.
9. Implementation checklist (12 points)
-
One route:
GET|POST {UrlBase}/api, dispatch onmode. Always HTTP 200, always JSON. -
Implement
version,get_config,queue(read +name=delete),history(read +name=delete),addfile,fullstatus. Optionallyretry(dead code today).get_catsis never called. -
Accept auth as either
apikey=orma_username=+ma_password=. Reject with 200 +{"status":false,"error":"API Key Incorrect"}/"API Key Required"— never a 401/403. -
Honour
category=onqueue/history(the clients also filter client-side by exact string match,Sabnzbd.cs:190). -
queuesizes in MB (mb,mbleft);historysizes in BYTES (bytes). Never mix them up. Queue usescat, history usescategory. -
timeleftis alwaysh:m:sord:h:m:s, never empty, never absent-as-empty-string. No date field is parsed anywhere, so epoch fields are free-form. -
statusvalues must come from the 16-memberSabnzbdDownloadStatuslist, exact case (an unknown name throws and kills the whole queue/history call).prioritymust be aSabnzbdPriorityname (exact case) or its integer; nevernull(NRE in the converter). -
config.miscmust be an object; every category must carry a non-null stringdirthat does not end in*.categories/sorters/serversmay be null or absent,miscanddirmay not. -
Keep
enable_tv_sorting,enable_movie_sorting,enable_date_sortingandpre_checkallfalse, andhistory_retention_option: "all", to keep the connection test and all health checks clean. -
storageon a Completed history slot = the job folder. Expect the *arr torm -rfit after a successful import (default settings), and expect the media file to be moved out of it first. If the magic directory is read-only the delete fails silently (warning log only), but the move of the imported file must still succeed or the import fails outright (§5.5). -
Do not place any *arr root folder at or above
/mnt/zurg/__magic__/__all__if you want Radarr'sDownloadClientRootFolderCheckto stay quiet (Sonarr only cares about exact equality). Put them beside the mirror instead —/mnt/zurg/__magic__/tv,/mnt/zurg/__magic__/movies— which is unrelated to the output folder rather than above or below it. -
/mnt/zurg/__magic__/__all__must exist as a directory on the Sonarr/Radarr host, not just where the emulator runs, orRemotePathMappingCheckraises an error. It does not need to be writable.
Configuring a larger completion window
Both clients default DownloadClientHistoryLimit to 60. zurg mirrors that
window when it keeps completed overflow in queue; increasing only the client
limit would report some jobs in both queue and history, where queue wins.
For a large backlog, set sabnzbd.history_limit in zurg and the clients'
DownloadClientHistoryLimit to the same value, for example 500. This exposes
more completed and failed jobs per poll. Leave zurg at the smallest client
limit when several clients use different values. This advanced client setting
is not exposed in the Sonarr v4 download-client configuration API resource;
back up and stop the client before editing its stored configuration.
sabnzbd:
enabled: true
history_limit: 500
The default remains 60; zero and negative values use that default. A diagnostic history request with a different limit never changes the configured window. Warnings that the client cannot resolve still require review; a larger window lets other jobs reach import while those warnings occupy history slots.
The availability check before completion samples the first sixteen articles and the last article of each content file. This catches a missing article in the media header that the former first-article canary missed. It transfers the first article of each content file, which checks article identity and that the body is still served (a provider can answer STAT for an article whose body it has taken down), plus randomly drawn articles up to sixteen bodies for a release of fewer files than that, and at most sixty-four bodies in all; a body the article cache already holds is not fetched again. STAT covers the rest of the sample. It does not validate every article or prove that the media decodes. STAT requests are batched per file so the sample pays for one pool reservation instead of seventeen, without raising the account's connection allowance. An unusable batch falls back to individual STATs. Server failures leave the job queued for retry; confirmed missing articles produce a failed download before the first completion notification.