CCCPaste

3b3f4d63b8c9b

#!/usr/bin/env python3

import os
import sys
import math
import logging
import requests
import subprocess
import shlex
import time
import asyncio
import json
import platform
import tempfile
import glob
import argparse
import shutil
import pycountry
from uuid import uuid4
from shutil import which
from types import SimpleNamespace
from pathlib import Path
from datetime import datetime

from dataclasses import dataclass
from typing import Tuple, Dict, Any, Optional, List

# Configure the logger
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# Tentativo import Telethon
try:
    from telethon import TelegramClient
    from telethon.tl.types import DocumentAttributeVideo, DocumentAttributeFilename
    from telethon.errors import FloodWaitError, FilePartsInvalidError
    TELETHON_AVAILABLE = True
    print("✅ Telethon importato correttamente")
except ImportError as e:
    TELETHON_AVAILABLE = False
    logger.error(f"❌ Telethon non disponibile: {e}")

########## CONFIGURATIONS ##########
# Telegram
telegram_bot_token = "6694045777:AAFx-8ACJI1Qv9PJgPNN8bwYt4kO0zJgA2Y"
owner_chatid = "45717165"

# Radarr
radarr_api = "ab82e440423849d2a0e3cb99939485b3"
radarr_host = "http://10.0.0.100"
radarr_port = "17878"

# Sonarr
sonarr_api = "a5566d0ad3764369a2d6bc994e8b872b"
sonarr_host = "http://10.0.0.100"
sonarr_port = "18989"
########## CONFIGURATIONS ##########

keyword = "searcharr"

chunk_bytes = 512 * 1024 # 512 KiB, limite Telethon/Telegram
max_parts_default = 4000 # limite parti tipico lato server (non-Premium)
safe_limit_factor = 1
safe_limit_bytes = int(chunk_bytes * max_parts_default * safe_limit_factor)

REENCODE_OUTPUT_EXT = ".mkv"

FORCE_MP4_PARTS = False  # se True e manca MP4Box, remux dei .mkv in .mp4 via ffmpeg -c copy + faststart

docker_mount_point = "/srv/dev-disk-by-uuid-ecc70552-d999-49f3-b5e4-9a0250e1c559/Sharing:/Sharing"
whisperai_model_mount_point = "/srv/dev-disk-by-uuid-ecc70552-d999-49f3-b5e4-9a0250e1c559/Sharing/Telegram-bot/audiomedia-models:/models"

ENV_DUMP = False # True/False
ENV_DUMP_FILE = "/Sharing/Telegram-bot/Sonarr-env.txt" # percorso del file di output

# --- Politiche HEVC / Qualità ---
# Floor minimi H.265 per film/serie live-action (kbps). Regolali a piacere.
H265_FLOOR_KBPS = {
    2160: 12000,
    1440:  8100,
    1080:  1000,
    720:    750,
    576:    450,
    480:    420,
}

MAX_TARGET_HEIGHT = 1080

# Stima “sicura” (rientro target) — usa già nel tuo codice
RETRY_ON_OVERSHOOT = True       # un solo retry abbassando il video_kbps se sfora safe_limit_bytes
RETRY_SAFETY_PCT = 0.005
FUDGE_VBR_FIRST_TRY = 0.0135

# Audio per traccia (se non riusciamo a leggere il bitrate reale)
AUDIO_KBPS_PER_TRACK = 96
FORCE_STEREO = True              # forza sempre 2 canali

# Bitrate video minimo tecnico (guard-rail)
MIN_VIDEO_KBPS = 100

# Preserva 10‑bit/HDR in uscita (usa p010le + main10)
NVENC_PRESERVE_10BIT = True

def dump_env():
    if not ENV_DUMP:
        return None
    # crea la cartella se serve
    d = os.path.dirname(ENV_DUMP_FILE)
    if d:
        os.makedirs(d, exist_ok=True)
    # usa "a" invece di "w" se vuoi accumulare più run
    with open(ENV_DUMP_FILE, "w", encoding="utf-8") as f:
        for k, v in sorted(os.environ.items()):
            f.write(f"{k}={v}\n")
    return ENV_DUMP_FILE

def _safe_run(cmd: list[str]) -> None:
    try:
        subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except Exception:
        pass

def update_mkv_metadata_final(out_path: Path, src_path: Path, eff_n_audio: int) -> None:
    """
    Esegue SOLO quando il file finale è nei limiti.
    - Reset e rigenerazione dei Track Statistics Tags (fix per MediaInfo: bitrate/bytes/durata corretti)
    - Segment title = nome file sorgente (senza estensione)
    - Primo audio default; gli altri non default
    - Commenti/encoded_by
    Richiede mkvpropedit in PATH. Se assente, esce silenziosamente.
    """
    if out_path.suffix.lower() != '.mkv':
        return
    if shutil.which('mkvpropedit') is None:
        return

    # 1) Track Statistics Tags: elimina qualunque residuo e rigenera dal contenuto attuale
    _safe_run(['mkvpropedit', str(out_path), '--delete-track-statistics-tags'])
    _safe_run(['mkvpropedit', str(out_path), '--add-track-statistics-tags'])

    # 2) Segment title
    title = src_path.stem
    _safe_run(['mkvpropedit', str(out_path), '--set', f'title={title}'])

    # 3) Flag default audio: a1 default, altri no (mkvpropedit conta da 1)
    if eff_n_audio > 0:
        _safe_run(['mkvpropedit', str(out_path), '--edit', 'track:a1', '--set', 'flag-default=1'])
        for i in range(2, eff_n_audio+1):
            _safe_run(['mkvpropedit', str(out_path), '--edit', f'track:a{i}', '--set', 'flag-default=0'])

    # 4) Commenti globali (facoltativi)
    comment = f'Encoded HEVC NVENC + AAC LC @ {AUDIO_KBPS_PER_TRACK} kbps per track'
    _safe_run(['mkvpropedit', str(out_path), '--edit', 'info', '--set', f'comment={comment}'])
    _safe_run(['mkvpropedit', str(out_path), '--edit', 'info', '--set', 'encoded_by=auto-compressor'])

def _analyze_audio_languages_with_docker(src: Path) -> list[dict]:
    host_prefix, container_prefix = docker_mount_point.split(":", 1)
    p = src.resolve().as_posix()
    if p.startswith(host_prefix):
        container_file = container_prefix + p[len(host_prefix):]
    else:
        container_file = p  # se non è nel mount, uso così com'è (niente assert)

    cmd = [
        "docker", "run", "--rm", "--gpus", "all",
        "-v", docker_mount_point,
        "-v", whisperai_model_mount_point,
        "chryses/audiomedia-checker:latest",
        "--gpu", "--check-all-tracks", "--dry-run", "--json",
        "--file", container_file,
    ]
    res = subprocess.run(cmd, capture_output=True, text=True, check=True)
    return json.loads(res.stdout.strip())  # es.: [{"track":1,"language":"ita"}, ...]

def _decide_audio_selection(profile_language: str, langs: List[dict]) -> Tuple[bool, List[int], Optional[str], str]:
    """
    Ritorna:
      - filter_enabled: bool → True se dobbiamo filtrare (cioè rimuovere alcune tracce)
      - keep_relidx: lista 0-based degli indici relativi audio da tenere (validi per ffmpeg 0:a:{idx})
      - notify_msg: eventuale messaggio per owner_chatid
      - summary_str: riepilogo tracce tipo "#1=ita, #2=eng"
    Regole:
      - 1 traccia: tieni sempre; se lang != profilo oppure lang == "und" → notifica.
      - >1 traccia:
          - se esiste almeno una traccia == profilo → filtra tenendo tutte le == profilo e tutte le "und"
          - se nessuna == profilo → notifica e NON filtrare (tieni tutte)
    """
    langs_norm = []
    for t in langs or []:
        tr = int(t.get("track"))
        lg = (t.get("language") or "").lower()
        langs_norm.append({"track": tr, "language": lg})
    langs_norm.sort(key=lambda x: x["track"])  # #1, #2, ...

    summary_str = ", ".join(f"#{t['track']}={t['language'] or 'und'}" for t in langs_norm)
    notify_msg: Optional[str] = None

    if not langs_norm:
        # Caso limite: nessun audio rilevato → niente filtro, segnala.
        return (False, [], "Nessuna traccia audio rilevata dal checker.", summary_str)

    profile_language = (profile_language or "").lower()

    if len(langs_norm) == 1:
        t = langs_norm[0]
        lang = t["language"]
        # tieni sempre la singola traccia
        keep_relidx = [t["track"] - 1]  # non verrà usato per filtrare (filter_enabled=False), ma è coerente
        if (lang != profile_language) or (lang == "und"):
            notify_msg = f"File con 1 sola traccia audio: #{t['track']}={lang} (profilo={profile_language})."
        return (False, keep_relidx, notify_msg, summary_str)

    # >1 traccia
    has_profile = any(t["language"] == profile_language for t in langs_norm)
    if not has_profile:
        # nessun match → tieni tutto, avvisa
        notify_msg = f"Nessuna traccia in lingua profilo ({profile_language}). Rilevate: {summary_str}"
        return (False, [], notify_msg, summary_str)

    # c'è almeno un match → filtriamo
    keep_relidx = [t["track"] - 1 for t in langs_norm if t["language"] == profile_language or t["language"] == "und"]
    return (True, keep_relidx, None, summary_str)

class EncodeNotFeasible(Exception):
    """Impossibile rispettare i floor minimi con i vincoli dati (limite/durata/audio)."""
    pass

# === helper sui bucket ===

def _sorted_buckets(floors: Dict[int, int]) -> List[int]:
    return sorted(int(k) for k in floors.keys())

def _floor_value_min(floors: Dict[int, int]) -> int:
    """Valore del floor del bucket più basso (quello 'minore')."""
    keys = _sorted_buckets(floors)
    return int(floors[keys[0]])

def _bucket_floor_for_height(h: int, floors: Dict[int, int]) -> int:
    """
    Ritorna il bucket più alto <= h; se h < bucket_min, ritorna comunque il bucket_min.
    Nota: questo non implica upscale: l'uso va combinato con la logica MAX_TARGET_HEIGHT.
    """
    keys = _sorted_buckets(floors)
    cand = keys[0]
    for k in keys:
        if k <= h:
            cand = k
        else:
            break
    return cand

def _cap_to_max_target(bucket_h: int, max_target_h: int, floors: Dict[int, int]) -> int:
    """Limita il bucket al massimo consentito (max_target_h), scendendo se necessario."""
    keys = _sorted_buckets(floors)
    allowed = [k for k in keys if k <= max_target_h]
    return allowed[-1] if allowed else keys[0]

# def _next_lower_bucket(curr: int, floors: Dict[int, int]) -> Optional[int]:
#     keys = _sorted_buckets(floors)
#     if curr not in keys:
#         # normalizza al bucket più vicino >= curr, poi scendi
#         for k in keys:
#             if curr <= k:
#                 curr = k
#                 break
#     idx = keys.index(curr)
#     return keys[idx-1] if idx > 0 else None

# def _floor_for_bucket(bucket_h: int, floors: Dict[int,int]) -> int:
#     return int(floors[int(bucket_h)])

def _select_bucket_for_v(v_kbps: int, start_bucket: int, floors: Dict[int, int]) -> Optional[int]:
    """
    Dato un budget video e un bucket di partenza, scende finché trova un floor <= v_kbps.
    Ritorna None se nemmeno il bucket minore è soddisfatto.
    """
    b = start_bucket
    while True:
        need = _floor_for_bucket(b, floors)
        if v_kbps >= need:
            return b
        nxt = _next_lower_bucket(b, floors)
        if not nxt:
            return None
        b = nxt

# ================ Utilità bucket/floor =================
def _height_bucket(h: int, floors: Dict[int, int]) -> int:
    keys = sorted(int(k) for k in floors.keys())
    for k in keys:
        if h <= k:
            return k
    return keys[-1]

def _next_lower_bucket(curr: int, floors: Dict[int, int]) -> Optional[int]:
    keys = sorted(int(k) for k in floors.keys())
    if curr not in keys:
        curr = _height_bucket(curr, floors)
    idx = keys.index(curr)
    return keys[idx-1] if idx > 0 else None

def _floor_for_bucket(bucket_h: int, floors: Dict[int,int]) -> int:
    return int(floors[int(bucket_h)])

# ================= ffprobe =================
def _ffprobe_json(path: str) -> Dict[str, Any]:
    cmd = [
        "ffprobe","-v","error","-print_format","json",
        "-show_streams","-show_format", path
    ]
    p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if p.returncode != 0:
        raise RuntimeError(f"ffprobe failed: {p.stderr.decode('utf-8', 'ignore')}")
    return json.loads(p.stdout.decode('utf-8', 'ignore'))

def _fmt_bytes(b: int) -> str:
    try:
        if b >= 1024**3: return f"{b/(1024**3):.2f} GiB"
        if b >= 1024**2: return f"{b/(1024**2):.0f} MiB"
        if b >= 1024:    return f"{b/1024:.0f} KiB"
        return f"{b} B"
    except Exception:
        return f"{b} B"

def _fmt_hms(sec: float) -> str:
    s = int(round(sec))
    h = s // 3600; m = (s % 3600) // 60; s = s % 60
    return f"{h:02d}:{m:02d}:{s:02d}"

def _clone_plan_with_new_v(plan, new_v_kbps: int):
    # Clona plan1 mantenendo audio e vf_height; aggiorna v_kbps e safe_total_kbps
    d = dict(plan.__dict__) if hasattr(plan, "__dict__") else dict(
        safe_total_kbps=getattr(plan, "safe_total_kbps"),
        target_audio_kbps_total=getattr(plan, "target_audio_kbps_total"),
        v_kbps=getattr(plan, "v_kbps"),
        vf_height=getattr(plan, "vf_height"),
        source_video_kbps_est=getattr(plan, "source_video_kbps_est", None),
    )
    d["v_kbps"] = int(max(1, new_v_kbps))
    d["safe_total_kbps"] = int(d["target_audio_kbps_total"] + d["v_kbps"])
    return SimpleNamespace(**d)

def _compute_retry_v_kbps(duration_s: float, out1_bytes: int, limit_bytes: int,
                          v1_kbps: int, safety_pct: float, floor_kbps: int = 200) -> int:
    """
    Spalma l'eccesso di byte sulla durata:
      delta_tot_kbps = ceil( (bytes_over * 8) / duration_s / 1000 )
      v2 = v1 - ceil(delta_tot_kbps * (1 + safety_pct))
    Audio resta invariato, quindi tutta la riduzione grava sul video.
    """
    if duration_s <= 0:
        # fallback prudente: togli 10% + safety
        dec = int(math.ceil(v1_kbps * (0.10 * (1.0 + safety_pct))))
        return max(floor_kbps, v1_kbps - dec)

    bytes_over = max(0, out1_bytes - limit_bytes)
    if bytes_over <= 0:
        return v1_kbps  # non dovrebbe capitare, ma lasciamo invariato

    delta_tot_kbps = int(math.ceil((bytes_over * 8) / duration_s / 1000.0))
    delta_with_margin = int(math.ceil(delta_tot_kbps * (1.0 + safety_pct)))
    v2 = max(floor_kbps, v1_kbps - delta_with_margin)
    return v2

# ================ Estrazione contesto =================
@dataclass
class EncodeContext:
    file_path: str
    ext_low: str
    duration_s: float
    width: int
    height: int
    height_bucket: int
    is_10bit: bool
    n_audio: int
    source_total_kbps_est: int
    source_video_kbps_est: int

def extract_encode_context(file_path: str) -> EncodeContext:
    src = Path(file_path)
    if not src.exists():
        raise FileNotFoundError("File non trovato")

    info = _ffprobe_json(str(src))
    fmt = info.get("format", {})
    streams = info.get("streams", [])

    # durata
    dur = None
    if "duration" in fmt and fmt["duration"]:
        try: dur = float(fmt["duration"])
        except: pass
    if not dur:
        for st in streams:
            if st.get("codec_type") == "video" and st.get("duration"):
                try: dur = float(st["duration"]); break
                except: pass
    if not dur or dur <= 0:
        raise ValueError("Durata non disponibile da ffprobe")

    # video stream principale
    v = next((s for s in streams if s.get("codec_type") == "video"), None)
    if not v:
        raise ValueError("Stream video non trovato")
    w = int(v.get("width") or 0)
    h = int(v.get("height") or 0)
    if w <= 0 or h <= 0:
        raise ValueError("Dimensioni video non valide")

    pix_fmt = (v.get("pix_fmt") or "").lower()
    bits_raw = v.get("bits_per_raw_sample")
    is_10 = False
    if isinstance(bits_raw, str):
        try: is_10 = int(bits_raw) >= 10
        except: pass
    if not is_10:
        # euristica
        is_10 = any(tag in pix_fmt for tag in ("p10", "yuv420p10", "p010"))

    # audio tracks
    a_streams = [s for s in streams if s.get("codec_type") == "audio"]
    n_a = len(a_streams)

    # stima bitrate totale sorgente
    file_bytes = src.stat().st_size
    total_kbps_est = max(1, int((file_bytes * 8) / dur / 1000))

    # stima bitrate audio
    audio_known = 0
    for a in a_streams:
        try:
            br = int(a.get("bit_rate") or 0)
            audio_known += br
        except:
            pass
    audio_kbps_known = audio_known // 1000 if audio_known > 0 else 0
    audio_kbps_guess = n_a * AUDIO_KBPS_PER_TRACK if audio_kbps_known == 0 else audio_kbps_known

    # stima video
    v_bit_rate = v.get("bit_rate")
    if v_bit_rate:
        try:
            src_v_kbps = max(MIN_VIDEO_KBPS, int(int(v_bit_rate) / 1000))
        except:
            src_v_kbps = max(MIN_VIDEO_KBPS, total_kbps_est - audio_kbps_guess)
    else:
        src_v_kbps = max(MIN_VIDEO_KBPS, total_kbps_est - audio_kbps_guess)

    bucket = _height_bucket(h, H265_FLOOR_KBPS)

    return EncodeContext(
        file_path=str(src),
        ext_low=src.suffix.lower(),
        duration_s=dur,
        width=w,
        height=h,
        height_bucket=bucket,
        is_10bit=is_10,
        n_audio=n_a,
        source_total_kbps_est=total_kbps_est,
        source_video_kbps_est=src_v_kbps
    )

# ================ Calcolo target (prima/seconda passata) =================
@dataclass
class TargetPlan:
    vf_height: int
    v_kbps: int
    target_audio_kbps_total: int
    safe_total_kbps: int
    source_video_kbps_est: int

# def compute_target_for_size(ctx: EncodeContext, safe_limit_bytes: int, n_audio_override: Optional[int] = None) -> TargetPlan:
#     safe_total_kbps = max(1, int((safe_limit_bytes * 8) / ctx.duration_s / 1000))
#     used_n_audio = n_audio_override if (n_audio_override is not None) else ctx.n_audio
#     audio_kbps = max(0, used_n_audio) * AUDIO_KBPS_PER_TRACK
#     v_kbps = max(MIN_VIDEO_KBPS, safe_total_kbps - audio_kbps)

#     # cap al bitrate video della sorgente
#     if v_kbps > ctx.source_video_kbps_est:
#         v_kbps = ctx.source_video_kbps_est

#     vf_h = ctx.height_bucket  # come già facevi

#     return TargetPlan(
#         safe_total_kbps=safe_total_kbps,
#         target_audio_kbps_total=audio_kbps,
#         v_kbps=v_kbps,
#         vf_height=vf_h,
#         source_video_kbps_est=ctx.source_video_kbps_est
#     )

def compute_target_for_size(
    ctx: EncodeContext,
    safe_limit_bytes: int,
    n_audio_override: Optional[int] = None,
) -> TargetPlan:
    # 1) budget totale "sicuro"
    safe_total_kbps = max(1, int((safe_limit_bytes * 8) / ctx.duration_s / 1000))

    # 2) budget audio
    used_n_audio = n_audio_override if (n_audio_override is not None) else ctx.n_audio
    # se hai ctx.source_audio_kbps_known, preferiscilo quando >0
    audio_kbps = max(0, used_n_audio) * AUDIO_KBPS_PER_TRACK

    # 3) budget video candidato
    v_kbps_cand = max(MIN_VIDEO_KBPS, safe_total_kbps - audio_kbps)

    # 4) precheck fattibilità sul floor minore (non "480" hardcoded)
    min_floor_val = _floor_value_min(H265_FLOOR_KBPS)
    if v_kbps_cand < min_floor_val:
        raise EncodeNotFeasible(
            f"Video budget {v_kbps_cand} kb/s < min floor {min_floor_val} kb/s → split richiesto"
        )

    # 5) bucket di partenza: floor dell'altezza sorgente (no upscale) e clamp al max target
    src_h = int(ctx.height)
    bucket_src_floor = _bucket_floor_for_height(src_h, H265_FLOOR_KBPS)
    bucket_max = _cap_to_max_target(bucket_src_floor, MAX_TARGET_HEIGHT, H265_FLOOR_KBPS)
    bucket_start = min(bucket_src_floor, bucket_max)

    # 6) enforce dei floor (scendi finché floor <= v)
    chosen_bucket = _select_bucket_for_v(v_kbps_cand, bucket_start, H265_FLOOR_KBPS)
    if not chosen_bucket:
        raise EncodeNotFeasible(
            f"Nessun bucket soddisfa {v_kbps_cand} kb/s (min floor={min_floor_val}) → split richiesto"
        )

    # 7) cap condizionale alla sorgente SOLO se non c'è downscale reale
    # downscale reale = target_height < min(src_h, MAX_TARGET_HEIGHT effettivo in pixel)
    # confrontiamo su altezze reali, non sui bucket nominali superiori a src_h
    downscale_real = chosen_bucket < min(src_h, MAX_TARGET_HEIGHT)
    v_final = v_kbps_cand

    if not downscale_real and v_kbps_cand > ctx.source_video_kbps_est:
        v_cap = ctx.source_video_kbps_est
        # Applica cap solo se non rompe il floor al bucket scelto.
        if v_cap >= _floor_for_bucket(chosen_bucket, H265_FLOOR_KBPS):
            v_final = v_cap
        else:
            # Il cap romperebbe il floor. In questo caso è preferibile scendere di bucket
            # e NON applicare cap (regola: dopo downscale il cap non vale).
            lower_ok = _select_bucket_for_v(v_kbps_cand, _next_lower_bucket(chosen_bucket, H265_FLOOR_KBPS) or chosen_bucket, H265_FLOOR_KBPS)
            if lower_ok:
                chosen_bucket = lower_ok
                # dopo il downscale, il cap non si applica
                v_final = v_kbps_cand
            else:
                # Nemmeno scendendo si rispetta il floor → non fattibile
                raise EncodeNotFeasible(
                    "Cap alla sorgente impedisce il rispetto del floor e non esistono bucket inferiori → split richiesto"
                )

    # 8) ritorna il piano
    return TargetPlan(
        safe_total_kbps=safe_total_kbps,
        target_audio_kbps_total=audio_kbps,
        v_kbps=int(v_final),
        vf_height=int(chosen_bucket),
        source_video_kbps_est=ctx.source_video_kbps_est,
    )

# === adeguamento per il tentativo 2 (retry) ===
def adjust_plan_after_retry(
    ctx: EncodeContext,
    prev_plan: TargetPlan,
    new_video_kbps: int,
) -> TargetPlan:
    """
    Dato il v_kbps ricalcolato dal meccanismo di retry (sulla base dello sforamento),
    ri-applica i floor e, se serve, scende di bucket. Usa SEMPRE la stessa policy del tentativo 1.
    """
    v2 = max(MIN_VIDEO_KBPS, int(new_video_kbps))
    min_floor_val = _floor_value_min(H265_FLOOR_KBPS)
    if v2 < min_floor_val:
        raise EncodeNotFeasible(
            f"Retry: v={v2} kb/s < min floor {min_floor_val} kb/s → split richiesto"
        )

    # bucket di partenza = quello del tentativo precedente (non si risale),
    # e comunque non oltre MAX_TARGET_HEIGHT
    start_bucket = min(prev_plan.vf_height, MAX_TARGET_HEIGHT)

    chosen_bucket = _select_bucket_for_v(v2, start_bucket, H265_FLOOR_KBPS)
    if not chosen_bucket:
        raise EncodeNotFeasible(
            f"Retry: nessun bucket soddisfa {v2} kb/s (min floor={min_floor_val}) → split richiesto"
        )

    # DOWNscale reale = confronto con la sorgente, NON con il max target.
    downscale_real = chosen_bucket < ctx.height

    v_final = v2
    if not downscale_real and v2 > ctx.source_video_kbps_est:
        v_cap = ctx.source_video_kbps_est
        if v_cap >= _floor_for_bucket(chosen_bucket, H265_FLOOR_KBPS):
            v_final = v_cap
        else:
            # Scendi ancora di bucket (se possibile) e ignora il cap
            lower_ok = _select_bucket_for_v(
                v2,
                _next_lower_bucket(chosen_bucket, H265_FLOOR_KBPS) or chosen_bucket,
                H265_FLOOR_KBPS
            )
            if lower_ok and lower_ok != chosen_bucket:
                chosen_bucket = lower_ok
                v_final = v2
            else:
                raise EncodeNotFeasible(
                    "Retry: cap romperebbe il floor e non esistono bucket inferiori → split richiesto"
                )

    return TargetPlan(
        safe_total_kbps=int(v_final + prev_plan.target_audio_kbps_total),  # ricalcolato
        target_audio_kbps_total=prev_plan.target_audio_kbps_total,
        v_kbps=int(v_final),
        vf_height=int(chosen_bucket),
        source_video_kbps_est=ctx.source_video_kbps_est,
    )

























def adjust_safe_limit_for_overshoot(safe_limit_bytes: int, actual_bytes: int) -> int:
    overs = max(0, actual_bytes - safe_limit_bytes)
    if overs <= 0:
        return safe_limit_bytes
    new_limit = int(safe_limit_bytes - overs * (1.0 + float(RETRY_SAFETY_PCT)))
    # non scendere sotto 16 MiB per evitare input strani
    return max(new_limit, 16 * 1024 * 1024)

# ================= Docker/FFmpeg helpers (tue funzioni, con minimi adattamenti) =================
def _docker_ffmpeg_args(cpus=None, cpuset=None, use_gpu=False):
    args = ['docker','run','--rm',
            '-e','DOCKER_CONFIG=/dev/null']
    if cpus:   args += ['--cpus', str(cpus)]
    if cpuset: args += ['--cpuset-cpus', str(cpuset)]
    if use_gpu:
        args += ['--gpus=all',
                 '-e','NVIDIA_VISIBLE_DEVICES=all',
                 '-e','NVIDIA_DRIVER_CAPABILITIES=all']
    args += [
        '--security-opt','seccomp=unconfined',
        '--cap-add','SYS_NICE',
        '-v', docker_mount_point,
        'ghcr.io/aperim/nvidia-cuda-ffmpeg:latest',
    ]
    return args

def _nvenc_cmd(
    file_path: str,
    out_path: str,
    vkbps: int,
    vf_h: int,
    is_10bit: bool,          # non usato qui
    n_audio: int,
    ext_low: str,            # compat
    audio_map_relidx: list[int] | None = None,
    force_stereo: bool = FORCE_STEREO,
    use_dplii_matrix: bool = True,
    keep_subtitles: bool = True,        # nuovo: se True, mantieni i sottotitoli
) -> list[str]:
    cmd = _docker_ffmpeg_args(use_gpu=True) + [
        '-y','-nostdin','-hide_banner','-loglevel','error',
        '-hwaccel','cuda',
        '-i', file_path,
        '-vf', f'scale=-2:{vf_h}:force_original_aspect_ratio=decrease',
    ]

    # --- Mapping ---
    # Video principale
    cmd += ['-map','0:v:0']

    # Audio: o filtro esplicito o tutte
    if audio_map_relidx is not None:
        cmd += ['-map','-0:a']  # drop tutte le audio
        for idx in sorted(audio_map_relidx):
            cmd += ['-map', f'0:a:{idx}']
        eff_n_audio = len(audio_map_relidx)
    else:
        cmd += ['-map','0:a?']
        eff_n_audio = n_audio

    # Sottotitoli (se richiesto)
    if keep_subtitles:
        cmd += ['-map','0:s?']
        eff_n_subs = 1  # serve solo per flag copy; non puntiamo gli indici qui
    else:
        eff_n_subs = 0

    # --- Video (come tuo originale NVENC) ---
    cmd += [
        '-c:v','hevc_nvenc',
        '-rc','vbr_hq','-multipass','fullres',
        '-b:v', f'{int(vkbps)}k',
        '-maxrate', f'{int(vkbps*1.08)}k',
        '-bufsize', f'{int(vkbps*2.0)}k',
        '-rc-lookahead','32',
        '-spatial_aq','1','-temporal_aq','1','-aq-strength','8',
        '-bf','3','-b_ref_mode','middle',
        '-g','240',
    ]

    # --- Audio ---
    if eff_n_audio > 0:
        cmd += ['-c:a','libfdk_aac']
        # Default globale (copre eventuali stream non indicizzati)
        cmd += ['-b:a', f'{AUDIO_KBPS_PER_TRACK}k']

        # Impostazioni per ciascuna traccia audio
        for i in range(eff_n_audio):
            cmd += [f'-b:a:{i}', f'{AUDIO_KBPS_PER_TRACK}k']
            if force_stereo:
                cmd += [f'-ac:a:{i}', '2']
                if use_dplii_matrix:
                    # indica encoding matrix dolby pro logic II (metadato utile)
                    cmd += [f'-filter:a:{i}', 'aresample=matrix_encoding=dplii']
            # Titolo pulito della traccia
            cmd += [f'-metadata:s:a:{i}', f'title=AAC LC 2.0 {AUDIO_KBPS_PER_TRACK} kbps']

        # Disposition: prima audio default, le altre non-default
        cmd += ['-disposition:a:0','default']
        for i in range(1, eff_n_audio):
            cmd += [f'-disposition:a:{i}','0']  # rimuove default/forced se ereditati
    else:
        cmd += ['-an']  # nessuna traccia audio

    # --- Sottotitoli: copia "as-is" (MKV è perfetto per srt/ass/pgs/vobsub) ---
    if keep_subtitles and eff_n_subs:
        cmd += ['-c:s','copy']
        # opzionale: assicurati che nessun sottotitolo sia "default"
        # (evita sorprese quando ffmpeg ereditasse i flag)
        cmd += ['-disposition:s','0']

    # --- Metadati/Capitoli del container ---
    # Puliamo: BPS/NUMBER_OF_BYTES/encoder ecc. (ri-setteremo noi dopo il size-check)
    cmd += ['-map_metadata','-1']

    # Matroska container (lavoriamo solo in mkv)
    cmd.append(out_path)
    return cmd

def _run(cmd: List[str]) -> Tuple[int, float, str]:
    start = time.time()
    p = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    elapsed = time.time() - start
    return p.returncode, elapsed, (p.stderr.decode('utf-8', errors='ignore') if p.stderr else "")

def run_encode_once(out_path: str, ctx: EncodeContext, plan: TargetPlan, audio_map_relidx: Optional[List[int]] = None) -> Tuple[int, float, int]:
    try:
        if os.path.exists(out_path):
            os.remove(out_path)
    except Exception:
        pass

    cmd = _nvenc_cmd(
        file_path=ctx.file_path,
        out_path=out_path,
        vkbps=int(plan.v_kbps),
        vf_h=int(plan.vf_height),
        is_10bit=ctx.is_10bit,
        n_audio=ctx.n_audio,
        ext_low=Path(ctx.file_path).suffix.lower(),
        audio_map_relidx=audio_map_relidx
    )

    eff_n_audio = (len(audio_map_relidx) if audio_map_relidx is not None else ctx.n_audio)
    logger.info(
        "[encode] start: out=%s, v=%s kb/s, a_total=%s kb/s, total=%s kb/s, vf_h=%s, audio_streams=%d, dur=%s (%.0fs)",
        out_path, plan.v_kbps, plan.target_audio_kbps_total, plan.safe_total_kbps,
        plan.vf_height, eff_n_audio, _fmt_hms(ctx.duration_s), ctx.duration_s
    )

    rc, elapsed, stderr = _run(cmd)
    out_size = os.path.getsize(out_path) if (rc == 0 and os.path.exists(out_path)) else -1

    if rc != 0:
        tail = (stderr[-400:] if stderr else "")
        logger.error(
            "[encode] failed: rc=%s, elapsed=%.1fs, out_exists=%s, stderr_tail=%s",
            rc, elapsed, os.path.exists(out_path), tail
        )
    else:
        # bitrate effettivo totale e fattore realtime
        eff_total_kbps = int((out_size * 8) / max(1.0, ctx.duration_s) / 1000)
        delta_pct = ( (eff_total_kbps / plan.safe_total_kbps - 1.0) * 100.0 ) if plan.safe_total_kbps else 0.0
        rt_factor = (ctx.duration_s / max(1e-6, elapsed))
        logger.info(
            "[encode] done: rc=0, elapsed=%.1fs (%.2fx realtime), size=%s (%d B), eff_total=%d kb/s "
            "(plan_total=%s, Δ=%.2f%%)",
            elapsed, rt_factor, _fmt_bytes(out_size), out_size,
            eff_total_kbps, plan.safe_total_kbps, delta_pct
        )

    return rc, elapsed, out_size

# ===================== Split (tua funzione, resa robusta) =====================
def split_into_streamable_parts(file_path: str, safe_limit_bytes: int, chat_id: int):
    """
    Split per dimensione con strumenti “container-aware”.
    - MP4: MP4Box -splits XM (preferito). Se manca MP4Box: fallback mkvmerge (MKV), opzionale remux in MP4.
    - MKV: mkvmerge --split size:XMiB
    - Altri: mkvmerge --split size:XM (opzionale remux in MP4 se FORCE_MP4_PARTS=True)
    - Nessun loop di affinamento: safe_limit_bytes è il target massimo per parte. Piccole variazioni possibili.
    Ritorna: lista di path (stringhe) delle parti create, oppure [file originale] se sotto soglia, oppure None su errore.
    Richiede: logger, send_telegram_message, eventuali FORCE_MP4_PARTS e _remux_mkv_to_mp4.
    """

    def _log_info(msg: str):
        try: logger.info(msg)
        except NameError: print(msg)

    def _log_warn(msg: str):
        try: logger.warning(msg)
        except NameError: print(f"WARNING: {msg}")

    def _have(tool: str) -> bool:
        return shutil.which(tool) is not None

    try:
        src = Path(file_path)
        if not src.exists():
            send_telegram_message(chat_id, "❌ File non trovato per split")
            return None

        if not safe_limit_bytes or safe_limit_bytes <= 0:
            send_telegram_message(chat_id, "❌ Limite per parte non valido (safe_limit_bytes)")
            return None

        total_size = src.stat().st_size
        if total_size <= safe_limit_bytes:
            return [str(src)]

        out_dir = src.parent
        ext = src.suffix.lower()

        # Dimensione target (MB interi, minimo 1)
        size_mb = max(1, int(safe_limit_bytes // (1024 * 1024)))
        size_mib = size_mb  # alias per mkvmerge "MiB"

        created: List[Path] = []

        # Pulizia preventiva di output precedenti ricorrenti per pattern noti
        # MKV pattern: {stem}.split-001.mkv
        for p in out_dir.glob(f"{src.stem}.split-*.mkv"):
            try: p.unlink()
            except Exception: pass
        # MP4Box pattern: {stem}_0001.mp4
        for p in out_dir.glob(f"{src.stem}_*.mp4"):
            try: p.unlink()
            except Exception: pass

        if ext == ".mkv":
            if not _have("mkvmerge"):
                send_telegram_message(chat_id, "❌ mkvmerge non trovato (pacchetto mkvtoolnix)")
                return None
            base = out_dir / f"{src.stem}.split.mkv"
            cmd = ["mkvmerge", "-o", str(base), "--split", f"size:{size_mib}MiB", str(src)]
            _log_info(f"[split] MKV → {' '.join(shlex.quote(c) for c in cmd)}")
            run = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            rc = run.returncode
            if rc != 0:
                err = (run.stderr.decode('utf-8', 'ignore') if run.stderr else "")
                logger.error("[split] mkvmerge rc=%s, stderr(last400)=%s", rc, err[-400:])
                send_telegram_message(chat_id, "❌ mkvmerge: split fallito")
                return None

            mkv_parts = sorted(out_dir.glob(f"{src.stem}.split-*.mkv"))
            if not mkv_parts and base.exists():
                mkv_parts = [base]
            if not mkv_parts:
                send_telegram_message(chat_id, "❌ Nessuna parte creata (mkvmerge)")
                return None

            try:
                if FORCE_MP4_PARTS:
                    mp4_parts = _remux_mkv_to_mp4(mkv_parts, chat_id)
                    if mp4_parts is None:
                        return None
                    created = [Path(p) for p in mp4_parts]
                else:
                    created = mkv_parts
            except NameError:
                created = mkv_parts

        elif ext == ".mp4":
            if _have("MP4Box"):
                cmd = ["MP4Box", "-splits", f"{size_mb}M", str(src)]
                _log_info(f"[split] MP4 → {' '.join(shlex.quote(c) for c in cmd)}")
                rc = subprocess.call(cmd, cwd=str(out_dir))
                if rc != 0:
                    send_telegram_message(chat_id, "❌ MP4Box: split fallito")
                    return None
                created = sorted([p for p in out_dir.glob("*.mp4") if p.stem.startswith(src.stem + "_")])
                if not created:
                    created = sorted(out_dir.glob(f"{src.stem}_*.mp4"))
            elif _have("mkvmerge"):
                base = out_dir / f"{src.stem}.split.mkv"
                cmd = ["mkvmerge", "-o", str(base), "--split", f"size:{size_mb}M", str(src)]
                _log_info(f"[split] MP4→MKV (fallback) → {' '.join(shlex.quote(c) for c in cmd)}")
                rc = subprocess.call(cmd)
                if rc != 0:
                    send_telegram_message(chat_id, "❌ mkvmerge: split fallito")
                    return None
                mkv_parts = sorted(out_dir.glob(f"{src.stem}.split-*.mkv"))
                if not mkv_parts:
                    send_telegram_message(chat_id, "❌ Nessuna parte creata (mkvmerge)")
                    return None
                try:
                    if FORCE_MP4_PARTS:
                        mp4_parts = _remux_mkv_to_mp4(mkv_parts, chat_id)
                        if mp4_parts is None:
                            return None
                        created = [Path(p) for p in mp4_parts]
                    else:
                        created = mkv_parts
                except NameError:
                    created = mkv_parts
            else:
                send_telegram_message(chat_id, "❌ Nessuno strumento per split MP4 (installa GPAC/MP4Box o mkvtoolnix)")
                return None

        elif ext in (".avi", ".mov", ".m4v", ".ts", ".m2ts", ".flv", ".webm", ".mpeg", ".mpg"):
            if not _have("mkvmerge"):
                send_telegram_message(chat_id, f"❌ mkvmerge non trovato per split di {ext} (installa mkvtoolnix)")
                return None
            base = out_dir / f"{src.stem}.split.mkv"
            cmd = ["mkvmerge", "-o", str(base), "--split", f"size:{size_mb}M", str(src)]
            _log_info(f"[split] {ext.upper()}→MKV → {' '.join(shlex.quote(c) for c in cmd)}")
            rc = subprocess.call(cmd)
            if rc != 0:
                send_telegram_message(chat_id, "❌ mkvmerge: split fallito")
                return None
            mkv_parts = sorted(out_dir.glob(f"{src.stem}.split-*.mkv"))
            if not mkv_parts:
                send_telegram_message(chat_id, "❌ Nessuna parte creata (mkvmerge)")
                return None
            try:
                if FORCE_MP4_PARTS:
                    mp4_parts = _remux_mkv_to_mp4(mkv_parts, chat_id)
                    if mp4_parts is None:
                        return None
                    created = [Path(p) for p in mp4_parts]
                else:
                    created = mkv_parts
            except NameError:
                created = mkv_parts

        else:
            send_telegram_message(chat_id, f"❌ Estensione non supportata per split: {ext}")
            return None

        if not created:
            send_telegram_message(chat_id, "❌ Split: nessun output rilevato")
            return None

        overs = [p for p in created if p.stat().st_size > safe_limit_bytes]
        if overs:
            worst = max(overs, key=lambda p: p.stat().st_size)
            _log_warn(f"[split] Una parte supera il target richiesto: {worst.name} = {worst.stat().st_size // (1024*1024)} MB")

        return [str(p) for p in created]

    except Exception as e:
        try: logger.exception(f"[split] errore inatteso: {e}")
        except NameError: print(f"[split] errore inatteso: {e}")
        try: send_telegram_message(chat_id, f"❌ Errore nello split: {e}")
        except NameError: pass
        return None

# ===================== CHECK_FILE (completa) =====================
def check_file(file_path: str, chat_id: int, item_id: int, app) -> dict:
    """
    - Se originale <= limite: original_ok.
    - Altrimenti:
        Tentativo 1: calcolo plan con limite "fudgiato" (solo qui).
        Se OK (<= limite reale): replace e encoded_ok.
        Se overshoot e RETRY_ON_OVERSHOOT=True: Tentativo 2.
            - Mantiene audio e vf_height del 1°
            - Ricalcola SOLO v_kbps 'spalmando' l'eccesso + safety.
        Se 2° OK: replace e encoded_ok.
        Altrimenti: too_big (lo split lo farà chi chiama).
    """
    try:
        src = Path(file_path)
        if not src.exists():
            msg = "❌ File non trovato"
            try: send_telegram_message(chat_id, msg)
            except Exception: pass
            return {"status": "error", "msg": msg, "path": None}

        orig_size = src.stat().st_size
        if orig_size <= safe_limit_bytes:
            # logger.info("[check] Originale sotto soglia: %s <= %s", orig_size, safe_limit_bytes)
            logger.info(
                "[check] originale <= limite: size=%s (%d B) <= limit=%s (%d B) — skip encode.",
                _fmt_bytes(orig_size), orig_size, _fmt_bytes(safe_limit_bytes), safe_limit_bytes
            )
            return {"status": "original_ok", "path": str(src), "size": orig_size}

        if app == "sonarr":
            api_key = sonarr_api
            host = sonarr_host
            port = sonarr_port
        elif app == "radarr":
            api_key = radarr_api
            host = radarr_host
            port = radarr_port
        else:
            raise ValueError("app must be 'radarr' or 'sonarr'")
        
        profile_language = get_profile_language_iso639_2(app, api_key, host, port, item_id)

        langs = _analyze_audio_languages_with_docker(src)
        filter_enabled, keep_relidx, notify_msg, summary_str = _decide_audio_selection(profile_language, langs)

        # Log elenco tracce rilevate
        logger.info("[audio] Tracce: %s", summary_str)

        # Notifiche all'owner, se richieste dalla logica
        if notify_msg:
            try:
                send_telegram_message(owner_chatid, f"ℹ️ {src.name}: {notify_msg}")
            except Exception:
                pass

        ctx = extract_encode_context(str(src))

        # Report keep/remove
        if filter_enabled:
            keep_1based = [i+1 for i in sorted(keep_relidx)]
            all_1based   = [int(t.get("track")) for t in langs]
            remove_1based = [t for t in all_1based if t not in keep_1based]
            # logger.info("[audio] Keep: %s | Remove: %s",
            #             ", ".join(f"#{i}" for i in keep_1based) if keep_1based else "(none)",
            #             ", ".join(f"#{i}" for i in remove_1based) if remove_1based else "(none)")
            logger.info(
                "[audio] selection: keep=%s (%d/%d), remove=%s",
                ", ".join(f"#{i}" for i in keep_1based) if keep_1based else "(none)",
                len(keep_1based), len(langs),
                ", ".join(f"#{i}" for i in remove_1based) if remove_1based else "(none)"
            )
        else:
            logger.info("[audio] selection: keep=all (%d/%d), remove=(none)", ctx.n_audio, ctx.n_audio)

        # Context e output temporaneo
        # ctx = extract_encode_context(str(src))
        out_tmp = str(src.with_name(f"{src.stem}.encode{REENCODE_OUTPUT_EXT}"))
        try:
            if os.path.exists(out_tmp):
                os.remove(out_tmp)
        except Exception:
            pass

        dur_txt = _fmt_hms(ctx.duration_s)
        try:
            send_telegram_message(
                chat_id,
                "🗜️ Provo compressione GPU (HEVC NVENC, multipass)…"
            )

            logger.info(
                "[check] source: name=%s, dur=%s (%.0fs), size=%s (%d B), limit=%s (%d B), app=%s, profile_lang=%s",
                src.name, _fmt_hms(ctx.duration_s), ctx.duration_s,
                _fmt_bytes(orig_size), orig_size,
                _fmt_bytes(safe_limit_bytes), safe_limit_bytes,
                app, profile_language or "n/a"
            )

            a_src_est = max(0, ctx.source_total_kbps_est - ctx.source_video_kbps_est)
            logger.info(
                "[src] video: %dx%d, %s-bit, v_src≈%d kb/s",
                ctx.width, ctx.height, ("10" if ctx.is_10bit else "8"), ctx.source_video_kbps_est
            )
            logger.info(
                "[src] audio: tracks=%d, a_src_est≈%d kb/s",
                ctx.n_audio, a_src_est
            )
            logger.info(
                "[src] container: total_src≈%d kb/s, dur=%s (%.0fs), size=%s (%d B), ext=%s",
                ctx.source_total_kbps_est, dur_txt, ctx.duration_s,
                _fmt_bytes(orig_size), orig_size, ctx.ext_low
            )

        except Exception:
            pass

        # ---- Tentativo 1 (fudge -1% sul limite totale) ----
        limit_try1 = int(safe_limit_bytes * (1.0 - FUDGE_VBR_FIRST_TRY))

        n_audio_eff = len(keep_relidx) if filter_enabled else ctx.n_audio

        plan1 = compute_target_for_size(ctx, limit_try1, n_audio_override=n_audio_eff)

        # logger.info("[check] Tentativo 1 → target_kbps_tot=%s, audio=%s, video=%s, vf_h=%s",
        #             plan1.safe_total_kbps, plan1.target_audio_kbps_total, plan1.v_kbps, plan1.vf_height)

        logger.info(
            "[plan] try#1: total=%s kb/s (v=%s, a=%s), vf_h=%s, limit_fudged=%s (%d B, fudge=%.2f%%), dur=%s (%.0fs)",
            plan1.safe_total_kbps, plan1.v_kbps, plan1.target_audio_kbps_total, plan1.vf_height,
            _fmt_bytes(limit_try1), limit_try1, (FUDGE_VBR_FIRST_TRY * 100.0),
            _fmt_hms(ctx.duration_s), ctx.duration_s
        )

        # Se filtriamo, passiamo la mappa esplicita; altrimenti None per comportamento corrente
        audio_map = keep_relidx if filter_enabled else None
        rc1, el1, out1 = run_encode_once(out_tmp, ctx, plan1, audio_map_relidx=audio_map)

        logger.info("[encode] v_kbps=%s, vf_h=%s, audio_kbps_total=%s, safe_total_kbps=%s, src_v_kbps=%s",
                    plan1.v_kbps, plan1.vf_height, plan1.target_audio_kbps_total,
                    plan1.safe_total_kbps, getattr(plan1, "source_video_kbps_est", "n/a"))

        if rc1 != 0 or out1 <= 0 or not os.path.exists(out_tmp):
            try: send_telegram_message(chat_id, "❌ Compress. fallita (1° tentativo).")
            except Exception: pass
            try:
                if os.path.exists(out_tmp):
                    os.remove(out_tmp)
            except Exception:
                pass
            return {"status": "error", "path": str(src), "msg": "encode1_failed"}

        logger.info("[encode] OK rc=%s, size=%s bytes", rc1, out1)

        if out1 <= safe_limit_bytes:
            # SUCCESSO → sostituisco l'originale
            try:
                dest_path = src.with_suffix(REENCODE_OUTPUT_EXT)
                os.replace(out_tmp, dest_path)
                if dest_path != src:
                    try:
                        os.remove(src)
                    except Exception:
                        pass

                try:
                    eff_n_audio = (len(audio_map) if audio_map is not None else ctx.n_audio)
                    update_mkv_metadata_final(dest_path, src, eff_n_audio)
                    logger.info("[final] mkvpropedit: track statistics tags rigenerati + segment title/default audio impostati.")
                except Exception:
                    pass

                final_size = os.path.getsize(dest_path)

                try:
                    eff_total_kbps_1 = int((final_size * 8) / max(1.0, ctx.duration_s) / 1000)
                    margin_pct_1 = (1.0 - (final_size / safe_limit_bytes)) * 100.0
                    logger.info(
                        "[final] try#1 OK: size=%s (%d B) ≤ limit=%s (%d B), margin=%.2f%%, "
                        "eff_total=%d kb/s vs plan_total=%s, v=%s, vf_h=%s, audio_streams=%d, dest=%s",
                        _fmt_bytes(final_size), final_size,
                        _fmt_bytes(safe_limit_bytes), safe_limit_bytes, margin_pct_1,
                        eff_total_kbps_1, plan1.safe_total_kbps, plan1.v_kbps, plan1.vf_height,
                        (len(audio_map) if filter_enabled else ctx.n_audio),
                        str(dest_path)
                    )

                    size_factor = (orig_size / final_size) if final_size > 0 else 0.0
                    size_redux_pct = 100.0 * (1.0 - (final_size / orig_size))
                    tot_redux_pct  = 100.0 * (1.0 - (eff_total_kbps_1 / max(1, ctx.source_total_kbps_est)))
                    v_redux_pct    = 100.0 * (1.0 - (plan1.v_kbps / max(1, ctx.source_video_kbps_est)))

                    logger.info(
                        "[final] delta#1: total≈%d→%d kb/s (Δ=%.2f%%), v≈%d→%d kb/s (plan, Δ=%.2f%%), "
                        "size %s→%s (x%.2f, Δ=%.2f%%), vf_h=%s, a_streams=%d",
                        ctx.source_total_kbps_est, eff_total_kbps_1, tot_redux_pct,
                        ctx.source_video_kbps_est, plan1.v_kbps, v_redux_pct,
                        _fmt_bytes(orig_size), _fmt_bytes(final_size), size_factor, size_redux_pct,
                        plan1.vf_height, (len(audio_map) if filter_enabled else ctx.n_audio)
                    )

                except Exception:
                    pass

            except Exception:
                try: os.remove(out_tmp)
                except Exception: pass
                final_size = out1  # best effort

            try:
                send_telegram_message(chat_id, f"✅ Compressione ok: {_fmt_bytes(final_size)} (limite {_fmt_bytes(safe_limit_bytes)})")
            except Exception:
                pass

            # return {"status": "encoded_ok", "path": file_path, "size": final_size,
            return {"status": "encoded_ok", "path": dest_path, "size": final_size,
                    "plan": {"vf_height": plan1.vf_height, "v_kbps": plan1.v_kbps,
                             "audio_kbps": plan1.target_audio_kbps_total,
                             "safe_total_kbps": plan1.safe_total_kbps,
                             "src_video_kbps_est": getattr(plan1, "source_video_kbps_est", None)}}

        # ---- Overshoot → valutazione retry ----
        if not RETRY_ON_OVERSHOOT:
            try:
                send_telegram_message(chat_id, f"ℹ️ Output {_fmt_bytes(out1)} > limite ({_fmt_bytes(safe_limit_bytes)}). Retry disattivato.")

                logger.info(
                    "[overshoot] try#1: size=%s (%d B) > limit=%s (%d B) by %.2f%% — retry disattivato.",
                    _fmt_bytes(out1), out1, _fmt_bytes(safe_limit_bytes), safe_limit_bytes,
                    ((out1 - safe_limit_bytes) / safe_limit_bytes) * 100.0
                )

            except Exception:
                pass
            try:
                if os.path.exists(out_tmp):
                    os.remove(out_tmp)
            except Exception:
                pass
            return {"status": "too_big", "path": str(src), "size": out1, "msg": "overshoot_no_retry"}

        overs_bytes = out1 - safe_limit_bytes
        pct = (overs_bytes / safe_limit_bytes) * 100.0
        
        min_floor = _floor_value_min(H265_FLOOR_KBPS)

        # v2 = _compute_retry_v_kbps(ctx.duration_s, out1, safe_limit_bytes, plan1.v_kbps, RETRY_SAFETY_PCT,
                                   # floor_kbps=max(64, int(getattr(plan1, "v_kbps_floor", 200))))

        v2 = _compute_retry_v_kbps(
            ctx.duration_s, out1, safe_limit_bytes, plan1.v_kbps, RETRY_SAFETY_PCT,
            floor_kbps=min_floor
        )

        try:
            plan2 = adjust_plan_after_retry(ctx, plan1, v2)  # usa H265_FLOOR_KBPS e MAX_TARGET_HEIGHT interni/globali
        except EncodeNotFeasible as e:
            # Retry non fattibile (v2 < floor minore, o nessun bucket compatibile) → chiedi split
            try:
                send_telegram_message(chat_id,
                    f"❌ Retry impossibile: {e}. Suggerito split.")
            except Exception:
                pass
            try:
                if os.path.exists(out_tmp):
                    os.remove(out_tmp)
            except Exception:
                pass
            return {"status": "too_big", "path": str(src), "size": out1, "msg": "overshoot_retry_unfeasible"}

        # CHANGE: ora invia il messaggio includendo eventuale cambio di risoluzione
        try:
            send_telegram_message(
                chat_id,
                "ℹ️ Overshoot: {} > limite ({}) di {:.2f}%.\n"
                "Retry: video {} → {} kb/s; risoluzione {} → {}."
                .format(_fmt_bytes(out1), _fmt_bytes(safe_limit_bytes), pct,
                        plan1.v_kbps, plan2.v_kbps, plan1.vf_height, plan2.vf_height)
            )

            logger.info(
                "[overshoot] try#1: size=%s (%d B) > limit=%s (%d B) by %.2f%% — "
                "retry plan: v=%s→%s kb/s, vf_h=%s→%s",
                _fmt_bytes(out1), out1, _fmt_bytes(safe_limit_bytes), safe_limit_bytes, pct,
                plan1.v_kbps, plan2.v_kbps, plan1.vf_height, plan2.vf_height
            )
        except Exception:
            pass

        logger.info(
            "[plan] try#2: total=%s kb/s (v=%s, a=%s), vf_h=%s",
            plan2.safe_total_kbps, plan2.v_kbps, plan2.target_audio_kbps_total, plan2.vf_height
        )

        rc2, el2, out2 = run_encode_once(out_tmp, ctx, plan2, audio_map_relidx=audio_map)
        logger.info("[encode] v_kbps=%s, vf_h=%s, audio_kbps_total=%s, safe_total_kbps=%s, src_v_kbps=%s",
                    plan2.v_kbps, plan2.vf_height, plan2.target_audio_kbps_total,
                    plan2.safe_total_kbps, getattr(plan2, "source_video_kbps_est", "n/a"))

        if rc2 != 0 or out2 <= 0 or not os.path.exists(out_tmp):
            try: send_telegram_message(chat_id, "❌ Compress. fallita (2° tentativo).")
            except Exception: pass
            try:
                if os.path.exists(out_tmp):
                    os.remove(out_tmp)
            except Exception:
                pass
            return {"status": "error", "path": str(src), "msg": "encode2_failed"}

        logger.info("[encode] OK rc=%s, size=%s bytes", rc2, out2)

        if out2 <= safe_limit_bytes:
            try:
                dest_path = src.with_suffix(REENCODE_OUTPUT_EXT)
                os.replace(out_tmp, dest_path)
                if dest_path != src:
                    try:
                        os.remove(src)
                    except Exception:
                        pass

                try:
                    eff_n_audio = (len(audio_map) if audio_map is not None else ctx.n_audio)
                    update_mkv_metadata_final(dest_path, src, eff_n_audio)
                    logger.info("[final] mkvpropedit: track statistics tags rigenerati + segment title/default audio impostati.")
                except Exception:
                    pass

                final_size = os.path.getsize(dest_path)

                try:
                    eff_total_kbps_2 = int((final_size * 8) / max(1.0, ctx.duration_s) / 1000)
                    margin_pct_2 = (1.0 - (final_size / safe_limit_bytes)) * 100.0
                    logger.info(
                        "[final] try#2 OK: size=%s (%d B) ≤ limit=%s (%d B), margin=%.2f%%, "
                        "eff_total=%d kb/s vs plan_total=%s, v=%s, vf_h=%s, audio_streams=%d, dest=%s",
                        _fmt_bytes(final_size), final_size,
                        _fmt_bytes(safe_limit_bytes), safe_limit_bytes, margin_pct_2,
                        eff_total_kbps_2, plan2.safe_total_kbps, plan2.v_kbps, plan2.vf_height,
                        (len(audio_map) if filter_enabled else ctx.n_audio),
                        str(dest_path)
                    )

                    size_factor = (orig_size / final_size) if final_size > 0 else 0.0
                    size_redux_pct = 100.0 * (1.0 - (final_size / orig_size))
                    tot_redux_pct  = 100.0 * (1.0 - (eff_total_kbps_2 / max(1, ctx.source_total_kbps_est)))
                    v_redux_pct    = 100.0 * (1.0 - (plan2.v_kbps / max(1, ctx.source_video_kbps_est)))

                    logger.info(
                        "[final] delta#2: total≈%d→%d kb/s (Δ=%.2f%%), v≈%d→%d kb/s (plan, Δ=%.2f%%), "
                        "size %s→%s (x%.2f, Δ=%.2f%%), vf_h=%s, a_streams=%d",
                        ctx.source_total_kbps_est, eff_total_kbps_2, tot_redux_pct,
                        ctx.source_video_kbps_est, plan2.v_kbps, v_redux_pct,
                        _fmt_bytes(orig_size), _fmt_bytes(final_size), size_factor, size_redux_pct,
                        plan2.vf_height, (len(audio_map) if filter_enabled else ctx.n_audio)
                    )

                except Exception:
                    pass

            except Exception:
                try: os.remove(out_tmp)
                except Exception: pass
                final_size = out2

            try:
                send_telegram_message(chat_id, f"✅ Compressione ok al 2° tentativo: {_fmt_bytes(final_size)} (limite {_fmt_bytes(safe_limit_bytes)})")
            except Exception:
                pass

            # return {"status": "encoded_ok", "path": file_path, "size": final_size,
            return {"status": "encoded_ok", "path": dest_path, "size": final_size,
                    "plan": {"vf_height": plan2.vf_height, "v_kbps": plan2.v_kbps,
                             "audio_kbps": plan2.target_audio_kbps_total,
                             "safe_total_kbps": plan2.safe_total_kbps,
                             "src_video_kbps_est": getattr(plan2, "source_video_kbps_est", None)}}

        # Ancora troppo grande → lasciare la decisione di split al chiamante
        try:
            if os.path.exists(out_tmp):
                os.remove(out_tmp)
        except Exception:
            pass
        try:
            send_telegram_message(chat_id, f"ℹ️ Output ancora > limite ({_fmt_bytes(safe_limit_bytes)}).")
        except Exception:
            pass

        logger.info(
            "[final] try#2 still too big: size=%s (%d B) > limit=%s (%d B) by %.2f%% — split demandato al chiamante.",
            _fmt_bytes(out2), out2, _fmt_bytes(safe_limit_bytes), safe_limit_bytes,
            ((out2 - safe_limit_bytes) / safe_limit_bytes) * 100.0
        )

        return {"status": "too_big", "path": str(src), "size": out2, "msg": "overshoot_after_retry"}

    except Exception as e:
        logger.exception("[check_file] eccezione: %s", e)
        try: send_telegram_message(chat_id, f"❌ Errore check_file: {e}")
        except Exception: pass
        return {"status": "error", "path": None, "msg": str(e)}

def parse_manual_args():
    p = argparse.ArgumentParser(prog="manual")
    p.add_argument("--file", required=True)
    p.add_argument("--chat-id", required=True, type=int)
    p.add_argument("--entity", required=False, default=None)
    p.add_argument('--split', metavar='MB', type=int, default=None, help='Forza lo split manuale, dimensione massima di ogni parte in MB')
    return p.parse_args(sys.argv[2:])

def run_manual_send(file_path, chat_id, entity, split_mb=None):
    file_path = str(Path(file_path).resolve())
    logger.info(f"[MANUAL] file={file_path} chat_id={chat_id} entity={entity} split={split_mb}")

    if not os.path.exists(file_path):
        send_telegram_message(chat_id, "❌ File non trovato")
        return 1

    # 1) stessa sequenza del flusso *arr: prima compressione (se serve)
    try:
        check_file(file_path, chat_id)
    except Exception as e:
        logger.warning(f"[MANUAL] check_file ha segnalato: {e}")

    # Nessuno split richiesto
    if split_mb is None:
         ok = send_file_to_telegram(file_path, chat_id, entity)
         return 0 if ok else 1

    # Split richiesto: uso il valore passato in MB come hard limit per parte
    if split_mb <= 0:
        send_telegram_message(chat_id, "❌ Valore --split non valido (deve essere > 0)")
        return 1

    target_bytes = split_mb * 1024 * 1024
    # Evita di superare il limite telegrafico (safe_limit_bytes): clamp e avviso
    if target_bytes > safe_limit_bytes:
        logger.warning(f"[MANUAL] --split={split_mb}MB supera il limite per parte; verrà usato {int(safe_limit_bytes/1024/1024)}MB")
        target_bytes = safe_limit_bytes

    logger.info(f"[MANUAL] Forzo split in parti da ~{int(target_bytes/1024/1024)} MB")
    parts = split_into_streamable_parts(file_path, target_bytes, chat_id)

    if not parts:
        send_telegram_message(chat_id, "❌ Split manuale fallito")
        return 1

    # Invio parti e pulizia
    sent_all = True
    for p in parts:
        ok = send_file_to_telegram(p, chat_id, entity)
        if not ok:
            sent_all = False
            break

    # Cancella solo le parti create (non il file originale)
    if sent_all:
        orig = os.path.abspath(file_path)
        for p in parts:
            try:
                if os.path.abspath(p) != orig:
                    os.remove(p)
            except Exception:
                pass
    return 0 if sent_all else 1

def send_telegram_message(chat_id, message, parse_mode=None):
    # pass
    try:
        data = {
            "chat_id": chat_id,
            "text": message
        }
        if parse_mode:
            data["parse_mode"] = parse_mode

        response = requests.post(
            f"https://api.telegram.org/bot{telegram_bot_token}/sendMessage",
            data=data
        )
        response.raise_for_status()
    except requests.RequestException as e:
        logger.error("Failed to send message to Telegram: %s", str(e))

def extract_telegram_data(tags, keyword, extract_type='username'):
    if not tags:
        return 'Unknown user' if extract_type == 'username' else 'Unknown chatid'
    
    tag_list = tags.split('|')
    relevant_tags = [tag for tag in tag_list if keyword in tag]
    
    if not relevant_tags:
        return 'Unknown user' if extract_type == 'username' else 'Unknown chatid'

    last_tag = relevant_tags[-1]
    parts = last_tag.split('-')

    if len(parts) < 3:
        logger.error(f"Invalid '{last_tag}' tag: must contain 3 parts separated by '-'")

    if extract_type == 'username':
        return '@' + parts[1]
    elif extract_type == 'chatid':
        return parts[2]
    else:
        raise ValueError("extract_type must be 'username' or 'chatid'")

def get_unique_tags(tags, keyword):
    if not tags:
        return []

    tag_list = tags.split('|')
    relevant_tags = [tag for tag in tag_list if keyword in tag]
    unique_tags = []

    for tag in relevant_tags:
        username = extract_telegram_data(tag, keyword, 'username')
        if username not in [extract_telegram_data(t, keyword, 'username') for t in unique_tags]:
            unique_tags.append(tag)
    return unique_tags

def humanize_size(size_in_bytes):
    size_in_mb = size_in_bytes / 1000000.0  # Convert to MB
    if size_in_mb > 1000:
        return f"{size_in_mb / 1000:.2f} GB"
    else:
        return f"{size_in_mb:.0f} MB"

def get_media_info(file_path):
    """
    Ritorna un dict con:
      duration_s, width, height,
      total_bitrate_kbps (se ffprobe lo fornisce),
      n_audio, audio_kbps_total (somma bit_rate stream audio se disponibili),
      v_codec, pix_fmt, color_primaries, transfer, matrix,
      is_10bit, is_hdr
    """
    base_cmd = [
        'docker','run','--rm',
        '-e','DOCKER_CONFIG=/dev/null',
        '--entrypoint','/usr/bin/ffprobe',
        '-v', docker_mount_point,
        'ghcr.io/aperim/nvidia-cuda-ffmpeg:latest',
        '-v','error',
        '-print_format','json',
        '-show_format','-show_streams',
        file_path
    ]
    try:
        out = subprocess.check_output(base_cmd).decode('utf-8', errors='ignore')
        import json
        data = json.loads(out)
        fmt = data.get('format', {}) or {}
        streams = data.get('streams', []) or []

        duration_s = float(fmt.get('duration') or 0) or 0.0
        total_bitrate_kbps = None
        if fmt.get('bit_rate'):
            try:
                total_bitrate_kbps = int(int(fmt['bit_rate'])/1000)
            except Exception:
                total_bitrate_kbps = None

        v = next((s for s in streams if s.get('codec_type')=='video'), None)
        width = (v or {}).get('width') or 0
        height = (v or {}).get('height') or 0
        v_codec = (v or {}).get('codec_name') or ''
        pix_fmt = (v or {}).get('pix_fmt') or ''
        color_primaries = (v or {}).get('color_primaries') or ''
        transfer = (v or {}).get('color_transfer') or ''
        matrix = (v or {}).get('color_space') or ''

        is_10bit = ('10' in (v or {}).get('bits_per_raw_sample','')) or pix_fmt in ('p010le','yuv420p10le')
        # HDR rozza: BT.2020 o transfer SMPTE2084/HLG
        is_hdr = ('bt2020' in (color_primaries or '').lower()) or ('smpte2084' in (transfer or '').lower()) or ('arib-std-b67' in (transfer or '').lower())

        # audio
        a_streams = [s for s in streams if s.get('codec_type')=='audio']
        n_audio = len(a_streams)
        audio_kbps_total = 0
        for a in a_streams:
            try:
                br = int(a.get('bit_rate') or 0)
                if br > 0:
                    audio_kbps_total += int(br/1000)
                else:
                    audio_kbps_total += AUDIO_KBPS_PER_TRACK
            except Exception:
                audio_kbps_total += AUDIO_KBPS_PER_TRACK
        if n_audio == 0:
            audio_kbps_total = 0

        return {
            'duration_s': max(1.0, duration_s),
            'width': int(width or 0),
            'height': int(height or 0),
            'total_bitrate_kbps': total_bitrate_kbps,
            'n_audio': n_audio,
            'audio_kbps_total': int(audio_kbps_total),
            'v_codec': v_codec,
            'pix_fmt': pix_fmt,