CCCPaste

eee01785d6e4c

def _compute_bpp_proxy(ctx: EncodeContext) -> float:
    """
    Proxy estremamente semplificato del "bitrate per pixel".
    Restituisce kbps per megapixel circa (solo per logging/euristica).
    """
    pixels = max(1, ctx.width * ctx.height)
    mpix = pixels / 1_000_000.0
    return ctx.source_video_kbps_est / mpix if mpix > 0 else ctx.source_video_kbps_est


def classify_complexity_heuristic(ctx: EncodeContext) -> Complexity:
    """
    Classificazione grossolana LOW/MID/HIGH basata sul "kbps per megapixel".
    DA SOSTITUIRE/RAFFINARE con SI/TI + VMAF nei passi successivi.
    """
    bpp_proxy = _compute_bpp_proxy(ctx)
    # Soglie iniziali, da ritarare:
    # - sotto ~700 kb/s per MPix → LOW
    # - tra 700 e 1300 → MID
    # - sopra 1300 → HIGH
    if bpp_proxy < 700:
        comp = Complexity.LOW
    elif bpp_proxy < 1300:
        comp = Complexity.MID
    else:
        comp = Complexity.HIGH

    logger.info(
        "[smart] complessità euristica: bpp_proxy≈%.1f kb/s/MPix → %s",
        bpp_proxy, comp.value
    )
    return comp

@dataclass
class SmartPlan:
    complexity: Complexity
    vf_height: int
    v_kbps: int
    floor_kbps: int
    max_video_kbps_by_limit: int
    limit_total_kbps: int
    target_audio_kbps_total: int

def smart_pick_bucket_and_bitrate(
    ctx: EncodeContext,
    limit_bytes: int,
    n_audio_eff: int,
    complexity: Complexity,
) -> SmartPlan:
    """
    Sceglie bucket (vf_height) e v_kbps "smart" rispettando:
    - limite di dimensione (limit_bytes),
    - numero di tracce audio,
    - floors SMART_FLOORS_KBPS[bucket][complexity],
    - MAX_TARGET_HEIGHT,
    - non superare il bitrate video stimato della sorgente.
    """

    if ctx.duration_s <= 0:
        raise ValueError("Durata non valida in smart_pick_bucket_and_bitrate")

    # Stima kb/s consentiti dal limite.
    limit_total_kbps = int((limit_bytes * 8) / ctx.duration_s / 1000.0)
    target_audio_kbps_total = max(0, n_audio_eff * AUDIO_KBPS_PER_TRACK)

    max_video_kbps_by_limit = max(
        MIN_VIDEO_KBPS,
        limit_total_kbps - target_audio_kbps_total
    )

    logger.info(
        "[smart] limite bytes=%s (%d B) → limit_total_kbps≈%d, "
        "audio_kbps_total_target=%d, max_v_by_limit≈%d",
        _fmt_bytes(limit_bytes),
        limit_bytes,
        limit_total_kbps,
        target_audio_kbps_total,
        max_video_kbps_by_limit,
    )

    # Nessun senso aumentare oltre il sorgente.
    max_video_kbps_global = min(max_video_kbps_by_limit, ctx.source_video_kbps_est)

    # ---- Candidati bucket (risoluzioni) ----
    # Niente upscale: non superiamo ctx.height, e rispettiamo MAX_TARGET_HEIGHT.
    max_h = min(ctx.height, MAX_TARGET_HEIGHT)
    candidate_heights = sorted(
        [h for h in SMART_FLOORS_KBPS.keys() if h <= max_h]
    )
    if not candidate_heights:
        # Se il video è addirittura più piccolo del bucket minimo, usiamo il più basso
        # (la tua logica di scale/no-scale si valuterà poi in ffmpeg).
        candidate_heights = [min(SMART_FLOORS_KBPS.keys())]

    logger.info(
        "[smart] candidate buckets (<=%d): %s",
        max_h, ", ".join(str(h) for h in candidate_heights)
    )

    chosen = None

    # Proviamo dal bucket più alto verso il basso.
    for h in sorted(candidate_heights, reverse=True):
        floors = SMART_FLOORS_KBPS[h]
        floor_kbps = floors[complexity.value]

        # v_kbps massimo che possiamo permetterci su questo bucket.
        v_kbps_candidate = max_video_kbps_global

        logger.info(
            "[smart] bucket %d: floor=%d kb/s (complexity=%s), "
            "max_v_global=%d kb/s",
            h, floor_kbps, complexity.value, v_kbps_candidate
        )

        if v_kbps_candidate < floor_kbps:
            logger.info(
                "[smart] bucket %d scartato: v_kbps_candidate=%d < floor=%d",
                h, v_kbps_candidate, floor_kbps
            )
            continue

        # Se arriviamo qui, questo bucket è fattibile.
        chosen = SmartPlan(
            complexity=complexity,
            vf_height=h,
            v_kbps=v_kbps_candidate,
            floor_kbps=floor_kbps,
            max_video_kbps_by_limit=max_video_kbps_by_limit,
            limit_total_kbps=limit_total_kbps,
            target_audio_kbps_total=target_audio_kbps_total,
        )
        logger.info(
            "[smart] bucket %d fattibile: scelgo v_kbps=%d (≥ floor=%d).",
            h, v_kbps_candidate, floor_kbps
        )
        break

    if chosen is None:
        # Nessun bucket compatibile col limite → al chiamante la decisione di split.
        msg = (
            "Nessun bucket compatibile con il limite: "
            f"max_video_kbps_global={max_video_kbps_global}, "
            f"min_floor={min(f[complexity.value] for f in SMART_FLOORS_KBPS.values())}"
        )
        logger.info("[smart] %s", msg)
        raise EncodeNotFeasible(msg)

    return chosen

def smart_plan_for_size(
    ctx: EncodeContext,
    limit_bytes: int,
    n_audio_eff: int,
) -> SmartPlan:
    """
    Wrapper: classifica la complessità e poi sceglie bucket+bitrate.
    (Per ora usa solo l'euristica bpp_proxy; in un secondo step si
    potranno aggiungere SI/TI + VMAF e ricalibrare complexity.)
    """
    logger.info(
        "[smart] calcolo piano smart per durata=%s (%.0fs), "
        "limit_bytes=%s (%d B), n_audio_eff=%d",
        _fmt_hms(ctx.duration_s),
        ctx.duration_s,
        _fmt_bytes(limit_bytes),
        limit_bytes,
        n_audio_eff,
    )

    complexity = classify_complexity_heuristic(ctx)
    plan = smart_pick_bucket_and_bitrate(
        ctx=ctx,
        limit_bytes=limit_bytes,
        n_audio_eff=n_audio_eff,
        complexity=complexity,
    )

    logger.info(
        "[smart] piano scelto: complexity=%s, vf_h=%d, v_kbps=%d, "
        "floor=%d, limit_total_kbps=%d, audio_kbps_total=%d",
        plan.complexity.value,
        plan.vf_height,
        plan.v_kbps,
        plan.floor_kbps,
        plan.limit_total_kbps,
        plan.target_audio_kbps_total,
    )
    return plan

def smart_check_file(file_path: str, chat_id: int, item_id: int, app) -> dict:
    """
    Variante 'smart' di check_file che:
    - Se originale <= limite: original_ok (come check_file).
    - Altrimenti:
      - decide quali audio tenere (loggando),
      - estrae EncodeContext,
      - calcola un piano video 'smart' (complessità, bucket, v_kbps),
      - NON lancia encode, ma ritorna il piano.
    Serve per testare/loggare il comportamento prima di integrare l'encode reale.
    """
    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

        # safe_limit_bytes è globale (come nella tua check_file)
        if orig_size <= safe_limit_bytes:
            logger.info(
                "[smart_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,
            }

        # ---- app / profilo lingua (come in check_file) ----
        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
        )

        # ---- Analisi tracce audio + decisione keep/remove ----
        langs = _analyze_audio_languages_with_docker(src)
        filter_enabled, keep_relidx, notify_msg, summary_str = _decide_audio_selection(
            profile_language, langs
        )

        logger.info("[smart_audio] Tracce: %s", summary_str)
        if notify_msg:
            try:
                send_telegram_message(
                    owner_chatid,
                    f"ℹ️ {src.name}: {notify_msg}"
                )
            except Exception:
                pass

        ctx = extract_encode_context(str(src))

        # Log 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(
                "[smart_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(
                "[smart_audio] selection: keep=all (%d/%d), remove=(none)",
                ctx.n_audio,
                ctx.n_audio,
            )

        dur_txt = _fmt_hms(ctx.duration_s)

        # Log sorgente
        try:
            send_telegram_message(
                chat_id,
                "[SMART] Analisi compressione GPU (HEVC NVENC)… (solo test piano)"
            )
            a_src_est = max(0, ctx.source_total_kbps_est - ctx.source_video_kbps_est)
            logger.info(
                "[smart_src] name=%s, dur=%s (%.0fs), size=%s (%d B), "
                "limit=%s (%d B), app=%s, profile_lang=%s",
                src.name,
                dur_txt,
                ctx.duration_s,
                _fmt_bytes(orig_size),
                orig_size,
                _fmt_bytes(safe_limit_bytes),
                safe_limit_bytes,
                app,
                profile_language or "n/a",
            )
            logger.info(
                "[smart_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(
                "[smart_src] audio: tracks=%d, a_src_est≈%d kb/s",
                ctx.n_audio,
                a_src_est,
            )
            logger.info(
                "[smart_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

        # ---- Calcolo piano smart (NIENTE ENCODE) ----
        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

        logger.info(
            "[smart_plan] uso limit_try1=%s (%d B, fudge=%.2f%%), n_audio_eff=%d",
            _fmt_bytes(limit_try1),
            limit_try1,
            FUDGE_VBR_FIRST_TRY * 100.0,
            n_audio_eff,
        )

        try:
            smart_plan = smart_plan_for_size(
                ctx=ctx,
                limit_bytes=limit_try1,
                n_audio_eff=n_audio_eff,
            )
        except EncodeNotFeasible as e:
            # Pianificazione non possibile → segnaliamo che servirà split
            logger.info(
                "[smart_plan] Encode non fattibile con i floors smart: %s", e
            )
            try:
                send_telegram_message(
                    chat_id,
                    f"❌ [SMART] Impossibile rispettare i floor minimi con il limite: {e}\n"
                    "Suggerito split/gestione a monte."
                )
            except Exception:
                pass
            return {
                "status": "too_big",
                "path": str(src),
                "size": orig_size,
                "msg": "smart_encode_not_feasible",
            }

        # --- TEST REALI SOLO PIANO (senza telegram pian, senza smart_final) ---
        if SMART_TEST_ENABLED:
            logger.info("[smart_test] abilitato: avvio test reali (mini-encode) sul piano smart")
            try:
                smart_run_tests(
                    src=src,
                    ctx=ctx,
                    smart_plan=smart_plan,
                    keep_relidx=keep_relidx,
                    filter_enabled=filter_enabled,
                )
            except Exception as te:
                logger.exception("[smart_test] errore durante i test smart: %s", te)

            logger.info("[smart_test] abilitato: test completati, ritorno solo il piano (nessun messaggio Telegram piano / smart_final).")

            # Qui NON proseguiamo con i messaggi Telegram e il log [smart_final]
            return {
                "status": "smart_plan_test",
                "path": str(src),
                "size": orig_size,
                "plan": {
                    "complexity": smart_plan.complexity.value,
                    "vf_height": smart_plan.vf_height,
                    "v_kbps": smart_plan.v_kbps,
                    "floor_kbps": smart_plan.floor_kbps,
                    "audio_kbps_total": smart_plan.target_audio_kbps_total,
                    "limit_total_kbps": smart_plan.limit_total_kbps,
                },
            }
        # ----------------------------------------------------------------------

        # Riepilogo finale del piano (solo log, nessun encode)
        try:
            send_telegram_message(
                chat_id,
                "ℹ️ [SMART] Piano stimato:\n"
                f"- complessità: {smart_plan.complexity.value}\n"
                f"- vf_height: {smart_plan.vf_height}\n"
                f"- v_kbps: ~{smart_plan.v_kbps} kb/s\n"
                f"- audio totale: ~{smart_plan.target_audio_kbps_total} kb/s\n"
                f"- totale piano: ~{smart_plan.limit_total_kbps} kb/s (limite fudgiato)"
            )
        except Exception:
            pass

        logger.info(
            "[smart_final] piano: complexity=%s, vf_h=%d, v_kbps=%d, "
            "floor=%d, audio_kbps_total=%d, limit_total_kbps≈%d, "
            "safe_limit_bytes=%d (%s)",
            smart_plan.complexity.value,
            smart_plan.vf_height,
            smart_plan.v_kbps,
            smart_plan.floor_kbps,
            smart_plan.target_audio_kbps_total,
            smart_plan.limit_total_kbps,
            safe_limit_bytes,
            _fmt_bytes(safe_limit_bytes),
        )

        # Ritorniamo SOLO il piano (nessun file creato/ritoccato)
        return {
            "status": "smart_plan",
            "path": str(src),
            "size": orig_size,
            "plan": {
                "complexity": smart_plan.complexity.value,
                "vf_height": smart_plan.vf_height,
                "v_kbps": smart_plan.v_kbps,
                "floor_kbps": smart_plan.floor_kbps,
                "audio_kbps_total": smart_plan.target_audio_kbps_total,
                "limit_total_kbps": smart_plan.limit_total_kbps,
            },
        }

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

def _smart_make_temp_dir(src: Path) -> Path:
    """
    Crea una cartella temporanea nello stesso percorso del file sorgente.
    Es: /path/file.mkv -> /path/.mtb_smarttmp_file
    Se esiste già, la cancella e la ricrea.
    """
    base_dir = src.parent
    # uso il nome del file senza estensione per distinguere i vari test
    temp_name = f".mtb_smarttmp_{src.stem}"
    temp_dir = base_dir / temp_name

    # se esiste, rimuovo tutto
    if temp_dir.exists():
        try:
            shutil.rmtree(temp_dir)
        except Exception as e:
            logger.warning("[smart_test] impossibile rimuovere temp_dir esistente %s: %s", temp_dir, e)

    try:
        temp_dir.mkdir(parents=False, exist_ok=False)
    except Exception as e:
        logger.warning("[smart_test] impossibile creare temp_dir %s: %s", temp_dir, e)
        # provo a usare una sottocartella random dentro base_dir
        import uuid
        temp_dir = base_dir / f".mtb_smarttmp_{src.stem}_{uuid.uuid4().hex[:8]}"
        temp_dir.mkdir(parents=False, exist_ok=False)

    logger.info("[smart_test] temp_dir creata: %s", temp_dir)
    return temp_dir

def _smart_pick_test_segments(duration_s: float) -> list[tuple[float, float]]:
    """
    Ritorna una lista di (t_start, t_dur) in secondi per gli spezzoni di test.
    Usa le percentuali SMART_TEST_SEGMENT_ANCHORS (0–100) e SMART_TEST_SEGMENT_SEC.
    """
    duration_s = max(1.0, float(duration_s or 0.0))
    seg_dur = min(SMART_TEST_SEGMENT_SEC, max(5.0, duration_s / 20.0))

    segments: list[tuple[float, float]] = []
    for anchor in SMART_TEST_SEGMENT_ANCHORS:
        # anchor è percentuale: 10, 50, 90 → 0.1, 0.5, 0.9
        t_start = duration_s * (anchor / 100.0)
        t_start = max(0.0, min(duration_s - seg_dur, t_start))
        t_start = round(t_start, 3)
        segments.append((t_start, round(seg_dur, 3)))

    # Elimina duplicati grossolani (se il video è corto)
    unique_segments: list[tuple[float, float]] = []
    seen = set()
    for t_start, t_dur in segments:
        key = int(t_start)
        if key in seen:
            continue
        seen.add(key)
        unique_segments.append((t_start, t_dur))

    logger.info("[smart_test] segments scelti (t_start, t_dur): %s", unique_segments)
    return unique_segments

def _nvenc_cmd_segment(
    file_path: str,
    out_path: str,
    vkbps: int,
    vf_h: int,
    is_10bit: bool,   # non usato qui ma tenuto per coerenza
    n_audio: int,
    ext_low: str,
    t_start: float,
    t_dur: float,
    audio_map_relidx: list[int] | None = None,
    force_stereo: bool = FORCE_STEREO,
    use_dplii_matrix: bool = True,
    keep_subtitles: bool = True,
) -> list[str]:
    """
    Come _nvenc_cmd, ma limitato a uno spezzone [t_start, t_start+t_dur].
    Usa HEVC NVENC e lo stesso mapping audio/sottotitoli della produzione.
    """
    cmd = _docker_ffmpeg_args(use_gpu=True) + [
        "-y", "-nostdin", "-hide_banner", "-loglevel", "error",
        "-hwaccel", "cuda",
        "-ss", str(t_start),
        "-i", file_path,
        "-t", str(t_dur),
        "-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"]  # droppa 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
    else:
        eff_n_subs = 0

    # --- Video (stessa logica della produzione) ---
    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"]
        cmd += ["-b:a", f"{AUDIO_KBPS_PER_TRACK}k"]
        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:
                cmd += [f"-filter:a:{i}", "aresample=matrix_encoding=dplii"]
            cmd += [f"-metadata:s:a:{i}", f"title=AAC LC 2.0 {AUDIO_KBPS_PER_TRACK} kbps"]

        cmd += ["-disposition:a:0", "default"]
        for i in range(1, eff_n_audio):
            cmd += [f"-disposition:a:{i}", "0"]
    else:
        cmd += ["-an"]

    # --- Sottotitoli ---
    if keep_subtitles and eff_n_subs:
        cmd += ["-c:s", "copy"]
        cmd += ["-disposition:s", "0"]

    # --- Metadati/Capitoli del container ---
    cmd += ["-map_metadata", "-1"]

    # Output
    cmd.append(out_path)
    return cmd

def smart_run_tests(
    src: Path,
    ctx: EncodeContext,
    smart_plan: SmartPlan,
    keep_relidx: list[int],
    filter_enabled: bool,
) -> None:
    """
    Esegue mini-encode di test in una cartella temporanea accanto al file.
    Usa il piano smart (vf_height, v_kbps) e la stessa selezione audio.
    Alla fine cancella la cartella temporanea.
    """
    temp_dir = _smart_make_temp_dir(src)
    segments = _smart_pick_test_segments(ctx.duration_s)

    # Determina audio_map_relidx da passare a ffmpeg
    audio_map_relidx = keep_relidx if filter_enabled and keep_relidx else None

    try:
        for idx, (t_start, t_dur) in enumerate(segments, start=1):
            out_path = temp_dir / f"smarttest_{idx:02d}.mkv"
            cmd = _nvenc_cmd_segment(
                file_path=str(src),
                out_path=str(out_path),
                vkbps=int(smart_plan.v_kbps),
                vf_h=int(smart_plan.vf_height),
                is_10bit=ctx.is_10bit,
                n_audio=ctx.n_audio,
                ext_low=ctx.ext_low,
                t_start=t_start,
                t_dur=t_dur,
                audio_map_relidx=audio_map_relidx,
            )

            logger.info(
                "[smart_test] sample#%d: t_start=%.2fs, t_dur=%.2fs, "
                "vf_h=%d, v_kbps=%d, out=%s",
                idx, t_start, t_dur,
                smart_plan.vf_height, smart_plan.v_kbps,
                out_path,
            )

            rc, elapsed, stderr = _run(cmd)
            if rc != 0:
                tail = (stderr[-400:] if stderr else "")
                logger.error(
                    "[smart_test] sample#%d FAILED: rc=%s, elapsed=%.1fs, stderr_tail=%s",
                    idx, rc, elapsed, tail
                )
            else:
                try:
                    out_size = os.path.getsize(out_path)
                except Exception:
                    out_size = -1
                logger.info(
                    "[smart_test] sample#%d OK: rc=0, elapsed=%.1fs, size=%s (%d B)",
                    idx, elapsed, _fmt_bytes(out_size), out_size
                )

    finally:
        # cleanup della cartella temporanea
        try:
            shutil.rmtree(temp_dir, ignore_errors=True)
            logger.info("[smart_test] cleanup temp_dir: %s", temp_dir)
        except Exception as e:
            logger.warning("[smart_test] errore rimozione temp_dir %s: %s", temp_dir, e)