Tiramisu Manual Content Addition

SkillFiles & storage

Use 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.

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.

NameWhere it comes fromExample
CTRLthe operator, or the default http://127.0.0.1:9080control 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.

  1. Read the deployment's own scoring profile from the config API
  2. Pick a release, which is the part nobody can do for you
  3. 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
  4. 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"
FieldMeaning
typemovie (default) or tv
hash / magnetone 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
titledisplay title, and the folder name for a series
release_titlethe raw release name; the quality tags in the filename (_DV, _Atmos, _REMUX) are read from here. Defaults to title, which loses them
year / release_dateeither; the year ends up in the filename. Movies only
first_air_dateTV. 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
imdbwritten 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_4koverrides the resolution read from release_title
season, episodeTV. season alone means "season pack": every file whose name carries SxxEyy is filed
file_indexoverrides the largest-video-file pick
quality_scorestored 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_waitseconds 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 _DV only, and _Atmos wins 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's imdb is 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 hash field 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_id is required, everything else is optional
  • type is movie (the default) or series
  • title and year are a secondary query for indexers that have no real IMDB search. Pass year for 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 getWhat it means
[], status 200Prowlarr is not configured on this deployment
[], status 200every query ran and genuinely found nothing
[], status 200one 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:

  1. querying the indexers, capped at 45s
  2. 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:

  • name is 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:

  1. resolution: res_4k if the release is 4K, otherwise res_1080p
  2. dynamic range: dolby_vision or hdr — mutually exclusive, DV wins
  3. audio: atmos or audio_5_1 or stereo_penalty — first match only, and stereo_penalty is negative
  4. remux if the release is a remux
  5. preferred_language if the title matches the configured preferred terms (language.preferred_terms in the same config)
  6. unknown_size_4k_penalty when the indexer reported no size and the release is 4K
  7. 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

  1. garbage release tags: camrip, hdcam, hdts, telesync, TS, telecine, TC, SCR, screener, webscreener
  2. excluded language matched in the title, from language.excluded_flags (see What the language gate actually does)
  3. blacklisted title, then blacklisted hash — the one gate that does not apply to you, see below
  4. seeders below min_seeders
  5. resolution neither 4K nor 1080p — anything else is rejected outright, there is no 720p path
  6. 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
  7. 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