Archives
Trending
Support
Login
clear text
XML
Django
JavaScript
MATLAB
C
C++
C#
Python
SQL
Shell
Bash
Markdown
YAML
JSON
HTML
CSS
PHP
Java
Ruby
Go
Rust
Swift
Kotlin
Arduino
TypeScript
Perl
Autohotkey
Lua
SQF
R
Scala
Haskell
Groovy
Dart
Clojure
VB.NET
Objective-C
PowerShell
Bash
CoffeeScript
Verilog
#!/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,
Mark as private (unlisted)
for 30 minutes
for 6 hours
for 1 day
for 1 week
for 1 month
for 1 year
everlasting (like CCCP)