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

creá un archivo llamado jackett.json

{
    "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.

Para instalar jackett en Arch usando el AUR:
yay -S jackett-bin
sudo systemctl start jackett

Y sumado a eso usá opencode para complementar con jackett y qbitorrent-nox ;)

Prompt:

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.

En /var/lib/jackett/Indexers

Está la lista de indexs si es que los activaste uno por uno guardalos para no tener que volver a hacerlo.

torrent_menu.sh (opencode, con big pickle)

#!/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."

En python este es mejor


#!/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)