đ Show Your Work
Hey everyone, long time no devlog. This is going to be a long one; weâll discuss three important architecture decisions in v2 and then present a complete rethink of how profiles might be built in the future. But first, some Leibniz.
More on him later.
Upgrades
One of the most common questions we get on Discord is some variation of âwhy did Radarr grab this release?â or âwhy did Sonarr downgrade my file?â People assume the answer has something to do with the quality profiles themselves; that the arrs evaluated all the options and picked the best one. But thatâs not how it works. The arrs donât search for the best release. They monitor RSS feeds and grab the first thing that qualifies as an upgrade over what you currently have. Not the best option. Just an option that clears the bar.
So the natural question becomes: how do you actually get the best release, not just the first acceptable one? There are a few existing approaches to this.
Existing Solutions
| Solution | How it works | Pros | Cons |
|---|---|---|---|
| Upgradinatorr by angrycuban13 | Triggers manual searches on a schedule. Grabs a batch, searches each, tags them as âdoneâ. Cron it and it works through your library over time. | Simple, configurable batch size and filters | Random selection, no prioritisation, manual cron setup |
wanted/cutoff API to find items below your quality cutoff and triggering searches. |
Note (26 Feb 2026): Huntarr has been discontinued. The GitHub repository and subreddit were taken private after a security review revealed critical authentication bypass vulnerabilities that exposed API keys for all connected arr instances to anyone who could reach the Huntarr web interface. If youâre still running it, remove it from your stack and rotate your api keys.
Weâve traditionally recommended Upgradinatorr, specifically via DAPS, because it doesnât rely on the wanted/cutoff metric and upgrades your library meaningfully over time, even if randomly. But we also realise not everyone wants to set up another service, learn another configuration process, and maintain another piece of their homelab. Or maybe you do, masochist. So we decided to bake upgrade functionality directly into Profilarr, building on Upgradinatorrâs approach.
Profilarrâs Solution
The basic approach is the same as Upgradinatorr; iterate through your entire library, figure out what needs an upgrade, and trigger searches using the command endpoint on Radarr or Sonarr. Nothing revolutionary there.
The difference is in how we decide what to upgrade. Upgradinatorr selects randomly from whatever matches its basic criteria. That works, but it means you have no control over priority. A niche foreign film from 2003 has the same chance of getting searched as the blockbuster you added yesterday.
Profilarr breaks this into a few steps:
Initial Grab: Pull everything from your arr instance - every movie or series, along with its metadata: ratings, popularity, file size, date added, quality profile, whether itâs monitored, whether itâs hit cutoff, and so on.
Filter: Define filters using AND/OR logic to narrow down what qualifies for upgrade. Filters can be nested arbitrarily deep, so you can express fairly complex conditions.
For example, the âHigh Priority Popularâ filter in the screenshot below says: monitored is true AND cutoff met is false AND size on disk is less than 15GB AND (popularity is greater than 30 OR TMDb rating is at least 7 OR year is 2020 or later). Thatâs a filter that targets popular, recent, or well-rated content thatâs currently undersized, exactly the stuff youâd want to prioritise.
You can filter on basically anything the arr knows about: title, year, genre, studio, collection, ratings from TMDb/IMDb/Rotten Tomatoes/Trakt, runtime, file size, release group, date added, original language, and more. Each field supports the operators that make sense for its type, text fields get contains/starts with/ends with, numbers get greater than/less than/equals, dates get before/after/in the last N days. Essentially, ripping riffing off Plexâs smart collection filtering!
Select: Once filtering narrows the pool, a selector picks which items actually get searched this run. Options include: random, oldest first, newest first, lowest custom format score, most popular, or least popular. Combined with a count (how many items per run) and a cooldown (skip items searched recently), this gives you precise control over upgrade behavior.
Modes: The filter system also supports multiple named filters with a filter mode. Round robin cycles through your filters in order, one filter per scheduled run. So you might have âUpgrade Neededâ run on Monday, âHigh Priority Popularâ on Tuesday, and so on. Or use random shuffle if you donât care about order.
Import/Export: Filter configs are fully exportable and importable. You can copy someone elseâs setup, share your own, or just browse examples to understand how the system works. Two filters - Basic & High Priority Popular can be seen below in its raw json form.
{
"name": "Basic",
"enabled": true,
"group": {
"type": "group",
"match": "all",
"children": [
{
"type": "rule",
"field": "monitored",
"operator": "is",
"value": true
},
{
"type": "rule",
"field": "cutoff_met",
"operator": "is",
"value": false
}
]
},
"selector": "random",
"count": 5,
"cutoff": 80,
"searchCooldown": 24
}The first person who builds the filter that somehow excludes their entire library and makes a support request for it will forever be memorialised in the discord bot with the /shame command.
Operations
In the last devlog, Mutable Immutability, we outlined v2âs approach to databases using SQL instead of YAML. SQL gave us referential integrity; the database enforces consistency, not the application code. On top of that, we introduced a âchange layerâ for user customisations. Instead of storing modified data directly, we stored the operations that modified it. Your changes became a sequence of discrete operations replayed on top of an immutable base. It worked. And once it was working, the natural question was: why limit this to user changes?
The new system applies the same principle to everything. The base database isnât state anymore, itâs operations. The schema isnât a static DDL file, itâs operations. Tweaks arenât a special system bolted on the side, theyâre operations. Everything reduces to the same primitive: an ordered, append-only sequence of SQL operations that can be replayed to produce state. We call this Operational SQL (OSQL), and the databases built with it are Profilarr Compliant Databases (PCDs).
This shift also made Dolt redundant. The previous devlog positioned Dolt as the version control layer, Git for databases. But Dolt versions state: the database after itâs been built. When you pull a Dolt database, youâre trusting that the binary blob you received is what was intended. Thereâs no way to audit it without running it. Thatâs like distributing VM images instead of Dockerfiles, or cloud console snapshots instead of Terraform configs. OSQL flips this. The .sql files are the source of truth; readable, auditable, diffable. The database state is just what falls out when you replay them. Infrastructure as code, not infrastructure as artifact.
Layers
Operations are organised into layers. Each layer is append-only, but later layers can override the effects of earlier ones.
| Layer | Purpose | Who writes it | Example |
|---|---|---|---|
| Schema | Defines tables, columns, foreign keys, constraints. No data. | Profilarr | CREATE TABLE custom_formats (...) |
| Dependencies | Allows PCDs to compose with other PCDs. Coming in a future major version. | Database maintainers, community | IMPORT pcd('shared-regex-library') |
| Base | The actual shipped database content. Profiles, formats, regex patterns, quality definitions. | Database maintainers (us) | INSERT INTO custom_formats VALUES ('DV', 'Dolby Vision', ...) |
| Tweaks | Optional adjustments that modify base behavior. Enable DV, ban a release group, boost streaming. | Database maintainers, community | UPDATE quality_profile_custom_formats SET score = 0 WHERE ... |
| User Ops | Your personal customisations. Heaviest value guards to detect upstream conflicts. | You | UPDATE quality_profile_custom_formats SET score = 1500 WHERE ... |
When Profilarr builds your database, it replays these layers in order: Schema -> Dependencies -> Base -> Tweaks -> User Ops. Later operations override earlier ones. The final state is whatever falls out of replaying everything.
Migration
Of course, none of this matters if migrating from v1 is painful. So Rosettarr was built to translate the old YAML configs into OSQL operations. Your existing setup becomes a replay log, just like everything else. The full schema and OSQL/PCD documentation are available here.
Testing
Testing in v1 was the weakest part of the system. Regex tests were full of noise, custom format testing only worked for regex-related conditions, and quality profiles didnât get any testing at all. While everything else in v1 was usable, testing was the module I was quite unhappy with, which was frustrating, because it was part of why I started working on Profilarr in the first place. I wanted an easier way to build and verify custom formats and quality profiles, not just sync them. Fixing testing was one of the most important tasks for v2.
Regular Expressions
Regex testing is straightforward; you want to verify a pattern matches or doesnât match release titles. Solutions for this already exist. Regex101 does it well, so we use that instead of maintaining something custom. Regex101 gives you an ID for each saved regex, and that ID updates in place when you make changes. We initially wanted to just store the ID, that way, when we update test cases, the link in the repo automatically points to the latest version without needing a commit. But regex101 IDs are editable by anyone who has them. Someone could modify the test suite for a format externally, and the repo would still point to it without any visible change. So we store the full versioned ID instead, the ID plus the version number, which is immutable. It means updating tests requires updating the link, but it also means the test suite canât be tampered with outside the repo.
Storing the full versioned ID also makes caching easier. Since itâs immutable, we can cache responses from regex101 indefinitely - first time you view a regex we fetch it, every time after that it loads from cache.
Custom Formats
Custom format testing is harder. You want to verify that a format matches or doesnât match a release title, same as regex, but custom formats arenât just regex. Theyâre combinations of conditions: regex patterns, quality specifiers, size limits, indexer flags. To test whether a format matches, you need to parse the release the same way Radarr and Sonarr do. Not approximately. Exactly.
My first thought was to spin up Radarr and Sonarr as microservices in the compose stack and query their parse endpoints directly. But thatâs wasteful; youâd need API keys, youâd have to add dummy titles, and the setup overhead made it miserable to work with. So instead, I pulled the C# parser code directly from Radarr and Sonarr, unified it under a single endpoint, and ran that as a microservice. Same parsing logic the arrs use, without the extra fluff.
With the parser in place, I added a parse table to the GUI. It breaks down each group of conditions and shows how they pass or fail, so you can see exactly why a format matched or didnât. Hopefully this makes debugging custom formats a bit less painful.
Quality Profiles
Quality profile testing was the hardest to solve. Unlike regex or custom formats, youâre not just checking if something matches. Youâre checking if a profile prioritises releases correctly. Does it pick the right one? Does it skip the ones it should? Does it upgrade when it should?
I ended up with two solutions: a simple one and a complex one. For testing, we went with the simpler approach. Testing needs to be accessible. If no one can read or understand it, no one will use it.
The solution is entity-based testing. You define an entity (a movie, a TV series) and then define a set of releases for that entity. The same parser we use for custom formats determines which formats apply to each release, then scores get calculated based on the profile. From there, you can see exactly how the profile ranks everything: what it would grab, what it would skip, what it would upgrade to.
Itâs not âtestingâ in the same sense as regex or custom formats. Itâs more like a simulation. Youâre speeding up what youâd normally do manually: sync, interactive search, notice somethingâs wrong, tweak, sync again, search again. Now you can do that loop entirely in Profilarr before anything touches your arr.
This feature isnât complete yet. Iâm still figuring out how it can work for documentation, and I think testing is a good double purpose here. Rather than relying on written descriptions to explain what a profile does, users can see exactly how it behaves in practice. Define some releases, watch how the profile scores them, and suddenly the logic clicks in a way that documentation alone canât provide.
Let Us Calculate
Simulation has limits. Youâre still observing behavior, not specifying it. You watch releases get ranked and hope the ranking matches your intent. If it doesnât, you tweak scores and simulate again. Itâs better than the sync-search-wait loop, but itâs still trial and error.
What if you could skip the trial and error entirely?
Back to Leibniz:
Property Based Profiles
Quality profiles are still backwards. You assign scores, then check if the behavior matches your intent. Scores first, behavior second. Youâre working in the wrong direction.
What if you could declare your intent directly? Not âREMUX gets 1500 pointsâ but âREMUX always beats WEB-DL.â Not âYIFY gets -10000â but âYIFY is always rejected.â Not scores, but properties. Invariants. The rules that must hold, regardless of how the scores are configured.
And then: let the system calculate the scores for you.
This flips the entire workflow. Normally you assign scores, sync, search, observe behavior, realise itâs wrong, tweak scores, repeat. Youâre working backwards; implementation first, then checking if it matches intent. Property-based profiles work forwards. You declare intent. The system derives implementation. If your intent is contradictory (A beats B, B beats C, C beats A), the system tells you before you waste an hour debugging.
To make this work, you need a way to express relationships precisely.
Predicates
A predicate is just a description of releases. âREMUXâ is a predicate. So is âWEB-DL with HDRâ or âanything from YIFY.â Predicates can be as simple or compound as you need.
Propositions
A proposition is a claim about how predicates relate:
| Symbol | Meaning | Example |
|---|---|---|
> | beats | REMUX > WEB-DL |
= | ties | BHDStudio = FraMeSToR |
* | is the winner | *Golden |
0 | is rejected | 0 YIFY |
Properties
A property is a collection of propositions that together define how a profile should behave. Hereâs what a real property might look like:
P = {
Golden > MA_SDR, "REMUX+HDR+Atmos beats Movies Anywhere SDR"
MA_SDR > P+_HDR, "Source quality over format"
P+_HDR > Trash, "Even mediocre HDR beats garbage"
*Golden, "Best release gets grabbed"
0 Trash "Trash never gets grabbed"
}Solving
Each proposition you write translates directly into a mathematical constraint.
Take REMUX > WEB-DL. What does that actually mean in terms of scores? It means: for any release matching REMUX and any release matching WEB-DL, the REMUX release must score higher. If we treat scores as variables, letâs call the REMUX score x1 and the WEB-DL score x2, then REMUX > WEB-DL is just:
x1 > x2Now stack a bunch of these together. A property set with ten propositions becomes a system of ten constraints. Twenty propositions, twenty constraints. And what do you have? A linear constraint satisfaction problem.
Mathematicians and computer scientists have been solving these for decades. The question âdoes a set of linear inequalities have a solution, and if so, what is it?â has known, efficient answers.
For a simple case, > relations form a hierarchy. Build a directed graph where each predicate is a node and each âbeatsâ relation is an edge. If thereâs a cycle, you have a contradiction (more on that in a moment). If thereâs no cycle, topologically sort the graph. That gives you an ordering. Walk the ordering and assign decreasing scores and youâre done. Guaranteed to satisfy every constraint.
For more complex cases - ties, specific score gaps, bounded ranges, weâd need linear programming. LP solvers take a system of linear inequalities and either find a feasible solution or prove none exists. This is the same math that optimises airline schedules and supply chains. Applying it to media profile scores is, frankly, overkill. But overkill means it works!
Cycle Detection
Not all property sets have solutions. If you declare:
A > B
B > C
C > AThatâs a cycle. A must score higher than B, B higher than C, C higher than A. Impossible. The solver catches this and tells you exactly where the contradiction is!
Feasibility
This isnât shipping in v2. It might never ship at all. The math works, but thatâs a long way from âusers can actually use this.â
The current system: assign scores, observe behavior, tweak, is already hard for people. The Discord is full of questions about why profiles behave unexpectedly. Adding a formal constraint language doesnât magically fix that. It just moves the complexity somewhere else. Instead of âwhy did this score cause that behavior,â it becomes âwhy did this property set produce those scoresâ or âwhy are my constraints unsatisfiable.â
Thereâs also the UI problem. How do you make predicate construction accessible? How do you visualise constraint graphs? How do you explain cycle detection errors to someone who just wants their movies to download correctly? I donât have good answers yet.
But I think itâs worth writing about anyway. Even if property-based synthesis never becomes a feature, itâs a useful thing to optimise toward. It clarifies what profiles actually are: not collections of numbers, but encoded intent. The numbers are just one valid implementation of that intent. Keeping that framing in mind shapes how we build everything else: testing, documentation, sharing.
Maybe someday.
Tidbits
Hereâs a list of other random things that donât fit neatly into this logâs structure.
- v2 is getting closer. Some days Iâll write code for 12 hours and other days Iâm playing Red Dead Redemption 2 for 12 hours. Balance. Progress is slowing down as I transition to implementing the repetitive tasks like edit functionality, dirty tracking, etc.
- I havenât touched the v1 codebase for a while. I feel pretty guilty about this since there are some outstanding cache bugs that really should have been merged into stable months ago, but just thinking about that god awful codebase makes me anxious.
- Iâve written 2 wiki articles recently:
- Multi Episode Splitting: A personal project I thought to share.
- Anatomy of a Profile: An ELI5 profile building guide at the request of SFusion! (Sorry it took so long)
- I added RSS feeds for the website, check the footer.
- On a more personal note, I finally finished my computer science degree this past december. Hopefully more time to code