#!/usr/bin/env python3
"""Genera el catálogo de PortNX. Solo utiliza la biblioteca estándar de Python.

Uso: python3 generar_indice.py /ruta/a/ports
Opcional: --base-url https://servidor/ports/
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import time
import struct
import xml.etree.ElementTree as ET
import unicodedata
from urllib.parse import quote, urlsplit
from urllib.request import Request, build_opener, HTTPRedirectHandler
from urllib.error import HTTPError, URLError

KEY_FILE = "/volumeUSB2/usbshare/dbi-magic/config-privada/steamgriddb.key"
API = "https://www.steamgriddb.com/api/v2"

# Metadatos del catálogo público indicado por el usuario, consultado el 2026-10-03.
# Solo nombres y carátulas; no contiene enlaces de descarga de juegos.
CATALOGO_PORTNX = {
  "05BBA9A2CAB4C000": {
    "name": "0 A.D. Empire Acendant",
    "iconUrl": "https://pbs.twimg.com/profile_images/378800000429149227/c5387b35d738133e7900c06ada84735d_400x400.png"
  },
  "05450B00BA020000": {
    "name": "Another Metroid 2 Remake",
    "iconUrl": "https://static.wikia.nocookie.net/metroid/images/6/69/Am2r_title_screen.png/"
  },
  "05454A7359780000": {
    "name": "Bully - Anniversary Edition",
    "iconUrl": "https://cdn.mobygames.com/covers/1591038-bully-scholarship-edition-iphone-front-cover.jpg"
  },
  "050C5369CCF62000": {
    "name": "Captain Claw",
    "iconUrl": "https://i.pinimg.com/originals/46/c8/c6/46c8c6fc5c51d363395634b82dfe61c3.jpg"
  },
  "05DE4E5905956000": {
    "name": "Castle of Illusion",
    "iconUrl": "https://images.launchbox-app.com/afe3c264-e9e3-46be-94ad-c7d5b459a57e.png"
  },
  "017A76BFD9F50000": {
    "name": "Castlevania ReVamped",
    "iconUrl": "https://i1.sndcdn.com/artworks-oYZS4X00R9kXQ630-Q1CmfQ-t500x500.png"
  },
  "0509D16F47048000": {
    "name": "Castlevania Symphony of the Night",
    "iconUrl": "https://i.pinimg.com/736x/6b/9d/a8/6b9da8e5c6c95769fd705820e8dc3dee.jpg"
  },
  "053B23DEF5DAE000": {
    "name": "Celeste 64",
    "iconUrl": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co7oz4.jpg"
  },
  "054037680F8DC000": {
    "name": "Chaos Ring 3",
    "iconUrl": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co2h4d.jpg"
  },
  "0528D256981BA000": {
    "name": "Chrono Trigger",
    "iconUrl": "https://i.pinimg.com/1200x/fb/9d/2f/fb9d2ff13be36fcd41bc9a3051dbb205.jpg"
  },
  "05F6641D152CC000": {
    "name": "Crazy Taxi",
    "iconUrl": "https://cdn2.steamgriddb.com/icon_thumb/4e6954f3e5fd0d7498210d63d2d4180c.png"
  },
  "05BECF2629C70000": {
    "name": "Diablo I",
    "iconUrl": "https://i.ebayimg.com/images/g/wtAAAOSwfi1nmrGx/s-l400.jpg"
  },
  "055FDC03655AC000": {
    "name": "Driver 2",
    "iconUrl": "https://f4.bcbits.com/img/a0609998823_2.jpg"
  },
  "054AE080EA0C0000": {
    "name": "Final Fantasy 3 - 3D Remake",
    "iconUrl": "https://images.gog-statics.com/51691e1d069210819135956c8342d63b90032958e6a69fad969c43febcdc92c3.jpg"
  },
  "054CEA358D2BC000": {
    "name": "Final Fantasy Dimensions",
    "iconUrl": "https://assets-prd.ignimgs.com/2022/05/23/ffdimensions-1653332120734.jpg"
  },
  "050FDEFA91AB6000": {
    "name": "Final Fantasy IV 3D Remake",
    "iconUrl": "https://pics.filmaffinity.com/final_fantasy_iv_3d_remake-701570382-large.jpg"
  },
  "0556102064CB2000": {
    "name": "Final Fantasy IV The After Years",
    "iconUrl": "https://cdn.loaded.com/265x265/media/catalog/product/f/i/final_fantasy_iv__the_after_years_pc.jpg"
  },
  "053B883A437F8000": {
    "name": "FreeSpace 2",
    "iconUrl": "https://m.media-amazon.com/images/I/71u8+NoYwML.jpg"
  },
  "05B2CA1E33D56000": {
    "name": "Grand Theft Auto - Chinatown Wars",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BZWZhYzJmNDItYmRmNS00NTA1LWE3M2EtZmQxNzU3OGQ5MTU5XkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg"
  },
  "05613D612D8BC000": {
    "name": "Grand Theft Auto - Liberty City Stories",
    "iconUrl": "https://m.media-amazon.com/images/I/61R9-TEQClL._SX342_SY445_QL70_FMwebp_.jpg"
  },
  "05BC0A1F4DA64000": {
    "name": "Grand Theft Auto - San Andreas",
    "iconUrl": "https://pbs.twimg.com/profile_images/611721586694070272/DzMJQ_rT_400x400.png"
  },
  "057AB9BA4059A000": {
    "name": "Heart of Darkness",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BYzhmNDI5YTgtMGRjNS00M2NkLTk0OTUtMTMyOGNmNGFmY2MyXkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg"
  },
  "052D79A5A788E000": {
    "name": "Jazz 2 Resurrection",
    "iconUrl": "https://pbs.twimg.com/media/G0iYTY1WoAAKxQ2.jpg"
  },
  "05511A96056D4000": {
    "name": "LEGO Batman 3 Beyond Gotham",
    "iconUrl": "https://cdn2.steamgriddb.com/icon/6198b5bcaeeac1143e6bdaf97826dbf5/32/512x512.png"
  },
  "055116067522A000": {
    "name": "Lego Star Wars - The Complete Saga",
    "iconUrl": "https://upload.wikimedia.org/wikipedia/en/5/56/Lego_Star_Wars-The_Complete_Saga.jpg"
  },
  "055577610FB98000": {
    "name": "LEGO Star Wars The Force Awakens",
    "iconUrl": "https://cdn.mobygames.com/covers/1906867-lego-star-wars-the-force-awakens-xbox-one-front-cover.png"
  },
  "05BECF2629B8E000": {
    "name": "Mario Kart 64",
    "iconUrl": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co67hm.jpg"
  },
  "05BECF2629C30000": {
    "name": "Max Payne",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BMzIzYmRkOGEtNjA5MS00ZDJmLWJlYzgtOGEwNzAzMDU3MTNlXkEyXkFqcGc@._V1_.jpg"
  },
  "056AFD46B3FE4000": {
    "name": "Medal of Honor Allied Assault",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BZmY2MmQ1ZGItZWRiZS00NmQwLTgwNDEtYjczYWEzODMxYTgxXkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg"
  },
  "0172D0986CA80000": {
    "name": "Mega Man W",
    "iconUrl": "https://m.media-amazon.com/images/I/81Vqw-JcPmL._AC_UF1000,1000_QL80_.jpg"
  },
  "05BD05496CC42000": {
    "name": "Megaman X8 - Demake",
    "iconUrl": "https://mediaproxy.tvtropes.org/width/1200/https://static.tvtropes.org/pmwiki/pub/images/mmx8_hq.png"
  },
  "0533872C158B2000": {
    "name": "Mighty Final Fight Forever",
    "iconUrl": "https://i1.sndcdn.com/artworks-El7hFWwyyISUJy4u-nsxdrQ-t1080x1080.jpg"
  },
  "052D49AD22C04000": {
    "name": "Professor Layton and the Curious Village",
    "iconUrl": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/coc6k5.jpg"
  },
  "05EAA5AD44FA8000": {
    "name": "Professor Layton and the Diabolical Box HD",
    "iconUrl": "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource112/v4/0e/d6/47/0ed647b9-ab22-163a-16c8-03f4bca551bf/2daf81bb-55e2-4db4-be57-1b7400fd6f34__U60aa_U9b54_U306e_U7bb1__U7c73_U82f1_U8a9e_1242x2208_01.png/392x696bb.png"
  },
  "05B75BC2C6B20000": {
    "name": "Professor Layton and the Unwound Future",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BYWEyMzNjN2MtNjk0Ny00ZWIzLWI4NjUtNmJmOWY3YWIyYzYzXkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg"
  },
  "05F25AA6EB69C000": {
    "name": "Serial Experiments Lain",
    "iconUrl": "https://assets-prd.ignimgs.com/2023/01/04/lain-1672795661299.jpg"
  },
  "05C297D962E08000": {
    "name": "Silent Hill",
    "iconUrl": "https://icons.iconarchive.com/icons/3xhumed/mega-games-pack-34/256/Silent-Hill-3-2-icon.png"
  },
  "05BECF2629BD0000": {
    "name": "Simpsons Hit & Run",
    "iconUrl": "https://i.ytimg.com/vi/V2kySup3zTw/maxresdefault.jpg"
  },
  "0502D0256A3BE000": {
    "name": "Sonic the Hedgehog 4 Episode II",
    "iconUrl": "https://upload.wikimedia.org/wikipedia/en/1/1f/S4E2_Boxart.png"
  },
  "05005873236D0000": {
    "name": "Sonic Time Twisted",
    "iconUrl": "https://cdn2.steamgriddb.com/icon_thumb/b040016a9f9b158b9e2110fd9299979b.png"
  },
  "058F53252754E000": {
    "name": "Sonic Triple Trouble 16-bit",
    "iconUrl": "https://info.sonicretro.org/images/0/05/SonicTripleTrouble16bit_Fangame_Title.png"
  },
  "05BECF2629B90000": {
    "name": "Star Fox 64",
    "iconUrl": "https://coverproject.sfo2.cdn.digitaloceanspaces.com/nintendo_64/label_n64_starfox64_thumb.jpg"
  },
  "05BECF2629BB0000": {
    "name": "Super Mario 64 - Render 96",
    "iconUrl": "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/i/0d7fbd1c-e00d-4a78-aaf7-785c60872783/deq79az-5e3c437a-323a-4986-b612-e7edae1db8d7.png/v1/fill/w_474,h_526/super_mario_64_render_96_icon_app_for_pc_by_jcmanu_deq79az-fullview.png"
  },
  "053DFA501D15E000": {
    "name": "Super Mario War",
    "iconUrl": "https://cdn2.steamgriddb.com/grid/9513e7a721539fc573477867b253b10d.png"
  },
  "05A520D7EAC5A000": {
    "name": "Super Mario X",
    "iconUrl": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co22kw.jpg"
  },
  "05A7748AD7BAA000": {
    "name": "Super Smash Bros.",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BYti1MTM4MjMtMjc1Yy00ODQ4LThmOTMtNzE0Y2Q0Y2JmNGViXkEyXkFqcGc@._V1_.jpg"
  },
  "05C9B476555FC000": {
    "name": "Swordigo",
    "iconUrl": "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/co3z7c.jpg"
  },
  "05BECF2629C50000": {
    "name": "The Legend of Zelda - A Link to the Past",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BN2U5OWRiMWYtMmI1Mi00MDMyLWFkOTctYTI3ZmYyMDk2ZTkzXkEyXkFqcGc@._V1_.jpg"
  },
  "0539210E01A62000": {
    "name": "The Legend of Zelda - Link's Awakening DX HD",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BYzRiMGYxOTEtMWZmYi00YzAwLWJiMTEtY2U1ZjhjMWViZmZlXkEyXkFqcGc@._V1_.jpg"
  },
  "05BECF2629BF0000": {
    "name": "The Legend of Zelda - Majoras Mask",
    "iconUrl": "https://m.media-amazon.com/images/I/61edKuM1b6L._AC_UF1000,1000_QL80_.jpg"
  },
  "05BECF2629B8C000": {
    "name": "The Legend of Zelda - Ocarina of Time",
    "iconUrl": "https://m.media-amazon.com/images/M/MV5BNDNjNTI4MzMtODVkNi00YmUzLTkxMDQtY2NiNDczMjFkMTEyXkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg"
  },
  "054195EEBB4F2000": {
    "name": "The Legend of Zelda - Twilight Princess",
    "iconUrl": "https://upload.wikimedia.org/wikipedia/en/0/0e/The_Legend_of_Zelda_Twilight_Princess_Game_Cover.jpg"
  },
  "052EF74E3DE54000": {
    "name": "Wipeout Rewrite",
    "iconUrl": "https://i.ibb.co/j33TKNh/wipeout-icon.png"
  }
}

# Carátulas por ID para los NSP añadidos; no necesita la clave de SteamGridDB.
CATALOGO_NSP = {
  "010021C000B6A000": {
    "id": "010021C000B6A000",
    "name": "The Binding of Isaac: Afterbirth+",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/3c1a8f954b40e3247eddcfc0491a7aed3d84cb3d8320eef4a17d2ba03147d155.jpg"
  },
  "010057D0021E8000": {
    "id": "010057D0021E8000",
    "name": "Shovel Knight: Treasure Trove",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/034f98b36f34420795a57274e0fa177333ff928f813eefc9257b6c98ac2d9131.jpg"
  },
  "01002FC00412C000": {
    "id": "01002FC00412C000",
    "name": "Little Nightmares Complete Edition",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/765c5c93eaa0adc70d13b5ff3af3ed3a940cbf537a8de576e67d2c8622b19040.jpg"
  },
  "0100838002AEA000": {
    "id": "0100838002AEA000",
    "name": "LEGO® Worlds",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/3e847584cfa56ac2ca760450de754cd42573a43699255c479f1782d039fd7d41.jpg"
  },
  "01008F1008DA6000": {
    "id": "01008F1008DA6000",
    "name": "Darkest Dungeon",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/de6131b6c023aee7e3d3c55b548ed7912a5c22d6ca0d4638ba682704cb6fbd99.jpg"
  },
  "010082400BCC6000": {
    "id": "010082400BCC6000",
    "name": "Untitled Goose Game",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/4e8b857cf14007f2a38799a6fa1e78a2381cab3cd24bc3fccfdded10cdff8f67.jpg"
  },
  "010029300E5C4000": {
    "id": "010029300E5C4000",
    "name": "The Stanley Parable: Ultra Deluxe",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/c7ef450d370134cbd839494d161f2ee0c8ff875ae7b5a2e54c1cc10b2322fb0f.jpg"
  },
  "01002E7016C46000": {
    "id": "01002E7016C46000",
    "name": "Cult of the Lamb",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/88df7f43cd29f836da726b4a64c84fed52538b048fe93726c19b4cbb5af59578.jpg"
  },
  "01007BB017812000": {
    "id": "01007BB017812000",
    "name": "Portal",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/b80279e843715dccc7232ca60067a4b0d6c77a79545b54c8b9274d28a3217ae7.jpg"
  },
  "010015100B514000": {
    "id": "010015100B514000",
    "name": "Super Mario Bros.™ Wonder – Nintendo Switch™ 2 Edition + Meetup in Bellabel Park",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/bf2fca7eed5ad7ec96d03025907ea52c3efe168e02c8be96e868d8430a247a57.jpg"
  },
  "010015F01C8EC000": {
    "id": "010015F01C8EC000",
    "name": "Five Nights at Freddy's: Into the Pit",
    "iconUrl": "https://img-eshop.cdn.nintendo.net/i/e58e0d0afc7ae0069dd4d0ba0aac52b08fc996e34754fa3a6c7eeb3763bc72ab.jpg"
  }
}

def title_id_archivo(archivo):
    hallado = re.search(r"(?:^|[^0-9a-fA-F])([0-9a-fA-F]{16})(?=$|[^0-9a-fA-F])", archivo.stem)
    return hallado.group(1).upper() if hallado else ""


def limpiar_nombre(nombre):
    nombre = re.sub(r"\s*\[v\d[^\]]*\].*$", "", nombre, flags=re.I)
    nombre = re.sub(r"\s*\[[^\]]*\]", "", nombre)
    nombre = re.sub(r"[-_\s]*@[^\s]+.*$", "", nombre)
    nombre = re.sub(r"(?<=\w)\+(?=\w)", " ", nombre).replace("_", " ")
    nombre = nombre.replace("®", "").replace("™", "")
    nombre = re.sub(r"\s*\(\d+\)$", "", nombre)
    nombre = re.sub(r"^port\s+", "", nombre, flags=re.I)
    return re.sub(r"\s+", " ", nombre).strip(" -_")


def title_id_paquete(archivo):
    """Lee únicamente la tabla PFS0 y los XML públicos, sin claves de consola."""
    try:
        with archivo.open("rb") as f:
            cabecera = f.read(16)
            if len(cabecera) != 16 or cabecera[:4] != b"PFS0":
                return ""
            cantidad, strings_size = struct.unpack_from("<II", cabecera, 4)
            if cantidad > 4096 or strings_size > 1024 * 1024:
                return ""
            tabla = f.read(cantidad * 24)
            strings = f.read(strings_size)
            if len(tabla) != cantidad * 24 or len(strings) != strings_size:
                return ""
            base = 16 + cantidad * 24 + strings_size
            total = archivo.stat().st_size
            aplicaciones, tickets = set(), set()
            for i in range(cantidad):
                offset, size, name_offset = struct.unpack_from("<QQI", tabla, i * 24)
                if name_offset >= len(strings) or base + offset + size > total:
                    continue
                nombre = strings[name_offset:].split(b"\0", 1)[0].decode("utf-8", "replace")
                ticket = re.fullmatch(r"([0-9a-fA-F]{16})[0-9a-fA-F]{16}\.tik", nombre)
                if ticket:
                    tickets.add(ticket.group(1).upper())
                if nombre.lower().endswith(".cnmt.xml") and size <= 1024 * 1024:
                    f.seek(base + offset)
                    datos = f.read(size)
                    if b"<!DOCTYPE" in datos.upper() or b"<!ENTITY" in datos.upper():
                        continue
                    try:
                        root = ET.fromstring(datos)
                        if root.findtext("Type", "").lower() != "application":
                            continue
                        tid = root.findtext("Id", "").strip()
                        if tid.lower().startswith("0x"):
                            tid = tid[2:]
                        if re.fullmatch(r"[0-9a-fA-F]{16}", tid):
                            aplicaciones.add(tid.upper())
                    except ET.ParseError:
                        continue
            if len(aplicaciones) == 1:
                return next(iter(aplicaciones))
            if not aplicaciones and len(tickets) == 1:
                return next(iter(tickets))
    except (OSError, ValueError, struct.error):
        pass
    return ""


class SinRedireccion(HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None


def normalizar(nombre):
    nombre = unicodedata.normalize("NFKD", nombre).encode("ascii", "ignore").decode().lower()
    return re.sub(r"[^a-z0-9]", "", nombre)


def nombres_busqueda(nombre):
    nombre = limpiar_nombre(nombre)
    nombre = re.sub(r"\s+(?:v(?:ersion)?\s*)?\d+\.\d+(?:\.\d+)*(?:\s.*)?$", "", nombre, flags=re.I).strip(" -_")
    variantes = [nombre]
    if re.match(r"^AM2R\b", nombre, re.I):
        variantes += ["AM2R", "Another Metroid 2 Remake", "AM2R: Another Metroid 2 Remake"]
    return list(dict.fromkeys(variantes))


def pedir_api(ruta, clave):
    req = Request(API + ruta, headers={"Authorization": "Bearer " + clave,
                  "User-Agent": "PortNX-NinMagic-Catalog/2", "Accept": "application/json"})
    with build_opener(SinRedireccion()).open(req, timeout=15) as respuesta:
        datos = json.loads(respuesta.read(2 * 1024 * 1024).decode("utf-8"))
    if not isinstance(datos, dict) or datos.get("success") is not True or not isinstance(datos.get("data"), list):
        raise ValueError("Respuesta de SteamGridDB no válida")
    return datos["data"]


def descargar_imagen(url, destino, referencia=False):
    parsed = urlsplit(url)
    host = parsed.hostname or ""
    permitido = host == "steamgriddb.com" or host.endswith(".steamgriddb.com")
    if referencia:
        permitido = permitido or host == "img-eshop.cdn.nintendo.net"
    if parsed.scheme != "https" or not permitido:
        raise ValueError("Servidor de imagen no permitido")
    req = Request(url, headers={"User-Agent": "PortNX-NinMagic-Catalog/2"})
    with build_opener(SinRedireccion()).open(req, timeout=15) as respuesta:
        datos = respuesta.read(8 * 1024 * 1024 + 1)
    if len(datos) > 8 * 1024 * 1024:
        raise ValueError("Imagen demasiado grande")
    if datos.startswith(b"\x89PNG\r\n\x1a\n"):
        extension = ".png"
    elif datos.startswith(b"\xff\xd8\xff"):
        extension = ".jpg"
    else:
        raise ValueError("La imagen no es PNG ni JPEG")
    destino = destino.with_suffix(extension)
    temporal = None
    try:
        with tempfile.NamedTemporaryFile(dir=str(destino.parent), prefix=".imagen-", delete=False) as f:
            temporal = f.name
            f.write(datos)
            f.flush()
            os.fsync(f.fileno())
        os.replace(temporal, str(destino))
    finally:
        if temporal and os.path.exists(temporal):
            os.unlink(temporal)
    return destino.name


def caratula(nombre, archivo, anterior, carpeta, base_url, clave, presupuesto):
    meta = dict(anterior.get("caratula", {})) if isinstance(anterior.get("caratula"), dict) else {}
    relativo = meta.get("archivo", "")
    if relativo and re.fullmatch(r"[0-9a-f]{32}\.(?:png|jpg)", relativo) and (carpeta / "covers" / relativo).is_file():
        return base_url.rstrip("/") + "/covers/" + relativo, meta
    reintentar = meta.get("reintentar", 0)
    if not isinstance(reintentar, (int, float)):
        reintentar = 0
    if not clave or presupuesto[0] <= 0 or time.time() < reintentar:
        return None, meta
    presupuesto[0] -= 1
    print("Buscando carátula: " + nombre, flush=True)
    try:
        variantes = nombres_busqueda(nombre)
        aceptados = {normalizar(v) for v in variantes}
        candidatos = {}
        for consulta in variantes:
            resultados = pedir_api("/search/autocomplete/" + quote(consulta, safe=""), clave)
            for juego in resultados:
                if isinstance(juego, dict) and normalizar(str(juego.get("name", ""))) in aceptados and isinstance(juego.get("id"), int):
                    candidatos[juego["id"]] = juego
            if candidatos:
                break
        if len(candidatos) != 1:
            print("Sin coincidencia única para: " + nombre)
            return None, {"reintentar": time.time() + 86400}
        juego = next(iter(candidatos.values()))
        imagenes = pedir_api("/grids/game/{}?types=static&nsfw=false&humor=false".format(juego["id"]), clave)
        imagenes = [i for i in imagenes if isinstance(i, dict) and not i.get("nsfw") and not i.get("humor") and not i.get("epilepsy") and i.get("url")]
        def orden(i):
            return (i.get("width") != i.get("height"), -float(i.get("score") or 0))
        imagenes.sort(key=orden)
        if not imagenes:
            print("No hay carátulas disponibles para: " + nombre)
            return None, {"reintentar": time.time() + 86400}
        covers = carpeta / "covers"
        covers.mkdir(exist_ok=True)
        destino = covers / hashlib.sha256(archivo.encode("utf-8")).hexdigest()[:32]
        # La miniatura reduce el consumo de memoria en la consola.
        elegido = imagenes[0]
        relativo = descargar_imagen(elegido.get("thumb") or elegido["url"], destino)
        meta = {"archivo": relativo, "game_id": juego["id"], "nombre": juego["name"]}
        print("Carátula guardada: " + juego["name"])
        return base_url.rstrip("/") + "/covers/" + relativo, meta
    except HTTPError as error:
        print("SteamGridDB: error HTTP {}. El catálogo seguirá disponible.".format(error.code))
        if error.code in (401, 403, 429):
            presupuesto[0] = 0
    except (URLError, OSError, ValueError, TypeError):
        print("No se pudo obtener la carátula. Se reintentará más tarde.")
    return None, {"reintentar": time.time() + 900}


def guardar_json(ruta, datos):
    temporal = None
    try:
        with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=str(ruta.parent),
                                         prefix=".portnx-", suffix=".tmp", delete=False) as f:
            temporal = f.name
            json.dump(datos, f, ensure_ascii=False, indent=2)
            f.write("\n")
            f.flush()
            os.fsync(f.fileno())
        os.replace(temporal, str(ruta))
    finally:
        if temporal and os.path.exists(temporal):
            os.unlink(temporal)


def enlace_catalogo(carpeta, archivo, title_id):
    """PortNX enlaza titledb solo si la URL termina en TITLEID.nsz/nsp."""
    enlaces = carpeta / "portnx-enlaces"
    marcador = enlaces / ".generado-por-portnx"
    if enlaces.is_symlink():
        raise ValueError("portnx-enlaces no debe ser un enlace simbólico")
    if not enlaces.exists():
        enlaces.mkdir()
        marcador.write_text("Enlaces del catálogo; no guardar juegos aquí.\n", encoding="utf-8")
    if not marcador.is_file():
        raise ValueError("La carpeta portnx-enlaces ya existe y no pertenece al generador")
    destino = enlaces / (title_id + archivo.suffix.lower())
    if not destino.is_symlink() and destino.exists() and os.path.samefile(str(archivo), str(destino)):
        return destino
    fd, temporal = tempfile.mkstemp(dir=str(enlaces), prefix=".enlace-")
    os.close(fd)
    os.unlink(temporal)
    try:
        # Un enlace duro comparte los datos; no copia el juego ni ocupa otro juego.
        os.link(str(archivo), temporal)
        os.replace(temporal, str(destino))
    except OSError as error:
        raise OSError("No se pudo crear el enlace del catálogo. Comprueba que el disco admite enlaces duros: " + archivo.name) from error
    finally:
        if os.path.lexists(temporal):
            os.unlink(temporal)
    return destino


def generar(carpeta, base_url, key_file=None):
    # El bloqueo impide que dos tareas escriban el catálogo simultáneamente.
    import fcntl
    with (carpeta / ".portnx-indice.lock").open("a") as bloqueo:
        try:
            fcntl.flock(bloqueo, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print("Ya hay otra generación en curso.")
            return
        cache_path = carpeta / ".portnx-cache.json"
        try:
            cache = json.loads(cache_path.read_text(encoding="utf-8"))
            if not isinstance(cache, dict):
                cache = {}
        except (OSError, ValueError):
            cache = {}
        nuevos = {}
        entradas = []
        titledb = {}
        omitidos = 0
        ids_usados = set()
        enlaces_usados = set()
        clave = ""
        presupuesto = [4]
        if key_file:
            try:
                key_path = Path(key_file).resolve()
                if carpeta == key_path or carpeta in key_path.parents:
                    raise ValueError("La clave debe estar fuera de la carpeta pública ports")
                clave = key_path.read_text(encoding="utf-8-sig").strip()
                if not re.fullmatch(r"[0-9a-fA-F]{32}", clave):
                    raise ValueError("El archivo de clave no contiene 32 caracteres hexadecimales")
            except (OSError, ValueError):
                print("No se pudo leer una clave API válida. Se actualizará el catálogo sin buscar imágenes.")
                clave = ""
        for archivo in sorted(carpeta.iterdir(), key=lambda p: p.name.lower()):
            if archivo.is_symlink() or not archivo.is_file() or archivo.suffix.lower() not in (".nsp", ".nsz"):
                continue
            stat = archivo.stat()
            if stat.st_size < 16:
                print("Omitido: archivo vacío o incompleto: " + archivo.name)
                omitidos += 1
                continue
            firma = [stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns]
            anterior = cache.get(archivo.name, {})
            if not isinstance(anterior, dict):
                anterior = {}
            if anterior.get("firma") == firma and re.fullmatch(r"[0-9a-f]{64}", str(anterior.get("sha256", ""))):
                digest = anterior["sha256"]
            else:
                print("Leyendo: " + archivo.name, flush=True)
                h = hashlib.sha256()
                with archivo.open("rb") as f:
                    if f.read(4) != b"PFS0":
                        print("Omitido: cabecera PFS0 no válida: " + archivo.name)
                        omitidos += 1
                        continue
                    f.seek(0)
                    for bloque in iter(lambda: f.read(4 * 1024 * 1024), b""):
                        h.update(bloque)
                digest = h.hexdigest()
            despues = archivo.stat()
            if firma != [despues.st_size, despues.st_mtime_ns, despues.st_ctime_ns]:
                print("Omitido: todavía se está copiando: " + archivo.name)
                omitidos += 1
                continue
            nombre = limpiar_nombre(archivo.stem) or archivo.stem
            identidad = title_id_archivo(archivo)
            if not identidad:
                almacenado = anterior.get("title_id", "") if anterior.get("sha256") == digest else ""
                identidad = almacenado if re.fullmatch(r"[0-9A-F]{16}", str(almacenado)) else title_id_paquete(archivo)
            referencia = CATALOGO_PORTNX.get(identidad) or CATALOGO_NSP.get(identidad, {})
            if not referencia and not identidad:
                coincidencias = [v for v in CATALOGO_NSP.values()
                                 if normalizar(limpiar_nombre(v["name"])) == normalizar(nombre)]
                if len(coincidencias) == 1:
                    referencia = coincidencias[0]
            clave_catalogo = identidad or referencia.get("id", "")
            if referencia.get("name"):
                nombre = referencia["name"]
            numerico = bool(re.fullmatch(r"\[?[0-9a-fA-F]{16}\]?(?:\s*\[[^\]]*\])*", nombre))
            entrada = {"name": nombre, "url": base_url.rstrip("/") + "/" + quote(archivo.name, safe=""),
                       "size": stat.st_size, "sha256": digest}
            if clave_catalogo:
                # Un ID obtenido por nombre solo enlaza la imagen; no afirma el ID del NSP.
                entrada["title_id"] = identidad or "0000000000000000"
                if clave_catalogo in ids_usados:
                    print("Omitido: Title ID repetido: " + identidad + " (" + archivo.name + ")")
                    omitidos += 1
                    continue
                ids_usados.add(clave_catalogo)
                enlace = enlace_catalogo(carpeta, archivo, clave_catalogo)
                entrada["url"] = base_url.rstrip("/") + "/portnx-enlaces/" + enlace.name
                enlaces_usados.add(enlace.name)
                actual = archivo.stat()
                if (actual.st_size, actual.st_mtime_ns) != (stat.st_size, stat.st_mtime_ns):
                    print("Omitido: el juego está cambiando: " + archivo.name)
                    omitidos += 1
                    continue
                firma = [actual.st_size, actual.st_mtime_ns, actual.st_ctime_ns]
            if anterior.get("nombre_busqueda") != nombre and isinstance(anterior.get("caratula"), dict):
                anterior = dict(anterior)
                anterior["caratula"] = dict(anterior["caratula"])
                anterior["caratula"].pop("reintentar", None)
            icon_url, imagen_meta = caratula(nombre, archivo.name, anterior, carpeta, base_url, clave if not numerico else "", presupuesto)
            # Conserva las imágenes de Nintendo en el NAS para evitar esperas externas.
            url_referencia = referencia.get("iconUrl", "")
            if clave_catalogo in CATALOGO_NSP and url_referencia:
                local = imagen_meta.get("archivo_referencia", "")
                if (imagen_meta.get("url_referencia") == url_referencia
                        and re.fullmatch(r"[0-9a-f]{32}\.(?:png|jpg)", local)
                        and (carpeta / "covers" / local).is_file()):
                    icon_url = base_url.rstrip("/") + "/covers/" + local
                else:
                    try:
                        covers = carpeta / "covers"
                        covers.mkdir(exist_ok=True)
                        destino = covers / hashlib.sha256(("nintendo:" + url_referencia).encode("utf-8")).hexdigest()[:32]
                        local = descargar_imagen(url_referencia, destino, referencia=True)
                        imagen_meta["archivo_referencia"] = local
                        imagen_meta["url_referencia"] = url_referencia
                        icon_url = base_url.rstrip("/") + "/covers/" + local
                        print("Carátula de referencia guardada: " + nombre, flush=True)
                    except (URLError, OSError, ValueError, TypeError):
                        print("No se pudo guardar la carátula de referencia: " + nombre)
                        icon_url = url_referencia
            icon_url = icon_url or url_referencia
            if icon_url:
                entrada["iconUrl"] = icon_url
                if clave_catalogo:
                    titledb[clave_catalogo] = {"name": nombre, "iconUrl": icon_url}
            if numerico and not referencia:
                print("ID sin nombre en el catálogo de referencia: " + identidad)
            entradas.append(entrada)
            nuevos[archivo.name] = {"firma": firma, "sha256": digest, "caratula": imagen_meta,
                                   "title_id": identidad, "nombre_busqueda": nombre}
        # Publica los juegos válidos; los incompletos se revisan en la siguiente ejecución.
        guardar_json(carpeta / "index.json", {"files": entradas, "titledb": titledb})
        guardar_json(cache_path, nuevos)
        # Retira únicamente los enlaces administrados que ya no están en el catálogo.
        enlaces = carpeta / "portnx-enlaces"
        if enlaces.is_dir() and (enlaces / ".generado-por-portnx").is_file():
            for enlace in enlaces.iterdir():
                if re.fullmatch(r"[0-9A-F]{16}\.(?:nsp|nsz)", enlace.name) and enlace.name not in enlaces_usados:
                    enlace.unlink()
        print("Catálogo actualizado: {} archivo(s), {} omitido(s).".format(len(entradas), omitidos))


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("carpeta", nargs="?", default=str(Path(__file__).resolve().parent))
    parser.add_argument("--base-url", default="https://dbimagic.magica.synology.me/ports/")
    parser.add_argument("--key-file", default=KEY_FILE, help="Archivo privado con la clave de SteamGridDB")
    args = parser.parse_args()
    carpeta = Path(args.carpeta).resolve()
    if not carpeta.is_dir():
        parser.error("La carpeta no existe: " + str(carpeta))
    url = urlsplit(args.base_url)
    if url.scheme not in ("http", "https") or not url.netloc or url.query or url.fragment:
        parser.error("La URL base debe ser una dirección HTTP(S) sin parámetros ni fragmentos.")
    try:
        generar(carpeta, args.base_url, args.key_file)
    except (OSError, ValueError, RuntimeError) as error:
        parser.exit(1, "No se completó la actualización: {}\n".format(error))


if __name__ == "__main__":
    main()
