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",
}
# 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)}