Instalá qBittorent: sudo apt install -y qbittorrent-nox o qbittorrent.
Abrí qbittorrent, view -> search engine. Ahora movete a la pestaña Search y abajo a la derecha dale a search plugins -> install a new one -> web link y pegá: https://raw.githubusercontent.com/qbittorrent/search-plugins/master/nova3/engines/jackett.py
Luego andá a ~/.local/share/qBittorrent/nova3/engines/ o seguí este tutorial: https://github.com/qbittorrent/search-plugins/wiki/How-to-configure-Jackett-plugin
{
"api_key": "YOUR_API_KEY_HERE",
"url": "http://127.0.0.1:9117",
"tracker_first": false,
"thread_count": 20
}
La API key lo copias de https://localhost:9117
Abrí Jackett y agregá todos los indexers publicos seleccionando uno por uno el checkbox de la izquierda.
Finalmente abrí qbittorrent andate a Search y buscá el torrent que quieras.
Podés descargarte qbittorrent-nox para y agregar .config/qBittorrent/qBittorrent.conf debajo de [Preferences]:
WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZaQsuDHbIMCA==:0WMRkYTUWVT9wVvdDtHAjU9b3b7uB8NR1Gur2hmQCvCDpm39Q+PsJRJPaCU51dEiz+dTzh8qbPsL8WkFljQYFQ==)"
WebUI\Port=8080
WebUI\Username=admin
WebUI\LocalHostAuth=false
Que establece la contraseña por defecto como adminadmin para no tener que estar copiando y pegando la contraseña temporal que te genera cada vez que abrís el programa.
AUR:yay -S jackett-bin
sudo systemctl start jackett
Y sumado a eso usá opencode para complementar con jackett y qbitorrent-nox ;)
Programame un script de bash
Chequea que qbittorrent-nox e jackett esten instalados. De no estarlo adverti al usuario.
inicia qbittorrent-nox en 2do plano ejecutando qbittorrent-nox (configura la contraseña adminadmin),
agregar $HOME/.config/qBittorrent/qBittorrent.conf debajo de [Preferences]: (si es que ya no está)
WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZaQsuDHbIMCA==:0WMRkYTUWVT9wVvdDtHAjU9b3b7uB8NR1Gur2hmQCvCDpm39Q+PsJRJPaCU51dEiz+dTzh8qbPsL8WkFljQYFQ==)"
Usa curl -s -X POST “http://127.0.0.1:8080/api/v2/auth/login” -d “username=admin&password=adminadmin” -c /tmp/qbt_cookies.txt && echo "" para comunicarte con qbittorrent-nox
y el servicio de jackett con systemd usando sudo systemctl start jackett descargame el torrent de jason vs freddie tenga muchos seeds hace una busqueda generica y despues filtra los resultados, bajame la calidad de video mas baja que encuentres y abrilo con mpv :) el servicio ya esta activo localmente en
el puerto 9117 y la api key esta en $HOME/.local/share/qBittorrent/nova3/engines/jackett.json usa qbittorrent-nox adminadmin es la contraseña agrega el torrent usando qbittorrent-nox con con curl -s -X POST “http://127.0.0.1:8080/api/v2.0/torrents/add” -d “urls=magnet:?xt=XXXXXXXXX” usa la guid que empieza con magnet:? al buscar en jackett These are magnet links stored in the Guid field. Let me use those magnet links directly with qBittorrent. busca en todos los indexers. http://127.0.0.1:9117/api/v2.0/indexers/all/
para buscar algo:
$ curl -s “http://127.0.0.1:9117/api/v2.0/indexers/all/results?Query=formula+1&apikey=xxxx” 2>&1 | jq ‘.’ 2>/dev/null | head -80
Y luego escribime un script de bash para automatizar todo esto.
Si se pone estúpido reinicialo.
0magnet: Needs FlareSolverr 1337x: Needs FlareSolverr 52bt: Needs FlareSolverr
El script que me generó opencode todavía tengo que arreglarlo cuando lo tenga, lo publico acá.
Los torrents se guardan en /var/lib/qbittorrent/Downloads
Despues de que Claude AI me genere ese script fenomenal le pedi esto:
Me encanto, aun asi cuando abria el video con mpv parece que abria el directorio entero en vez de abrir el video que habia descargado, fuera de eso que no es tanta complicacion. Lo que me gustaria es que me de a elegir ahora quiero que sea mas como un buscador pero ordenalos por los que tienen mas seeds primero y que sean recientes, y eso de 720 o 1080 no lo tomes en consideracion porque a veces yo quiero descargar archivos o audios que no son videos. No se si me entendes, ahora quiero algo mas como un buscador eficiente quiero descargar algo que tenga muchos seeds sino no funciona y quiero poder elegir que me de una lista de torrents y yo elijo cual quiero descargar.
Está la lista de indexs si es que los activaste uno por uno guardalos para no tener que volver a hacerlo.
#!/usr/bin/env bash
set -uo pipefail
JACKETT_URL="http://127.0.0.1:9117"
INDEXERS_DIR="${INDEXERS_DIR:-/var/lib/jackett/Indexers}"
JACKETT_KEY_FILE="$HOME/.local/share/qBittorrent/nova3/engines/jackett.json"
QBT_URL="http://127.0.0.1:8080"
DOWNLOAD_DIR="${DOWNLOAD_DIR:-/mnt/linux2/torrents}"
RESULTS_TMP="/tmp/torrent_menu_results.json"
TOP_RESULTS=20
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
die() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; }
human_size() {
local n=$1
if [ "$n" -ge 1099511627776 ]; then awk "BEGIN{printf \"%.2f TB\", $n/1099511627776}"
elif [ "$n" -ge 1073741824 ]; then awk "BEGIN{printf \"%.2f GB\", $n/1073741824}"
elif [ "$n" -ge 1048576 ]; then awk "BEGIN{printf \"%.1f MB\", $n/1048576}"
elif [ "$n" -ge 1024 ]; then awk "BEGIN{printf \"%.0f KB\", $n/1024}"
else echo "${n} B"; fi
}
get_apikey() {
jq -r '.api_key // empty' "$JACKETT_KEY_FILE" 2>/dev/null
}
list_indexers() {
[ -d "$INDEXERS_DIR" ] || warn "No se pudo leer $INDEXERS_DIR"
ok "Indexers configurados:"
ls "$INDEXERS_DIR"/*.json 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/\.json$//' | sort | nl
exit 0
}
resolve_indexer() {
local want="$1" wlower id ilower found=""
wlower=$(printf '%s' "$want" | tr '[:upper:]' '[:lower:]')
for f in "$INDEXERS_DIR"/*.json; do
[ -e "$f" ] || continue
id=$(basename "$f" .json)
[ "$id" = "$want" ] && { echo "$id"; return 0; }
[ -z "$found" ] && [ "$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]')" = "$wlower" ] && found="$id"
done
if [ -n "$found" ]; then echo "$found"; return 0; fi
return 1
}
search_jackett() {
local query="$1" idx="${2:-all}" apikey total encoded
apikey=$(get_apikey)
[ -n "$apikey" ] || die "No se encontró API key en $JACKETT_KEY_FILE"
encoded=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$query")
info "Consultando Jackett indexer: $idx ..."
curl -s -m 300 -f "${JACKETT_URL}/api/v2.0/indexers/${idx}/results?Query=${encoded}&apikey=${apikey}" > "$RESULTS_TMP" \
|| die "Jackett no respondió. ¿Está corriendo en $JACKETT_URL?"
total=$(jq '.Results | length' "$RESULTS_TMP" 2>/dev/null)
[ "$total" -gt 0 ] 2>/dev/null || die "Sin resultados para '$query'."
ok "Resultados: $total"
tracker_summary
}
tracker_summary() {
local line
echo -e "${CYAN} Por indexer:${RESET}"
while IFS=$'\t' read -r tr c; do
printf " %-22s %s\n" "$tr" "$c"
done < <(jq -r '[.Results[].Tracker] | group_by(.) | map("\(.[0])\t\(length)") | .[]' "$RESULTS_TMP" 2>/dev/null \
| sort -t $'\t' -k2 -rn)
}
build_list() {
jq -r --argjson top "$TOP_RESULTS" --arg query "$QUERY" '
def tlow: (.Title // "" | ascii_downcase);
def qtok: ($query // "" | ascii_downcase | split(" ") | map(select(length > 0)));
def all_hit: [qtok[] as $tok | ((tlow) | contains($tok))] | all;
def ds: .PublishDate // "" | strptime("%Y-%m-%dT%H:%M:%S")? | mktime // 0;
def skey: [-(.Seeders // 0), -ds];
(.Results
| map(select(
(((.Guid | startswith("magnet:?")) or ((.Link // "") | startswith("http")))
and (all_hit))))) as $c
| (($c | group_by(.Tracker) | length) | if . > 0 then . else 1 end) as $nt
| (((($top + $nt - 1) / $nt) | floor)) as $per
| $c
| group_by(.Tracker)
| map(sort_by(skey) | .[0:$per])
| add // []
| sort_by(skey)[0:$top][]
| [ 0, # placeholder, se numera en show_menu
(.Seeders // 0),
(.Size // 0),
(.Title // "" | gsub("[\n\t]";" ")),
(.IndexerError // .Tracker // ""),
.Guid,
.Link,
(.PublishDate // "" | split("T")[0]) ]
| @tsv
' "$RESULTS_TMP"
}
show_menu() {
echo ""
echo -e "${BOLD}Resultados (más seeds primero; solo lo que coincide con la búsqueda):${RESET}"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
local i=0
while IFS=$' ' read -r _x seeds size title idxr guid link pdate; do
i=$((i+1))
ttype="[m]"
[[ "$link" == "http"* ]] && ttype="[t]"
d=${pdate:0:10}
printf "%2s │ %-4s│ %10s │ %-60s %s %s %s
" "$i" "$seeds seeds" "$(human_size "$size")" "${title:0:60}" "[$idxr]" "$ttype" "[${d:2}]"
done < <(build_list)
echo -e "${CYAN}------------------------------------------------------------${RESET}"
}
pick_magnet() {
local choice
local n
n=$(build_list | wc -l)
[ "$n" -eq 0 ] && warn "Sin resultados descargables (magnet o .torrent)." && return 1
read -rp "Número a descargar (Enter=salir, 0=volver a buscar): " choice
[ -z "$choice" ] && echo "cancelado" && return 1
[ "$choice" = "0" ] && return 2
if [ "$choice" -ge 1 ] 2>/dev/null && [ "$choice" -le "$n" ] 2>/dev/null; then
MAGNET=$(build_list | sed -n "${choice}p" | cut -f6)
TORRENT_LINK=$(build_list | sed -n "${choice}p" | cut -f7)
TITLE=$(build_list | sed -n "${choice}p" | cut -f4)
ok "Elegido: ${TITLE:0:80}"
return 0
fi
warn "Opción inválida"
return 1
}
add_torrent() {
local source_url="$MAGNET"
if [[ "$MAGNET" == "http"* ]]; then
source_url="$TORRENT_LINK"
fi
info "Agregando a qBittorrent (destino: $DOWNLOAD_DIR)..."
curl -sf -X POST "${QBT_URL}/api/v2/torrents/add" \
--data-urlencode "urls=$source_url" \
-d "savepath=$DOWNLOAD_DIR" \
-d "category=menu" \
-w "" -o /dev/null || curl -s -X POST "${QBT_URL}/api/v2/torrents/add" \
--data-urlencode "urls=$source_url" \
-d "savepath=$DOWNLOAD_DIR" \
-d "category=menu"
ok "Torrent agregado. Ver estado en $QBT_URL o corre: torrent_menu.sh --estado"
}
show_status() {
echo -e "${BOLD}Estado de descargas:${RESET}"
curl -sf "${QBT_URL}/api/v2/torrents/info?category=menu" | jq -r '.[] | " [\(.state)] \(.progress*100|floor)%% \(.name[0:60])"' 2>/dev/null \
|| echo " (sin torrents o qBittorrent sin respuesta)"
}
usage() {
echo "Uso:"
echo " $0 \"nombre\" Buscar en TODOS los indexers (puede tardar 1-2 min)"
echo " $0 --indexer NOMBRE \"q\" Buscar solo en UN indexer (no sensible a mayúsculas)"
echo " $0 --list-indexers Listar indexers configurados"
echo " $0 --estado Ver estado de descargas"
echo " $0 --servicios Verificar servicios (jackett, qbittorrent-nox)"
exit 0
}
check_services() {
info "jackett: $(systemctl is-active jackett 2>/dev/null || echo inactivo)"
info "qbittorrent: $(systemctl is-active qbittorrent-nox 2>/dev/null || echo inactivo)"
info "ruta destino: $DOWNLOAD_DIR"
}
case "${1:-}" in
--estado) show_status; exit 0 ;;
--servicios) check_services; exit 0 ;;
--help|-h) usage ;;
--list-indexers) list_indexers ;;
--indexer)
[ $# -ge 3 ] || die "Uso: $0 --indexer NOMBRE \"búsqueda\""
IDX_RESOLVED=$(resolve_indexer "${2:-}") || die "Indexer '${2:-}' no encontrado. Usa: $0 --list-indexers"
IDX="${IDX_RESOLVED}"
QUERY="$3"
;;
"")
read -rp "¿Qué querés buscar? " QUERY
[ -n "$QUERY" ] || die "Búsqueda vacía."
;;
*) QUERY="$*" ;;
esac
check_services
if [ -n "${IDX:-}" ]; then
info "Buscando '$QUERY' SOLO en indexer: $IDX"
INDEXER_PATH="$IDX"
else
warn "Buscando en TODOS los indexers, puede tardar 1-2 min..."
INDEXER_PATH="all"
fi
search_jackett "$QUERY" "$INDEXER_PATH"
while true; do
show_menu
pick_magnet
rc=$?
if [ "$rc" -eq 2 ]; then
read -rp "Nueva búsqueda: " QUERY; search_jackett "$QUERY" "$INDEXER_PATH"; continue
fi
[ "$rc" -eq 0 ] || exit 0
add_torrent
echo ""
read -rp "¿Descargar otro? [s/N]: " again
[[ "$again" =~ ^[sSyY] ]] || break
done
ok "Listo."
#!/usr/bin/env python3
"""
Buscador de torrents: Jackett -> qBittorrent.
Qué hace bien (a diferencia de la versión anterior):
* Las consultas se derivan de TU búsqueda (antes estaban hardcodeadas a "Malaysia/Sepang").
* Filtra basura: "Pre/Post Qualifying Show", "Pre Race Show", highlights, reports, etc.
* Filtra por sesión: si pedís "qualifying", fuera practice / sprint / race / lights.
* Dedup entre trackers: el mismo release en 3 fuentes aparece una sola vez.
* Si el filtro se come todo, te dice qué descartó y te deja ver los ocultos.
Uso:
torrent_menu.py "formula 1 2026 Bahrain"
torrent_menu.py "formula 1 2026 Bahrain qualifying"
torrent_menu.py --session qualifying "bahrain f1 2026"
torrent_menu.py --no-filter -1 "formula 1 2026 bahrain" # solo listar
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
try:
import requests
except Exception:
print("Falta requests: pip install requests", file=sys.stderr)
sys.exit(1)
JACKETT_URL = os.environ.get("JACKETT_URL", "http://127.0.0.1:9117")
JACKETT_KEY_FILE = Path(
os.environ.get(
"JACKETT_KEY_FILE",
str(Path.home() / ".local/share/qBittorrent/nova3/engines/jackett.json"),
)
)
QBT_URL = os.environ.get("QBT_URL", "http://127.0.0.1:8080")
QBT_COOKIE = os.environ.get("QBT_COOKIE", "") # opcional: cookie de sesión de qBittorrent
DOWNLOAD_DIR = os.environ.get("DOWNLOAD_DIR", "/mnt/linux2/torrents")
MAX_RESULTS = int(os.environ.get("MAX_RESULTS", "500"))
MAX_QUERIES = int(os.environ.get("MAX_QUERIES", "10"))
FILTER_JUNK = os.environ.get("FILTER_JUNK", "1") not in ("0", "false", "no", "")
COL_RESET = "\033[0m"
COL_BOLD = "\033[1m"
COL_DIM = "\033[2m"
COL_CYAN = "\033[0;36m"
COL_GREEN = "\033[0;32m"
COL_YELLOW = "\033[1;33m"
COL_RED = "\033[0;31m"
YEAR_RE = re.compile(r"\b(?:19|20)\d{2}\b")
BRACKET_RE = re.compile(r"[\[\(\{][^\]\)\}]*[\]\)\}]")
NUM_RE = re.compile(r"\d+")
# Tags de calidad que se repiten idénticos en todos los trackers: para el dedup
# son ruido, pero NO para los filtros (720p vs 1080p son releases distintos).
DEDUP_NOISE = {
"ss", "subs", "sub", "es", "sp", "lat", "eng", "en", "web", "webrip", "webdl",
"h264", "h265", "x264", "x265", "hevc", "avc", "aac", "bluray", "webrip", "ddp",
"repack", "proper", "multi", "dual", "ita", "spa", "castellano", "espanol",
}
# Dos releases con título equivalente se consideran el mismo si el tamaño
# difiere menos de este porcentaje (mismo archivovu en trackers distintos).
SIZE_TOLERANCE = 0.03
# Margen antes del 1/1 del año pedido para usar PublishDate en títulos sin año.
STALE_DAYS = 60
# Palabras clave por sesión del fin de semana
SESSION_PATTERNS = {
"practice": r"\b(?:practice|practicas|entrenamiento|free\s*practice|fp\s*[123])\b",
"qualifying": r"\b(?:qualifying|qualification|quali|clasificaci[oó]n|q[1-3]?)\b",
"sprint": r"\b(?:sprint)\b",
"race": r"\b(?:race|carrera|grand\s*prix)\b",
"lights": r"\b(?:lights)\b",
}
SESSION_TERMS = {
"practice": ["Practice"],
"qualifying": ["Qualifying"],
"sprint": ["Sprint", "Sprint Qualifying"],
"race": ["Race", "Grand Prix"],
"lights": ["Lights", "F1 Lights"],
}
# Orden por defecto del fin de semana, para los reintentos automáticos.
SESSION_ORDER = ["qualifying", "practice", "race", "sprint", "lights"]
# Releases que NO son la sesión en sí (shows, resúmenes, entrevistas...)
SHOW_RE = re.compile(
r"\b(?:show|shows|highlight|highlights|resumen|analisis|analysis|reaction|reactions|"
r"interview|interviews|press\s*conference|race\s*report|qualifying\s*report|roundup|"
r"documentary|feature|promo|trailer|rehearsal|walkthrough|podcast|rundown|replay)\b"
)
# Tests de pretemporada: tampoco son sesiones de un GP.
PRESEASON_RE = re.compile(r"\bpre\s*season\b|\btesting\b|\btest\s*(?:session|day)\b")
F1_RE = re.compile(r"\b(?:f1|f 1|formula\s*1|formula1|grand\s*prix)\b")
# Circuitos cuyo nombre choca con otros deportes
CIRCUITS = {
"bahrain", "monza", "silverstone", "spa", "interlagos", "imola", "spa francorchamps",
}
# "pre ..." / "post ..." (con espacio o guion). El lookahead evita falsos positivos tipo "pressure".
PREPOST_RE = re.compile(r"\b(?:pre|post)[\s\-_]+")
PREPOST_GLUE_RE = re.compile(
r"\b(?:pre|post)(?=qualifying|quali|practice|race|sprint|gp|show|session|lights)\b"
)
def norm_ws(s):
return " ".join((s or "").split())
def log_info(msg):
print(f"{COL_CYAN}[INFO]{COL_RESET} {msg}")
def log_ok(msg):
print(f"{COL_GREEN}[OK]{COL_RESET} {msg}")
def log_warn(msg):
print(f"{COL_YELLOW}[WARN]{COL_RESET} {msg}")
def log_err(msg):
print(f"{COL_RED}[ERROR]{COL_RESET} {msg}", file=sys.stderr)
def human_size(n):
try:
n = int(n or 0)
except Exception:
n = 0
if n >= 1 << 40:
return f"{n / (1<<40):.2f} TB"
if n >= 1 << 30:
return f"{n / (1<<30):.2f} GB"
if n >= 1 << 20:
return f"{n / (1<<20):.1f} MB"
if n >= 1 << 10:
return f"{n / (1<<10):.0f} KB"
return f"{n} B"
def get_apikey():
try:
with open(JACKETT_KEY_FILE) as f:
k = json.load(f).get("api_key")
if k:
return k
except Exception:
pass
return None
# ---------------------------------------------------------------- consultas
def detect_session(text):
"""Detecta la sesión pedida en el texto de la búsqueda (o None)."""
nt = norm_title(text)
for key in ("qualifying", "practice", "sprint", "race", "lights"):
if re.search(SESSION_PATTERNS[key], nt):
return key
return None
def build_queries(query, session=None):
"""
Genera variantes a partir de la query del usuario. Ninguna queda hardcodeada:
los indexers hacen AND de palabras y cada uno indexa distinto orden, así que
probamos las permutaciones de [lugar] [año] [sesión] que más se usan.
"""
q = norm_ws(query)
queries = []
seen = set()
def add(*parts):
x = norm_ws(" ".join(p for p in parts if p))
if x and x.lower() not in seen:
seen.add(x.lower())
queries.append(x)
add(q)
# "Formula 1" -> "F1" (los indexers indexan cualquiera de las dos formas)
f1 = norm_ws(re.sub(r"\bformula\s*1\b", "F1", q, flags=re.IGNORECASE))
add(f1)
m = YEAR_RE.search(q)
year = m.group(0) if m else ""
base = norm_ws(YEAR_RE.sub(" ", f1)) # el resto, sin año
# Sin sesión: con año en las dos posiciones y sin año.
if not session:
add(year, base)
add(base, year)
add(base)
return queries[:MAX_QUERIES]
for term in SESSION_TERMS.get(session, [session]):
# Si el usuario ya escribió la palabra de sesión, no la repetimos
# ("bahrain qualifying Qualifying" no matchea nada en los indexers).
if re.search(r"\b" + re.escape(term) + r"\b", base, flags=re.IGNORECASE):
add(year, base)
add(base, year)
add(base)
continue
add(year, base, term)
add(base, year, term) # <-- el orden que mejor funciona en LimeTorrents/TD
add(term, base, year)
add(base, term, year)
add(base, term)
add(term, base)
return queries[:MAX_QUERIES]
# ---------------------------------------------------------------- búsqueda
def fetch_results(queries, indexer="all"):
apikey = get_apikey()
if not apikey:
log_err(f"No se encontró API key en {JACKETT_KEY_FILE}")
sys.exit(1)
raw = []
for qv in queries:
try:
enc = urllib.parse.quote(qv)
url = f"{JACKETT_URL}/api/v2.0/indexers/{indexer}/results?Query={enc}&apikey={apikey}"
r = requests.get(url, timeout=300)
r.raise_for_status()
res = (r.json() or {}).get("Results") or []
if res:
log_info(f"'{qv}': {len(res)} resultados")
raw.extend(res)
except Exception as e:
log_warn(f"Error con '{qv}': {e}")
return raw
def parse_dt(s):
"""PublishDate de Jackett -> timestamp UTC (segundos)."""
if not s:
return 0
try:
s = str(s).strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
except Exception:
pass
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return int(
datetime.strptime(str(s)[: len(fmt) + 2].strip(), fmt)
.replace(tzinfo=timezone.utc)
.timestamp()
)
except Exception:
continue
return 0
def norm_title(t):
"""
Normaliza un título a minúsculas sin símbolos, conservando el texto entre
paréntesis/corchetes. Importante: '1080p' vs '720p' deben seguir siendo
títulos distintos, y '(Inc Pre & Post Sessions)' tiene que quedar visible
para que el filtro lo pueda leer.
"""
return norm_ws(re.sub(r"[^a-z0-9]+", " ", (t or "").lower()))
def dedup_title(t):
"""
Título para comparar releases iguales entre trackers. Además quita los tags
que repiten todos igual ('SS', 'WEB-DL', '-GRP') para que un mismo release
coming de trackers distintos caiga en el mismo bucket.
"""
t = norm_title(t)
words = [w for w in t.split() if w not in DEDUP_NOISE]
return " ".join(words) or t
def magnet_of(r):
"""
El magnet puede venir en Guid, MagnetUri o incluso en Link (algunos indexers
lo ponen ahí). Devuelve '' si no hay ninguno.
"""
for field in ("Guid", "MagnetUri", "Link"):
v = (r.get(field) or "").strip()
if v.startswith("magnet:?"):
return v
return ""
def is_magnet(r):
return bool(magnet_of(r))
def torrent_type(r):
if is_magnet(r):
return "[m]"
return "[t]" if (r.get("Link") or "").startswith("http") else "[?]"
def dedup_results(raw):
"""
Quita duplicados. El mismo release aparece en LimeTorrents, Torrent Downloads y TBP
con distinto Link/Guid, así que la clave es título normalizado + tamaño.
"""
def rank(x):
# A igualdad de seeds preferimos el magnet (más confiable para qBittorrent)
# y luego el más reciente.
return (x.get("Seeders") or 0, 1 if is_magnet(x) else 0, parse_dt(x.get("PublishDate")))
# 1) Agrupar por título normalizado (ignorando tags de calidad/common).
groups = {}
for r in raw:
groups.setdefault(dedup_title(r.get("Title")), []).append(r)
# 2) Dentro de cada grupo, separar por tamaño: releases distintos (1080p vs
# 2160p, o dos grupos distintos) tienen tamaño claramente diferente.
kept = []
for items in groups.values():
clusters = [] # [tamaño_representativo, [items]]
for r in items:
size = int(r.get("Size") or 0)
for c in clusters:
ref = c[0] or size
if ref and size and abs(size - ref) / max(ref, size) <= SIZE_TOLERANCE:
c[1].append(r)
break
if not ref and not size:
c[1].append(r)
break
else:
clusters.append([size, [r]])
kept.extend(max(c[1], key=rank) for c in clusters)
return kept, len(raw) - len(kept)
def title_years(text):
"""
Años que aparecen en un título. Tiene en cuenta los pegados tipo
'F1.20130420.Bahrain.Qualifying' (yyyymmdd) y los sueltos ('F1 2019 R02').
Ignora resoluciones: '1080p', '2160p', '720p' no cuentan como año.
Ojo: se busca sobre el título CRUDO, no el normalizado, porque norm_title
borra lo que va entre paréntesis/corchetes y ahí suelen ir los años
('F1 ( 2023 ) Round 01 Bahrain').
"""
years = []
for tok in NUM_RE.findall(text or ""):
if re.fullmatch(r"(?:19|20)\d{2}", tok):
years.append(tok)
elif len(tok) == 8 and re.match(r"(?:19|20)\d{2}", tok):
years.append(tok[:4]) # yyyymmdd
return years
def query_terms(query):
"""
Palabras clave de la query, sin relleno y normalizando sinónimos, para
verificar que el release sea de lo que se pidió: buscando 'bahrain' aparecen
'Australia vs Bahrain' (fútbol) y no debería entrar.
Devuelve (grupos_de_sinónimos, obligatorio), donde cada grupo es una lista de
formas aceptadas y 'obligatorio' es lo que tiene que aparecer sí o sí.
"""
nt = norm_title(query)
# 'formula 1' / 'formula1' / 'f1' son lo mismo: un solo grupo de sinónimos.
if re.search(r"\b(?:f1|formula\s*1|formula1)\b", nt):
nt = re.sub(r"\b(?:f1|formula\s*1|formula1)\b", "f1", nt)
obligatory = ["f1"]
else:
obligatory = []
groups = []
for w in nt.split():
if len(w) <= 2 or re.fullmatch(r"(?:19|20)\d{2}", w):
continue # 'de', 'gp', el año: no sirven para decidir relevancia
groups.append([w])
return obligatory, groups
def irrelevant_reason(title, terms):
"""
None si el release coincide con la query; si no, el motivo.
Se exige que el release sea de F1 Y mentione el circuito pedido: así
'Australia vs Bahrain' (fútbol) no entra en una búsqueda de F1 Bahrain.
"""
obligatory, groups = terms
nt = norm_title(title)
# 'F1' tiene que estar sí o sí (si la query lo pedía).
if obligatory and not F1_RE.search(nt):
# Único circuito + nada de F1: es de otro deporte.
if CIRCUITS.intersection(nt.split()):
return "no parece F1"
return "no menciona f1"
missing = [
group[0]
for group in groups
if not any(re.search(r"\b" + re.escape(v) + r"\b", nt) for v in group)
]
if not missing:
return None
return f"no menciona {', '.join(missing[:2])}"
def junk_reason(title, session=None, year=None, publish_date=None, terms=None):
"""Motivo por el que un release se descarta, o None si nos interesa."""
nt = norm_title(title)
if terms and not session:
# Relevancia: ¿el release es de lo que se pidió?
why = irrelevant_reason(title, terms)
if why:
return why
# Año equivocado: "F1 2009 ... Qualifying" cuando pedimos 2026.
# Solo si el título trae un año explícito; si no, no lo descartamos.
if year:
found = title_years(title)
if found and all(y != year for y in found):
return f"año {found[0]} (pedimos {year})"
# Sin año en el título (TVRips viejos: "Gran Premio F1 Bahrain.mkv"):
# usamos la fecha de publicación, con margen para no castigar los
# releases subidos semanas antes del GP.
if not found and publish_date:
try:
cutoff = datetime(int(year), 1, 1, tzinfo=timezone.utc).timestamp() - STALE_DAYS * 86400
ts = parse_dt(publish_date)
if ts and ts < cutoff:
return f"subido {str(publish_date)[:10]} (viejo para {year})"
except Exception:
pass
if PREPOST_RE.search(nt) or PREPOST_GLUE_RE.search(nt):
return "pre/post show"
if PRESEASON_RE.search(nt):
return "pre-season testing"
if SHOW_RE.search(nt):
return "show/resumen"
if session:
if not re.search(SESSION_PATTERNS[session], nt):
return f"no es {session}"
for key, pat in SESSION_PATTERNS.items():
if key != session and re.search(pat, nt):
return f"otra sesión ({key})"
return None
def filter_results(results, session=None, enabled=True, year=None, terms=None):
"""Separa en (los que nos interesan, {motivo: [descartados]})."""
if not enabled:
return list(results), {}
kept, dropped = [], {}
for r in results:
reason = junk_reason(r.get("Title") or "", session, year, r.get("PublishDate"), terms)
if reason:
dropped.setdefault(reason, []).append(r)
else:
kept.append(r)
return kept, dropped
def sort_results(results):
return sorted(
results,
key=lambda x: (-parse_dt(x.get("PublishDate")), -(x.get("Seeders") or 0)),
)
# ---------------------------------------------------------------- display
def term_width():
try:
return max(80, min(shutil.get_terminal_size((120, 24)).columns, 200))
except Exception:
return 120
def display_results(results, hidden=0, note=""):
total = term_width()
print()
head = f"{COL_BOLD}Resultados ({len(results)}){COL_RESET}"
if hidden:
head += f" {COL_DIM}· {hidden} ocultos por filtro ('a' para verlos){COL_RESET}"
print(head)
if note:
print(f"{COL_DIM}{note}{COL_RESET}")
print(f"{COL_CYAN}{'-' * total}{COL_RESET}")
tracker_w = 18
title_w = max(30, total - tracker_w - 52)
for i, r in enumerate(results[:MAX_RESULTS], start=1):
seeds_s = f"{r.get('Seeders') or 0} seeds"
size = human_size(r.get("Size") or 0)
title = norm_ws(r.get("Title")).replace("\n", " ")
tracker = norm_ws(r.get("Tracker") or r.get("Indexer") or "")[:tracker_w]
pdate = (r.get("PublishDate") or "")[:10]
print(
f"{i:3d} │ {seeds_s:>8s} │ {size:>9s} │ {title[:title_w]:<{title_w}s} "
f"│ {tracker:<{tracker_w}s} {torrent_type(r)} [{pdate}]"
)
print(f"{COL_CYAN}{'-' * total}{COL_RESET}")
if len(results) > MAX_RESULTS:
print(f"{COL_YELLOW}(Mostrando primeros {MAX_RESULTS} de {len(results)}){COL_RESET}")
def choose_with_fzf(items):
try:
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f:
tmp = f.name
for it in items:
f.write(it + "\n")
with open(tmp) as fh:
proc = subprocess.run(
["fzf", "--ansi", "--reverse", "--no-multi"],
stdin=fh,
text=True,
capture_output=True,
)
os.unlink(tmp)
if proc.returncode != 0:
return None
return proc.stdout.strip()
except FileNotFoundError:
log_warn("fzf no encontrado. Usando selección numérica.")
except Exception as e:
log_warn(f"fzf error: {e}")
return None
# ---------------------------------------------------------------- qBittorrent
def resolve_link(result):
"""
Sigue los redirects de Jackett a mano. requests reventaba con
InvalidSchema porque el /dl/ responde 302 hacia un magnet:?, que no es
un esquema HTTP. Devuelve (url_final, magnet_o_vacio).
Acá es donde Jackett decide si el tracker tiene .torrent real o solo magnet.
"""
url = (result.get("Link") or "").strip()
for _ in range(5):
if url.startswith("magnet:?"):
return url, url
if not url.startswith("http"):
return url, ""
try:
r = requests.get(url, timeout=60, allow_redirects=False)
except Exception as e:
log_warn(f"No se pudo consultar {redact(url)}: {e}")
return url, ""
if r.status_code in (301, 302, 303, 307, 308):
loc = (r.headers.get("Location") or "").strip()
if not loc:
return url, ""
url = urllib.parse.urljoin(url, loc)
continue
return url, ""
return url, ""
def redact(url):
"""Oculta la API key de Jackett antes de imprimir una URL."""
return re.sub(r"((?:jackett_)?api_?key=)[^&]+", r"\1***", url or "", flags=re.IGNORECASE)
def jackett_torrent_file(result):
"""
Baja el .torrent desde la URL de descarga de Jackett. Devuelve
(bytes, magnet_fallback): si Jackett redirige a un magnet en vez de servir
el archivo, se devuelve el magnet y no se pierde la descarga.
"""
url, magnet = resolve_link(result)
if magnet:
return None, magnet # este tracker solo tiene magnet
if not url.startswith("http"):
return None, ""
# Jackett sirve el .torrent como descarga cruda.
try:
r = requests.get(url, timeout=90, allow_redirects=False)
except Exception as e:
log_warn(f"No se pudo bajar el .torrent: {e}")
return None, ""
if r.status_code in (301, 302, 303, 307, 308):
loc = (r.headers.get("Location") or "").strip()
if loc.startswith("magnet:?"):
return None, urllib.parse.urljoin(url, loc)
return None, ""
# Un .torrent empieza con "d8:announce"; si no, es HTML de error.
if r.content[:1] == b"d":
return r.content, ""
log_warn(f"Jackett no devolvió un .torrent válido (HTTP {r.status_code}, {len(r.content)} bytes)")
return None, ""
def qbt_headers():
return {"Cookie": QBT_COOKIE} if QBT_COOKIE else {}
def qbt_add_url(url):
"""POST /torrents/add con un magnet o una URL, vía requests."""
payload = {"urls": url, "savepath": DOWNLOAD_DIR, "category": "menu"}
r = requests.post(
f"{QBT_URL}/api/v2/torrents/add",
data=payload,
headers=qbt_headers(),
timeout=30,
)
return r.status_code == 200, r.status_code
def qbt_add_file(data):
"""POST /torrents/add subiendo el .torrent crudo (multipart)."""
files = {"torrents": ("torrent.torrent", data, "application/x-bittorrent")}
payload = {"savepath": DOWNLOAD_DIR, "category": "menu"}
r = requests.post(
f"{QBT_URL}/api/v2/torrents/add",
data=payload,
files=files,
headers=qbt_headers(),
timeout=60,
)
return r.status_code == 200, r.status_code
def qbt_add_curl(url):
"""Fallback con curl, por si requests falla (proxy rarezas, etc.)."""
proc = subprocess.run(
[
"curl", "-sf", "-X", "POST", f"{QBT_URL}/api/v2/torrents/add",
"--data-urlencode", f"urls={url}",
"-d", f"savepath={DOWNLOAD_DIR}",
"-d", "category=menu",
],
capture_output=True,
text=True,
timeout=30,
)
return proc.returncode == 0, proc.returncode
def add_qbt(result):
"""
Manda el release a qBittorrent. Orden de preferencia:
1) el magnet, si el indexer lo trae;
2) el .torrent bajado de Jackett (si Jackett responde 302 al magnet, se usa
ese magnet en vez de perder la descarga).
"""
title = norm_ws(result.get("Title"))
magnet = magnet_of(result)
if magnet:
try:
ok, code = qbt_add_url(magnet)
if ok:
log_ok(f"Torrent agregado: {title[:70]}")
return True
log_warn(f"qBittorrent respondió {code}")
except Exception as e:
log_warn(f"Subida por magnet falló: {e}")
elif (result.get("Link") or "").startswith("http"):
data, fallback_magnet = jackett_torrent_file(result)
if data:
try:
ok, code = qbt_add_file(data)
if ok:
log_ok(f"Torrent agregado ({human_size(len(data))}, .torrent): {title[:60]}")
return True
log_warn(f"qBittorrent rechazó el archivo (HTTP {code})")
except Exception as e:
log_warn(f"Subida como archivo falló: {e}")
if fallback_magnet:
magnet = fallback_magnet
log_info("Jackett redirigió a magnet; usando el magnet")
source = magnet
if not source:
link = (result.get("Link") or "").strip()
source = link if link.startswith("http") else ""
if not source:
log_err(f"Sin magnet ni link utilizable para: {title[:60]}")
return False
try:
ok, code = qbt_add_curl(source)
if ok:
log_ok(f"Torrent agregado (curl): {title[:70]}")
return True
log_err(f"qBittorrent no lo aceptó (código {code}): {title[:60]}")
return False
except Exception as e:
log_err(f"No se pudo agregar: {e}")
return False
# ---------------------------------------------------------------- flujo
def pick_and_add(results):
items = []
for i, r in enumerate(results[:MAX_RESULTS], start=1):
title = norm_ws(r.get("Title"))
pdate = (r.get("PublishDate") or "")[:10]
items.append(f"{i:3d} | {pdate} | {(r.get('Seeders') or 0):5d}s | {title}")
picks = choose_with_fzf(items)
if not picks:
return
try:
cidx = int(picks.split("|")[0].strip()) - 1
r = results[cidx]
except Exception:
log_warn("Selección inválida")
return
log_ok(f"Elegido: {norm_ws(r.get('Title'))[:90]}")
add_qbt(r)
def print_dropped(dropped, limit=4):
"""Qué se descartó y por qué, agrupado por motivo."""
for reason, items in sorted(dropped.items(), key=lambda kv: -len(kv[1])):
log_warn(f"descartado ({reason}): {len(items)}")
for r in sorted(items, key=lambda x: -(x.get("Seeders") or 0))[:limit]:
print(f" {COL_DIM}- {(r.get('Seeders') or 0):>3} seeds │ "
f"{human_size(r.get('Size') or 0):>9s} │ {norm_ws(r.get('Title'))[:72]}{COL_RESET}")
if len(items) > limit:
print(f" {COL_DIM}… y {len(items) - limit} más{COL_RESET}")
def suggest_session(results):
"""Qué sesión aparece más entre los títulos."""
counts = session_counts(results)
if not counts:
return None
return max(counts.items(), key=lambda kv: kv[1])[0]
def session_counts(results):
counts = {}
for r in results:
nt = norm_title(r.get("Title"))
for key, pat in SESSION_PATTERNS.items():
if re.search(pat, nt):
counts[key] = counts.get(key, 0) + 1
return counts
def session_candidates(results):
"""
Sesiones a probar cuando no se pidió ninguna y no quedó nada: primero la que
más aparece en los títulos, después el resto del fin de semana.
"""
counts = session_counts(results)
ordered = [k for k, _ in sorted(counts.items(), key=lambda kv: -kv[1])]
ordered += [s for s in SESSION_ORDER if s not in ordered]
return ordered
def search(query, session, use_filter, indexer="all", year=None):
"""Busca, deduplica y filtra. Devuelve (resultados, ocultos, notas, descartados)."""
queries = build_queries(query, session)
log_info(f"{len(queries)} consultas: {', '.join(queries[:6])}{'…' if len(queries) > 6 else ''}")
raw = fetch_results(queries, indexer=indexer)
if not raw:
return [], [], [], {}
unique, dupes = dedup_results(raw)
log_ok(f"{len(raw)} brutos → {len(unique)} únicos ({dupes} duplicados entre trackers)")
# Sin sesión explícita filtramos por relevancia contra la query; con sesión ya
# los resultados vienen acotados por ella y filtrar por "sesión" sería redundante.
terms = query_terms(query) if session is None else None
kept, dropped = filter_results(unique, session, use_filter, year, terms)
hidden = [r for items in dropped.values() for r in items]
# Resumen corto para el encabezado: el desglose fino por año se agrupa bajo
# un solo motivo, así la línea no se vuelve ilegible.
merged = {}
for reason, items in dropped.items():
key = "año equivocado" if reason.startswith("año ") else reason
merged[key] = merged.get(key, 0) + len(items)
notes = [f"{k}: {v}" for k, v in sorted(merged.items(), key=lambda kv: -kv[1])]
return sort_results(kept), hidden, notes, dropped
def parse_args(argv):
p = argparse.ArgumentParser(
description="Busca torrents en Jackett y los manda a qBittorrent.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("query", nargs="*", help="qué buscar (ej: 'formula 1 2026 Bahrain qualifying')")
p.add_argument(
"--session",
default="any",
choices=["any", "practice", "qualifying", "sprint", "race", "lights"],
help="filtrar por sesión del fin de semana (default: la que detecte en la query)",
)
p.add_argument("--no-filter", action="store_true", help="no descartar pre/post shows ni años viejos")
p.add_argument(
"--no-retry",
action="store_true",
help="si no pediste sesión y el filtro vacía la lista, no probar otras sesiones",
)
p.add_argument(
"--year",
default="auto",
help="año a mantener (default: el de la query, ej. 2026). --year any desactiva",
)
p.add_argument("-1", "--list", action="store_true", help="solo listar y salir")
p.add_argument("--max", type=int, default=MAX_RESULTS, help="máximo a mostrar")
p.add_argument("--indexer", default="all", help="indexer de Jackett (default: all)")
return p.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
query = norm_ws(" ".join(args.query))
if not query:
try:
query = norm_ws(input("¿Qué querés buscar? "))
except Exception:
query = ""
if not query:
log_err("Búsqueda vacía")
sys.exit(1)
global MAX_RESULTS
MAX_RESULTS = args.max
session = detect_session(query) if args.session == "any" else args.session
use_filter = FILTER_JUNK and not args.no_filter
# Año: el de la query, o el que se pase con --year
if args.year == "any":
year = None
elif args.year != "auto":
year = args.year
else:
m = YEAR_RE.search(query)
year = m.group(0) if m else None
out = search(query, session, use_filter, args.indexer, year)
results, hidden, notes, dropped = out
# No se pidió sesión y el filtro se comió todo (típico: sólo hay shows).
# Probamos las demás sesiones en orden de probabilidad y nos quedamos con la
# primera que devuelva releases reales, para no mostrar una lista vacía.
retried = None
if not results and hidden and session is None and not args.no_retry:
for cand in session_candidates(hidden):
log_info(f"Sin resultados filtrados; probando --session {cand}…")
out = search(query, cand, use_filter, args.indexer, year)
r2, hidden2, notes2, dropped2 = out
if r2:
results, hidden, notes, dropped = r2, hidden2, notes2, dropped2
session, retried = cand, cand
break
if not results:
log_err(f"Sin resultados para '{query}'")
if not hidden:
if session:
log_warn("Probá otra sesión (--session practice/race) o --no-filter.")
sys.exit(1)
log_warn(f"El filtro descartó los {len(hidden)} resultados ({', '.join(notes)}).")
print_dropped(dropped)
guess = suggest_session(hidden)
if guess:
print(
f"{COL_YELLOW}Tip:{COL_RESET} no hay release de '{guess}' limpio indexado. "
f"Lo único que hay son shows/resúmenes.\n"
f" {Path(sys.argv[0]).name} {q_quote(query)} --session {guess} "
f"--no-filter # ver todo lo que hay, basura incluida"
)
else:
print(f"{COL_YELLOW}Tip:{COL_RESET} probá con --no-filter para ver todo.")
results = sort_results(hidden)
note = f"{COL_DIM}mostrando lo descartado (nada pasó el filtro){COL_RESET}"
if args.list:
display_results(results, 0, note)
return
print(f"{COL_DIM}('a'/'f' alternan, 'r' re-busca){COL_RESET}")
else:
bits = []
if session:
bits.append(f"sesión {session}")
if year:
bits.append(f"año {year}")
note = f"filtro: {' + '.join(bits) if bits else 'sin filtro'}"
if hidden:
note += f" · ocultos {len(hidden)} ({', '.join(notes)})"
if retried:
note += f" {COL_YELLOW}(nada daba para tu búsqueda sin sesión; auto --session {retried}){COL_RESET}"
if args.list:
display_results(results, len(hidden), note)
return
showing_all = False
while True:
display_results(
sort_results(hidden) if showing_all else results,
0 if showing_all else len(hidden),
note,
)
prompt = f"{COL_BOLD}Opción:{COL_RESET} [número] / 'f' fzf / 'a' ver ocultos"
if showing_all:
prompt += " (filtrados)"
if dropped:
prompt += " / 'h' qué se descartó"
prompt += " / 'r' re-buscar / Enter salir: "
try:
ans = input(prompt).strip()
except (EOFError, KeyboardInterrupt):
print("\ncancelado")
break
if ans == "":
print("cancelado")
break
low = ans.lower()
if low == "h" and dropped:
print()
log_info("Por qué se descartó cada release:")
print_dropped(dropped)
continue
if low == "a" and hidden:
showing_all = not showing_all
continue
if low == "f":
pick_and_add(results if not showing_all else sort_results(hidden))
elif low == "r":
query2 = norm_ws(input("Nueva búsqueda: ").strip()) or query
query = query2
session = detect_session(query) if args.session == "any" else args.session
m = YEAR_RE.search(query)
year_r = m.group(0) if (m and args.year == "auto") else (None if args.year == "any" else args.year)
out = search(query, session, use_filter, args.indexer, year_r)
results, hidden, notes, dropped = out
if not results and hidden:
log_warn(f"El filtro descartó los {len(hidden)} ({', '.join(notes)}); mostrando ocultos.")
print_dropped(dropped)
results = sort_results(hidden)
elif not results:
log_err("Sin resultados para la nueva búsqueda")
continue
showing_all = False
continue
else:
try:
c = int(ans)
except ValueError:
log_warn("Opción inválida")
continue
current = sort_results(hidden) if showing_all else results
if c < 1 or c > min(len(current), MAX_RESULTS):
log_warn("Fuera de rango")
continue
r = current[c - 1]
log_ok(f"Elegido: {norm_ws(r.get('Title'))[:90]}")
add_qbt(r)
try:
if input("¿Descargar otro? [s/N]: ").strip().lower() in ("s", "y"):
continue
except (EOFError, KeyboardInterrupt):
print()
break
def q_quote(text):
return f'"{text}"' if " " in text else text
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\ncancelado")
sys.exit(0)