Tiramisu Manual Content Addition
SkillFiles & storageUse when adding a specific movie/TV release to a Tiramisu library by hand. Picks a release with the deployment's own scoring and files it through the Library API, which needs no access to the filesystem.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Tiramisu Manual Content Addition skill
What this skill tells your AI
The instructions your AI receives, as published by mrrobotogit/tiramisu in hermes/SKILL.md and read by ahel’s review.
How to add a specific movie or TV series to a Tiramisu library when the automated
indexer sync missed it, replicating the same quality-scoring and virtual-file logic
the sync engine uses. Deployment, operation and debugging live in
tiramisu-development; this skill only covers getting one release into the library.
Everything happens over HTTP against the control port: the server writes the files, so this runs from anywhere that can reach the deployment and needs no access to its filesystem. One helper script is embedded in Helper scripts and only reads the configuration. No host, IP or secret is embedded anywhere: fill the placeholders per deployment, never hardcode.
Requires Tiramisu v1.9.64 or later. On an older build the endpoints answer
404. Say so and stop. Updating is the fix: writing the stub by hand is never
the answer, wherever you happen to be running.
The only thing you need from the operator is CTRL, the control API base
(http://<host>:9080 by default). The deployment describes itself from there:
GET {CTRL}/api/config returns every other path and port, so ask for them only
if that call fails.
| Name | Where it comes from | Example |
|---|---|---|
CTRL | the operator, or the default http://127.0.0.1:9080 | control API, everything goes here |
The same response also carries tmdb_api_key, the Prowlarr block,
torrentio_url, media_server_type and the plex block with its library ids.
Resolve them, do not ask for them.
If media_server_type is empty, infer it: a populated plex.url means Plex.
What you take from that response is the scoring profile and the indexer credentials. The paths it reports are the server's own business.
Core model
Tiramisu libraries are virtual: each file Plex/Jellyfin sees is a small JSON stub on the real filesystem, exposed by the FUSE layer with the declared full size.
- Read the deployment's own scoring profile from the config API
- Pick a release, which is the part nobody can do for you
- Hand it to
POST {CTRL}/api/library/add, which registers the torrent, waits for its file list, writes one JSON stub per video file and asks the media server to rescan - The FUSE layer presents each stub as a full-size virtual file
Step 3 is one HTTP call. Knowing what it does server-side is still worth it: it is what lets you tell a bad pick from a broken deployment.
What to report
Three things, and nothing else. Brevity is never the rule before something destructive: what Before deleting anything prescribes, the full list with a count and what could not be resolved, stands whole. Trimming a report costs the reader time; trimming the list they are about to approve costs them files.
The choice, when there is one. Once the candidates are scored, say what survived and ask which release to file. One line each: the release name as the indexer gives it, its size, its seeders, and whatever would change the decision (a cut that is not labelled, a swarm that has gone cold). Ask once, with the options and your recommendation. The candidates the gates rejected, the scoring behind the order and the searches that produced them are not part of the question.
One surviving candidate is still a choice: present it and wait. The confirmation is not about which release wins, it is about writing into a library that is not yours, and it is worth one line even when the answer looks obvious. A candidate that only just cleared the gates, or one you would not have picked yourself, is exactly the case where the operator wants to see it before it is filed.
The outcome, in one line. What add returned: the stub that was written, or
the error. Nothing about the calls that led there.
Anything that did not go as this file describes. An indexer suspended, every candidate rejected by the same gate, a release that turned out to be unreachable, a reply that does not match the documented one. Say what came back, as it came back.
A run that narrows thirty candidates to one is reported by the one. The log costs the reader the time the run was meant to save, and buries the two lines they need: what to choose, and what happened.
Everything you leave out you still keep: rejected candidates, individual scores, timings, raw responses. Hand any of it over the moment it is asked for, without making the operator ask twice.
Library API: the whole add in one call
Available from v1.9.64 on, on the control port. It does what the sync engine does for one title: registers the torrent, waits for the file list, picks the file, writes the stub with the deployment's own naming, registers TV episodes in the state DB and asks the media server to rescan. No filesystem access, no stub written by hand, no scan call of your own.
curl -s -X POST -H 'Content-Type: application/json' --max-time 120 \
-d '{"type":"movie","hash":"<40 hex>","title":"Dune Part Two","year":2024,
"release_title":"Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.Atmos-GRP",
"imdb":"tt15239678"}' \
"{CTRL}/api/library/add"
| Field | Meaning |
|---|---|
type | movie (default) or tv |
hash / magnet | one of the two. With hash alone the server builds the magnet with its own default tracker list; a magnet's own trackers are kept as they are |
title | display title, and the folder name for a series |
release_title | the raw release name; the quality tags in the filename (_DV, _Atmos, _REMUX) are read from here. Defaults to title, which loses them |
year / release_date | either; the year ends up in the filename. Movies only |
first_air_date | TV. The (YYYY) in the series folder comes from here and from nowhere else: without it the show lands in Series instead of Series (2024), which is a second entry in the media server |
imdb | written into a movie stub, and what bulk operations later filter on. Ignored for TV: episode stubs carry no id, because the webhook matcher pairs a Plex episode event with an open file by looking at the ones whose id is empty |
is_4k | overrides the resolution read from release_title |
season, episode | TV. season alone means "season pack": every file whose name carries SxxEyy is filed |
file_index | overrides the largest-video-file pick |
quality_score | stored in the TV registry, and what the next TV sync compares against. Left out it is zero, so the sync replaces the episode with the first release it scores above that. Pass the score you computed for the release you picked, the same number Score candidates produces |
metadata_wait | seconds to wait for the file list, default 60, capped at 300 |
Answers 201 with the stubs it created, or 200 with "already_present": true
when the release was already filed. --max-time has to exceed metadata_wait:
a cold swarm uses all of it.
{"hash":"...","title":"Dune Part Two","type":"movie","already_present":false,
"files":[{"path":"/mnt/torrserver/movies/Dune_Part_Two_2024_2160p_DV_Atmos_REMUX_deadbeef.mkv",
"fuse_path":"movies/Dune_Part_Two_2024_2160p_DV_Atmos_REMUX_deadbeef.mkv",
"size":68719476736,"file_index":2}]}
Failures say which half broke: 400 the request, 422 the torrent holds no
video file, 502 the engine refused it, 503 the state DB is unavailable (TV
only: an episode that cannot be registered would be deleted by the next sync),
504 no metadata within metadata_wait. Every failure removes the torrent it
added, so a failed call leaves nothing behind and can simply be retried.
What is already there
curl -s "{CTRL}/api/library/list?type=movie" | \
python3 -c 'import sys,json; [print(i["fuse_path"], i["hash"][-8:], i.get("imdb","")) for i in json.load(sys.stdin)]'
The slice is the half that appears in a movie filename; for type=tv print
i["hash"][:8] instead. The full hash is in the JSON either way, so script
against that rather than the fragment; when it is empty, only the path
identifies the entry.
Only movie, tv and gaps mean anything: any other value, a typo included,
silently falls back to the movie library, so a wrong word reads as a wrong
answer. Ask for the library you are about to write into.
One entry per stub, with size, hash, imdb and, for TV, season/episode.
This is the dedup check: it reads the filesystem server-side, which is the only
source that answers "will this look like a duplicate in Plex".
Removing
# take fuse_path from add or list
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"path":"movies/Title_2024_1080p_e7f8a9b0.mkv","blacklist":true}' \
"{CTRL}/api/library/remove"
{"hash":"<40 hex>"} works too and removes every stub of that release.
blacklist is the difference between a removal that sticks and one that does
not. With it, the release is recorded the way the FUSE unlink handler records
it, and the sync engines will not add the title back. Without it, they are free
to, which is what you want when removing only to make room for a better release.
The torrent behind a removed stub is dropped only when no other stub still points at it: one season pack is a single torrent behind many episodes, and removing one episode must not break the others.
What the library looks like
You do not write any of this, the server does. Knowing the shape is what lets
you read a list response and tell whether a title is already there.
movies/
└── <Title>_<Year>_<Resolution>[_<tags>]_<HASH8>.mkv (flat, imdb set)
tv/
└── <Series_Name> (<Year>)/
└── Season.NN/
└── <Series>_S<NN>E<XX>_<HASH8>.mkv (nested, imdb empty)
- Movies are flat, and the stub carries the IMDB id you passed
- The quality tags are exclusive pairs. A release tagged both DV and HDR
becomes
_DVonly, and_Atmoswins over_5.1. Do not expect_DV_HDR: raw release names in the library may carry both, but those are not names Tiramisu generated - TV is nested per series and season, the year comes from
first_air_date, and the stub'simdbis empty by convention. Passing one for a TV add is ignored on purpose: the webhook matcher pairs a Plex episode event with an open file by looking at the ones whose id is empty - HASH8 is 8 lowercase hex chars of the info hash, but not the same 8:
movies use the LAST 8, episodes the FIRST 8. That is what the server writes
today, not a rule every stub on disk obeys: legacy entries can carry the other
half. Match on the
hashfield of the entry, never on the filename fragment
Episode gaps (TV)
The TV reaper removes an episode when its release stops resolving its metadata
and a complete search finds nothing live to replace it. The hole is recorded,
not forgotten, and type=gaps is how you see it:
curl -s "{CTRL}/api/library/list?type=gaps" | \
python3 -c 'import sys,json; [print(g["show"], g["season"], g["episode_key"], g["dead_hash"][:8]) for g in json.load(sys.stdin)]'
The response is an array like the other types, but the objects are gaps, not
stubs: episode_key, show, season, show_imdb, path (where the stub was),
dead_hash, removed_at (unix seconds) and last_attempt, zero when the engine
has never tried that hole. It is capped at 500 entries and carries no total, so
treat a full page as "there may be more".
The engine retries the open gaps on its own. Every TV sync re-searches them,
skipping the ones younger than six hours, at most five shows per run, oldest
first, with a show whose resolution failed moved to the back of the queue. A
gap disappears as soon as the episode is back. Doing nothing is therefore a
valid outcome: an episode missing from list for a while is not a fault to
repair by hand.
What the skill is for here:
- explaining the disappearance: the episode was in a release whose swarm died, and no live release existed at that moment
- filling the hole on request: run the normal search, score and add flow for
a live release of that episode. Never re-file
dead_hash: it is the exact release the reaper discarded - checking, not editing: gaps ride on
list, there is no separate endpoint and nothing in the database should be touched
An episode that comes back gets a new HASH8, so Plex sees a new item and the watched/resume state for that episode does not carry over. That is the same trade-off as any upgrade, not a symptom of the repair.
Resolve the IMDB id first
Every search below is keyed on the IMDB id, and nothing in the flow will find it
for you. Resolve it through TMDB, the same way the sync engine does, using
tmdb_api_key from the config:
# 1. title + year -> TMDB id
curl -s "https://api.themoviedb.org/3/search/movie?api_key=$TMDB&query=Paris%2C%20Texas&year=1984"
# 2. TMDB id -> IMDB id
curl -s "https://api.themoviedb.org/3/movie/<tmdb_id>/external_ids?api_key=$TMDB" # -> imdb_id
For a series the second call is /tv/<tmdb_id>/external_ids.
Check the title and year that come back before using the id. Picking the
neighbouring result is easy and silent: tt0087884 is Paris, Texas and
tt0087889 is The Party Animal. Everything downstream will then quietly work on
the wrong film.
Search for candidates
Search by IMDB id, not by free text. Both indexers Tiramisu uses are keyed on it, and a title search is what makes generic one-word show names collect unrelated releases.
Prowlarr, through Tiramisu
For searching, go through Tiramisu: it already holds the credentials and exposes an endpoint that uses them, so there is no need to hunt for an API key.
That is the default, not an absolute. When the endpoint returns nothing and the
timing points at the deadline, calling Prowlarr directly with prowlarr.url and
prowlarr.api_key from the config is the correct move, and often the only way
to get the candidates at all. Use the key for that, never print it.
# --max-time 180: this endpoint routinely takes minutes, see below
curl -s --max-time 180 "{CTRL}/api/prowlarr/search?imdb_id=tt1234567&type=movie&title=Some%20Title&year=2024"
curl -s --max-time 180 "{CTRL}/api/prowlarr/search?imdb_id=tt1234567&type=series&title=Some%20Show"
imdb_idis required, everything else is optionaltypeismovie(the default) orseriestitleandyearare a secondary query for indexers that have no real IMDB search. Passyearfor movies; for a series it would be the year of season 1 and only hurts- the response is a JSON array of
{name, title, infoHash, behaviorHints}
An empty [] has three different causes, and the status code separates only
one of them. Do not read it as a single condition:
| What you get | What it means |
|---|---|
[], status 200 | Prowlarr is not configured on this deployment |
[], status 200 | every query ran and genuinely found nothing |
[], status 200 | one query answered with nothing while the others timed out |
HTTP 502, body prowlarr search failed: all N Prowlarr queries failed: ... | every query failed, usually context deadline exceeded |
The third row is the trap. The client reports an error only when all queries fail; if one answers, the search counts as completed even when the rest died on the deadline, so a half-broken search is indistinguishable from a real "nothing found". A well known title coming back empty is the symptom.
Give it minutes, not seconds
This endpoint is slow by construction, and a short client timeout is the most common way to misjudge it. Allow at least 180s before calling it broken.
The call has two phases and only the first is bounded:
- querying the indexers, capped at 45s
- resolving the info hashes, with no overall cap
Phase 2 exists because some indexers, 1337x among them, do not return an
infoHash inline: each such result needs a redirect followed through Prowlarr's
download proxy. That runs 5 at a time with a 20s budget each, so 40-odd results
needing resolution is minutes of legitimate work, not a hang.
Measured on a real deployment: HTTP 200 in 129.9s with 55 results, on the same
query where a direct Prowlarr search answered in 29.4s. The direct search is
faster because it does not resolve hashes at all — and those hashes are exactly
what you need to add anything. Faster there does not mean better.
A client timeout below the total is indistinguishable from a dead endpoint: you get no status and no body, and conclude the service is broken while it is still working. If you cut a call short, say so as "I did not wait long enough", never as "the endpoint does not respond".
Telling a timeout from an empty answer
Time the call. A genuine "nothing found" comes back quickly. An empty array that arrives at almost exactly 45s is phase 1 being cut off, not an answer.
curl -s -o /dev/null -w '%{time_total}s\n' --max-time 180 "{CTRL}/api/prowlarr/search?imdb_id=..."
Measured on the same deployment: a title with 57 results on Prowlarr came back as
[] after 45.024s. The indexers were healthy and answering; phase 1 was simply
cut off.
When the timing says deadline, query Prowlarr directly to confirm and to
recover the candidates. Take prowlarr.url and prowlarr.api_key from the
config for this:
curl -s -H "X-Api-Key: {prowlarr.api_key}" \
"{prowlarr.url}/api/v1/search?query=<title>&categories=2000"
Results there and none through the endpoint means the deadline, full stop: keep the direct results and say the endpoint timed out. Nothing in either place means the indexers really have nothing.
Either way fall through to Torrentio as well, and report which of the causes it was. Silently calling a timeout "no results" is how candidates get lost.
This endpoint queries Prowlarr and nothing else. It is not the same search the sync engine performs, so its result is not the full candidate set: to see what the engine would see, query Torrentio as well and merge the two lists yourself, deduplicating by info hash. A 4K release missing here is very often present on Torrentio.
Two things that mislead when reading the response:
nameis literally"Torrentio\n<resolution>"even for Prowlarr results. It is a Stremio format label, not the source. Torrentio has NOT been consulted- the 45 second deadline applies to the indexer queries only, not to the whole call, which routinely runs far longer. The same query can return a different number of results minute to minute, so few results is not proof that few exist
title is a multi-line string, and the extra lines are where seeders and size
live. Scoring reads the whole thing, so keep it intact rather than splitting off
the first line:
Brazil 1985 DC 4K HDR DV 2160p BDRemux Ita Eng x265 NAHOM
👤 2 ⬇️ 10
💾 83.24GB
Seeders are the number after the 👤 emoji (the engine matches exactly that).
name is not the release name: it carries the indexer and a resolution tag,
for example Torrentio\n4k.
Torrentio, directly
Torrentio needs no credentials. Take the base URL from the config
(torrentio_url, default https://torrentio.strem.fun) and query by IMDB id:
curl -s "{TORRENTIO}/{config}/stream/movie/tt1234567.json"
curl -s "{TORRENTIO}/{config}/stream/series/tt1234567:2:5.json" # season 2, episode 5
The {config} segment is the filter string the sync engine uses,
sort=qualitysize|qualityfilter=480p,720p,scr,cam.
The numbers are in title, not in name. Torrentio answers
{"streams":[...]}, and each stream carries the release name on the first line
of title, the counters on the second and, for some indexers, flags on a third.
name holds the indexer and the resolution tag, never the seeders or the size.
Parse the fields, do not eyeball them:
curl -s "{TORRENTIO}/{config}/stream/movie/tt1234567.json" | python3 -c '
import sys, json, re
for x in json.load(sys.stdin).get("streams", []):
t = x.get("title", "")
name = t.split("\n")[0]
seeders = int(re.search(r"\U0001F464 (\d+)", t).group(1)) if re.search(r"\U0001F464 (\d+)", t) else 0
size = float(re.search(r"([0-9.]+) GB", t).group(1)) if re.search(r"([0-9.]+) GB", t) else 0.0
print(f"{seeders:4d} {size:6.2f}GB {x.get('infoHash','')} {name[:70]}")
'
The same shape comes back from /api/prowlarr/search, which formats its results
the Torrentio way on purpose, so one parser serves both. A stream whose size is
reported in MB rather than GB is not a video file.
How the sync engine combines them
Worth mirroring, because it is not a fallback chain: the engine queries Prowlarr and Torrentio both, concatenates the results, deduplicates by info hash, and only then filters and scores. A search counts as failed only when every indexer failed; if Prowlarr is simply not configured, that is not a failure and Torrentio alone carries the run.
Finally, prefer a full H.264 season pack over per-episode x265 releases when the client cannot decode HEVC natively (no-transcode playback).
Score candidates
Never hardcode weights. Scoring is per-deployment configuration: the engine
reads the quality_scoring block of its config, and any value may have been
tuned by the operator. Fetch the live profile first:
python3 resolve_deployment.py # media server, library ids, scoring profile
When quality_scoring is absent from the config, no profile was configured and
the engine's own built-in defaults apply; the script says so explicitly rather
than guessing numbers.
How the score is composed
The shape of the formula is stable even though the numbers are not. For a movie candidate, from the release title plus its seeders and size:
- resolution:
res_4kif the release is 4K, otherwiseres_1080p - dynamic range:
dolby_visionorhdr— mutually exclusive, DV wins - audio:
atmosoraudio_5_1orstereo_penalty— first match only, andstereo_penaltyis negative remuxif the release is a remuxpreferred_languageif the title matches the configured preferred terms (language.preferred_termsin the same config)unknown_size_4k_penaltywhen the indexer reported no size and the release is 4K- seeders, capped at
seeder_cap
Note steps 2 and 3: they are either/or, not additive. A DV+HDR release does not collect both bonuses.
Rejection gates, in the order the engine applies them
- garbage release tags: camrip, hdcam, hdts, telesync, TS, telecine, TC, SCR, screener, webscreener
- excluded language matched in the title, from
language.excluded_flags(see What the language gate actually does) - blacklisted title, then blacklisted hash — the one gate that does not apply to you, see below
- seeders below
min_seeders - resolution neither 4K nor 1080p — anything else is rejected outright, there is no 720p path
- size out of band, and the two resolutions differ here: a 4K release with
an unknown size (0) is ACCEPTED and merely takes
unknown_size_4k_penalty, while a 1080p release with an unknown size is REJECTED - final score <= 0
The blacklist is not one of your gates. It exists so the unattended sync does not keep re-proposing titles the operator threw out, night after night, with nobody there to say no. You are the opposite case: someone asked for this title, now, and that request outranks a standing rule written for decisions made without anyone watching. File what was asked for. You cannot read the blacklist over the API anyway, and you do not need to.
The size bands are calibrated for 2h+ features: for shorter content (<=2h docs, live shows) they may reject legitimate encodes, so relax the band manually for those and say so when reporting what was chosen.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 141
- Forks
- 14
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
tiramisu-manual-content-add- Source
- github.com/mrrobotogit/tiramisu