Skip to content

Custom Rules Vs Sonarr Custom Formats

Sonarr custom formats and Scryer custom rules answer the same question — how much do I want this release? — but they answer it in different shapes. A custom format is a declarative matcher: you pick from a fixed set of specification types, and the score lives somewhere else. A Scryer rule is a policy: a small piece of Rego that looks at a release and emits named score entries.

If you are coming from Sonarr, most of your custom formats port over directly. The parts that do not port are usually the parts worth revisiting anyway.

Sonarr custom formatScryer custom rule
Written asA list of specifications in the UIRego policy source
Condition vocabulary8 fixed specification typesAny field on the rule input document
CombinationSame type ORs, different types ANDOrdinary boolean logic
Where the score livesIn each quality profile, per formatIn the rule itself
ScopingPer quality profilePer facet (movie, series, anime)
BlockingEmergent from the score totalAn explicit veto sentinel
Sees the actual fileNo stream-level detailYes, at import
Reusable across profilesScore re-entered per profileRule carries its own score

Sonarr’s eight specification types are Release Title, Release Group, Language, Indexer Flag, Source, Resolution, Size, and Release Type. That set is the ceiling: if the thing you care about is not one of those eight, there is no custom format for it. A Scryer rule reads a structured input document instead, so its ceiling is the document.

This is the first thing that surprises people porting a setup.

In Sonarr, a custom format carries no score. It is purely a matcher. The score is stored in each quality profile’s format items, which is why the same custom format can be worth +500 in one profile and -10000 in another, and why importing a TRaSH custom format is only half the job — you still have to set its score in every profile that should care.

In Scryer, the number is in the rule:

Scryer — the score is the rule
score_entry["x265_bonus"] := 100 if {
codec := scryer.normalize_codec(input.release.video_codec)
codec == "H.265"
}

score_entry is a map of code → delta. The code (x265_bonus) is what shows up in the scoring breakdown for a release, so it is worth naming precisely. One rule can emit several entries, and each lands in the breakdown on its own line.

The tradeoff is real in both directions. Sonarr’s split lets one matcher mean different things to different profiles. Scryer’s rules are scoped by facet rather than by profile, so a rule says one thing everywhere it applies — easier to reason about, less flexible if you genuinely wanted per-profile weights.

Both systems use -10000 as the “never grab this” number, which makes them look more alike than they are.

In Sonarr, blocking is arithmetic. A release is rejected when its total custom format score falls below the profile’s Minimum Custom Format Score (default 0). The -10000 convention works because -10000 is far below zero — but it is a convention, not a mechanism. It can be outvoted:

ReleaseMatched formatsScoreRejected by min score?
...1080p.BluRay.x264-YIFYBlocked Groups−10000Yes
...1080p.BluRay.x265-YIFYBlocked Groups, Prefer x265 (+100)−9900Yes
...1080p.BluRay.x265-YIFYBlocked Groups, Prefer x265 (+10100)+100No

That last row is not hypothetical — it is what Sonarr does when a positive format is scored high enough to cancel a -10000. The “must not have” format still matched; it just lost the arithmetic.

In Scryer, a veto is not a number. A rule blocks by emitting scryer.block_score(), and the decision’s allowed flag is set by the presence of that entry, not by the sum. No stack of bonuses can revive a blocked release.

Scryer — a veto that cannot be outvoted
score_entry["blocked_group"] := scryer.block_score() if {
input.release.release_group != null
input.release.release_group != ""
group := lower(input.release.release_group)
blocked_groups[group]
}

Scryer does also have Sonarr’s threshold behavior, as a separate profile setting — a minimum score to grab, which produces a score_below_minimum block. That is a grab floor, deliberately not an import gate: a file already on disk and correct is not improved by refusing it.

A rule is evaluated against one input document per release. The top-level sections:

PathWhat it holds
input.releaseEverything parsed from the release name, plus size, age, and indexer extras
input.profileThe active quality profile’s criteria
input.contextTitle, library, facet, tags, original language, runtime, search mode
input.builtin_scoreWhat Scryer’s own scoring already decided, including its codes
input.fileMedia-analysis facts about the actual file — null before download

input.builtin_score has no Sonarr equivalent: a rule can read the built-in decision and react to it, rather than only adding to it.

Useful input.release fields include quality, source, video_codec, audio_codecs, audio_channels, languages_audio, release_group, size_bytes, age_days, is_remux, is_atmos, is_dolby_vision, is_hdr10plus, is_10bit, is_dual_audio, is_repack, is_proper_upload, is_season_pack, is_multi_episode, is_obfuscated, is_retagged, has_release_group, edition, streaming_service, and extra for indexer-supplied values.

Five builtins do the normalization you would otherwise write by hand:

BuiltinReturns
scryer.block_score()The veto sentinel
scryer.size_gib(bytes)Size in GiB
scryer.lang_matches(code, pattern)Language match across ISO 639 aliases (ja, jpn, japanese)
scryer.normalize_source(raw)Canonical source (WEB-DL, BLURAY, …)
scryer.normalize_codec(raw)Canonical codec (H.264, H.265, …)

The Sonarr JSON below is the shape the v3 API accepts: fields is an array of {name, value} objects. Exports from TRaSH Guides are written for the UI’s import box and use an object instead — worth knowing when a paste is rejected.

Sonarr matches the release title with a regex. Scryer normalizes the parsed codec, so it does not need to enumerate the ways a group might spell HEVC.

Sonarr — custom format
{
"name": "Prefer x265",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "x265",
"implementation": "ReleaseTitleSpecification",
"negate": false,
"required": true,
"fields": [{ "name": "value", "value": "\\b(x265|h265|hevc)\\b" }]
}
]
}
Scryer — rule (Rego)
score_entry["x265_bonus"] := 100 if {
codec := scryer.normalize_codec(input.release.video_codec)
codec == "H.265"
}

Then, in Sonarr, set the score to 100 in each profile. In Scryer the 100 above is the whole story.

Sonarr — custom format
{
"name": "Preferred Anime Groups",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "Groups",
"implementation": "ReleaseGroupSpecification",
"negate": false,
"required": true,
"fields": [{ "name": "value", "value": "^(SubsPlease|Erai-raws|EMBER|Yameii)$" }]
}
]
}
Scryer — rule (Rego)
preferred_groups := {"subsplease", "erai-raws", "ember", "yameii"}
score_entry["preferred_anime_group"] := 400 if {
input.release.release_group != null
input.release.release_group != ""
group := lower(input.release.release_group)
preferred_groups[group]
}

A set with a case-folded lookup is easier to extend than a regex alternation, and it cannot accidentally match mid-token. This rule would be scoped to the anime facet, so it never touches Movie or Series decisions — in Sonarr the equivalent isolation means keeping separate profiles.

Before writing group rules by hand, check what is already there: Scryer ships TRaSH Guides release-group tiers built in, scored through your scoring persona. Many group-preference custom formats are redundant on arrival.

Penalize x264 at 4K. In Sonarr this is two specifications of different types, which AND together.

Sonarr — custom format
{
"name": "x264 at 2160p",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "2160p",
"implementation": "ResolutionSpecification",
"negate": false,
"required": true,
"fields": [{ "name": "value", "value": 2160 }]
},
{
"name": "x264",
"implementation": "ReleaseTitleSpecification",
"negate": false,
"required": true,
"fields": [{ "name": "value", "value": "\\b(x264|h264|avc)\\b" }]
}
]
}
Scryer — rule (Rego)
score_entry["x264_4k_penalty"] := -200 if {
input.release.quality == "2160P"
scryer.normalize_codec(input.release.video_codec) == "H.264"
}

The combination rule is the thing to internalize. Sonarr groups specifications by type, then requires every group to match. Within a group, a required specification must match, and at least one specification must match. In practice:

SpecificationsBehavior
Two of the same type, neither requiredOR — either one matching is enough
Two of the same type, both requiredAND
Two of different typesAND, always

In Rego there is no such table. Conditions in one body are AND; write a second body for the same key to get OR.

Sonarr — custom format
{
"name": "Freeleech",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "Freeleech",
"implementation": "IndexerFlagSpecification",
"negate": false,
"required": true,
"fields": [{ "name": "value", "value": 1 }]
}
]
}
Scryer — rule (Rego)
score_entry["freeleech_bonus"] := 500 if {
input.release.extra.freeleech == true
}
score_entry["halfleech_bonus"] := 200 if {
input.release.extra.downloadvolumefactor == 0.5
}

Sonarr’s indexer flags are a fixed enum — Freeleech, Halfleech, DoubleUpload, Internal, Scene, Freeleech75, Freeleech25, Nuked — and they exist only on torrent releases; a Usenet release never carries one. Scryer reads input.release.extra, which is whatever the indexer plugin supplied, so anything the indexer publishes is available without waiting for a new specification type.

Sonarr — custom format
{
"name": "Oversized",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "Over 100GB",
"implementation": "SizeSpecification",
"negate": false,
"required": true,
"fields": [
{ "name": "min", "value": 100 },
{ "name": "max", "value": 10000 }
]
}
]
}
Scryer — rule (Rego)
score_entry["too_large"] := scryer.block_score() if {
scryer.size_gib(input.release.size_bytes) > 100
}

Two gotchas here. Sonarr’s size field is a range, not a threshold — matching “bigger than 100” means setting a maximum you never expect to hit. And the unit labeled GB is computed as 1024³, so it is really GiB; Scryer’s scryer.size_gib() at least says so in the name.

The blocking difference from earlier applies directly: the Sonarr version rejects only while nothing else outscores it, and only for profiles where you set -10000. The Scryer version is decisive in every facet the rule applies to.

This is the one that does not really port.

Sonarr — custom format
{
"name": "Japanese Audio",
"includeCustomFormatWhenRenaming": false,
"specifications": [
{
"name": "Japanese",
"implementation": "LanguageSpecification",
"negate": false,
"required": true,
"fields": [
{ "name": "value", "value": 8 },
{ "name": "exceptLanguage", "value": false }
]
}
]
}
Scryer — rule (Rego)
score_entry["no_japanese_audio"] := scryer.block_score() if {
input.file != null
not has_japanese_audio
}
has_japanese_audio if {
some lang in input.file.audio_languages
scryer.lang_matches(lang, "ja")
}

The Sonarr format matches a release whose name claims Japanese. The Scryer rule inspects the audio tracks of the downloaded file and refuses the import when no Japanese track is actually present. One trusts the announcement; the other checks.

A Sonarr custom format’s input is limited to parsed release info, quality, size, indexer flags, languages, filename, and release type. There is no stream-level detail available to it — no per-stream codecs, channel counts, bit depth, or HDR format. Scryer’s input.file carries all of that at import time, which is why rules like this exist at all.

Because input.file is null before the download, guarding on input.file != null is what makes a rule import-only. A rule without that guard runs at search time too, where file fields simply read as undefined.

  • negate. Sonarr inverts one specification with a flag. In Rego you write not, but watch the interaction with null: not input.release.is_atmos is true when the field is absent, which is usually what you want, but be deliberate about it.
  • Per-profile scores. If you relied on one custom format scoring differently across profiles, a single rule cannot express that. Split it into differently-scoped rules.
  • Source and Resolution formats. These are usually better expressed as quality-profile criteria in Scryer — source_allowlist, quality_tiers, and friends — rather than as rules. Reach for a rule when you want a preference, not an eligibility boundary.
  • Large TRaSH bundles. Check what is already built in before porting dozens of formats by hand. Release-group tiers, source scoring, audio and HDR weighting, and size shaping are handled by scoring personas.
  1. Write down each custom format and its score in each profile — half the information is in the profile, not the format.
  2. Drop anything the built-in TRaSH data and personas already cover.
  3. Convert the rest, one rule per intent, with a precise score_entry code so the breakdown stays readable.
  4. Replace -10000 scores with scryer.block_score(), never a hand-written number.
  5. Scope each rule to the facets it belongs to instead of duplicating it per profile.
  6. Reconsider anything language- or codec-related — a name-matching format may be better as a post-download rule against input.file.

Scryer ships templates for most of the patterns above; start from Settings → Rules rather than from a blank editor.