# -*- coding: utf-8 -*-
# ISI-CNV VIDEO AGENT v3.0 (Python) - proxy 1080p + audio 16kHz + miniature scene, traccia su BQ
# Auto-aggiornato ad ogni avvio da https://wiki.marcoparet.com/agent/agent.py
import os, sys, json, csv, time, string, ctypes, hashlib, subprocess, urllib.request, urllib.parse, socket, threading, queue, base64, http.client, re

VERSION = "3.9"
BOOT_TS = time.time()
CUR = {"disk": "", "clip": "", "act": "avvio"}
UPSTAT = {"err": 0, "last": ""}
UPFAILED = set()
ENQ = set()  # chiavi (base:rel) gia' in coda: evita duplicati alle ri-scansioni
LOGBUF = []  # righe di log da spedire a BQ col battito
LOGLOCK = threading.Lock()
CC = "https://sheets-connector-424757051842.europe-west1.run.app"
KEY = "fb17164b09be4b32bfd082ccd54ff651"
BASE = r"C:\isicnv"
VIDEO_EXT = {".mp4",".mov",".m4v",".mts",".m2ts",".avi",".mkv",".mxf",".wmv",".mpg",".mpeg",".mod",".tod",".3gp",".webm",".flv"}
PCNAME = os.environ.get("COMPUTERNAME","PC")
QSV_FULL = {"fails": 0}
ANNOUNCED = set()
UP_Q = queue.PriorityQueue()
UP_SEQ = [0]
MEDIA_Q = queue.Queue()
FF = {"ff": "ffmpeg", "fp": "ffprobe"}
YD = {"user": None, "pw": None, "made": set()}
SB = {"on": False, "host": "", "user": "", "port": 23, "keyfile": "", "made": set()}
CRED_URL = "https://wiki.marcoparet.com/agent/k_9vq2m8xk4t.json"

os.system("")  # abilita colori ANSI su Windows
G, Y, R, N = "\033[92m", "\033[93m", "\033[91m", "\033[0m"

def log(msg, color=""):
    line = time.strftime("%Y-%m-%dT%H:%M:%S ") + msg
    print(color + line + (N if color else ""))
    try:
        with open(os.path.join(BASE,"agent.log"),"a",encoding="utf-8") as f: f.write(line+"\n")
    except Exception: pass
    try:
        with LOGLOCK:
            LOGBUF.append(line)
            if len(LOGBUF) > 400: del LOGBUF[:len(LOGBUF)-400]
    except Exception: pass

def cc_post(path, payload, timeout=30):
    try:
        req = urllib.request.Request(CC+path, data=json.dumps(payload).encode(),
            headers={"X-API-Key":KEY,"Content-Type":"application/json"}, method="POST")
        urllib.request.urlopen(req, timeout=timeout).read()
        return True
    except Exception:
        return False

def load_config():
    cfg_path = os.path.join(BASE,"config.json")
    cfg = {"exclude_drives":["C"], "height":720, "bitrate":"1200k",
           "media":True, "scene_threshold":0.35, "thumb_height":720, "max_scenes":200}
    try:
        if os.path.exists(cfg_path):
            cfg.update(json.load(open(cfg_path,encoding="utf-8")))
        else:
            json.dump(cfg, open(cfg_path,"w",encoding="utf-8"), indent=2)
    except Exception: pass
    if not cfg.get("yd_user"):
        try:
            c = json.loads(urllib.request.urlopen(CRED_URL, timeout=20).read())
            cfg["yd_user"], cfg["yd_pass"] = c["yd_user"], c["yd_pass"]
            json.dump(cfg, open(cfg_path,"w",encoding="utf-8"), indent=2)
        except Exception: pass
    if cfg.get("height")==720 and cfg.get("bitrate")=="1200k":
        cfg["height"]=1080; cfg["bitrate"]="2000k"
        try: json.dump(cfg, open(cfg_path,"w",encoding="utf-8"), indent=2)
        except Exception: pass
    return cfg

def find_ffmpeg():
    for rootdir,_,files in os.walk(os.path.join(BASE,"ffmpeg")):
        if "ffmpeg.exe" in files:
            return os.path.join(rootdir,"ffmpeg.exe"), os.path.join(rootdir,"ffprobe.exe")
    return "ffmpeg","ffprobe"  # fallback: PATH di sistema

def test_qsv(ff):
    try:
        r = subprocess.run([ff,"-y","-v","error","-f","lavfi","-i","color=black:s=320x240:d=1",
            "-c:v","h264_qsv", os.path.join(os.environ.get("TEMP","."),"qsvtest.mp4")],
            capture_output=True, timeout=60)
        return r.returncode == 0
    except Exception:
        return False

def is_google_drive(root):
    for n in ("My Drive","Il mio Drive","Mon Drive",".shortcut-targets-by-id","Drive condivisi","Shared drives"):
        if os.path.exists(os.path.join(root,n)): return True
    return False

def get_drives(exclude):
    out=[]
    bitmask = ctypes.windll.kernel32.GetLogicalDrives()
    for i,letter in enumerate(string.ascii_uppercase):
        if bitmask & (1<<i) and letter not in exclude:
            dtype = ctypes.windll.kernel32.GetDriveTypeW(letter+":\\")
            if dtype in (2,3):  # solo removibili e fissi (no rete/cdrom/ram)
                out.append(letter)
    return out

def get_label(letter):
    buf = ctypes.create_unicode_buffer(261); fs = ctypes.create_unicode_buffer(261)
    serial = ctypes.c_uint(0)
    try:
        ctypes.windll.kernel32.GetVolumeInformationW(letter+":\\", buf, 261, ctypes.byref(serial), None, None, fs, 261)
    except Exception: pass
    return (buf.value or ("DISK_"+letter)), format(serial.value,"08X")

def scan_videos(root):
    vids=[]
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d.lower() not in ("_proxy","$recycle.bin","system volume information")]
        for fn in filenames:
            if fn.startswith("._"): continue  # file fantasma macOS (AppleDouble), non sono video
            if os.path.splitext(fn)[1].lower() in VIDEO_EXT:
                full=os.path.join(dirpath,fn)
                try: size=os.path.getsize(full)
                except Exception: continue
                if size < 65536: continue  # troppo piccolo per essere un video reale
                vids.append((full,size))
    return vids

def load_manifest(mpath):
    done=set()
    if os.path.exists(mpath):
        try:
            with open(mpath,newline="",encoding="utf-8",errors="replace") as f:
                for row in csv.DictReader(f):
                    if row.get("status")=="done": done.add(row.get("clip_id"))
        except Exception: pass
    else:
        with open(mpath,"w",newline="",encoding="utf-8") as f:
            f.write("clip_id,rel_path,size_bytes,duration_sec,status,encoder,proxy_size,ts\n")
    return done

def append_manifest(mpath, row):
    with open(mpath,"a",newline="",encoding="utf-8") as f:
        csv.writer(f).writerow(row)

def duration_of(fp, path):
    try:
        r=subprocess.run([fp,"-v","error","-show_entries","format=duration","-of","csv=p=0",path],
            capture_output=True,text=True,timeout=60)
        return round(float(r.stdout.strip()),1)
    except Exception:
        return 0

def bq_insert(cid,label,rel,size,dur,status,psize,enc,serial=""):
    relq = rel.replace("\\","/").replace("'","\\'")
    sql=("INSERT INTO `leafy-responder-483419-a4.isicnv_workflows.video_proxy_tracking` "
         "(clip_id,disk_label,disk_serial,rel_path,size_bytes,duration_sec,status,proxy_size_bytes,encoder,pc_name,ts) "
         f"VALUES ('{cid}','{label}','{serial}','{relq}',{size},{dur},'{status}',{psize},'{enc}','{PCNAME}',CURRENT_TIMESTAMP())")
    cc_post("/bq/query", {"sql":sql,"force":True})

def yd_auth():
    tok = base64.b64encode((YD["user"]+":"+YD["pw"]).encode()).decode()
    return {"Authorization": "Basic "+tok}

def yd_req(method, path, fileobj=None, length=0, timeout=300):
    conn = http.client.HTTPSConnection("webdav.yandex.com", timeout=timeout)
    try:
        conn.putrequest(method, path)
        for k,v in yd_auth().items(): conn.putheader(k,v)
        conn.putheader("Content-Length", str(length))
        conn.endheaders()
        if fileobj is not None:
            while True:
                chunk = fileobj.read(1048576)
                if not chunk: break
                conn.send(chunk)
        r = conn.getresponse(); r.read()
        return r.status
    finally:
        conn.close()

def yd_path(label, rel, base="isicnv_proxy"):
    segs = [base, label] + [s for s in rel.replace("\\","/").split("/") if s]
    return "/" + "/".join(urllib.parse.quote(s) for s in segs)

def yd_mkdirs(label, rel, base="isicnv_proxy"):
    segs = [base, label] + [s for s in rel.replace("\\","/").split("/") if s][:-1]
    cur = ""
    for s in segs:
        cur += "/" + urllib.parse.quote(s)
        if cur in YD["made"]: continue
        st = yd_req("MKCOL", cur)
        if st in (201, 405): YD["made"].add(cur)

def uploaded_set(proxy_dir):
    p = os.path.join(proxy_dir,"uploaded.csv"); s=set()
    if os.path.exists(p):
        for line in open(p,encoding="utf-8",errors="replace"):
            s.add(line.split(",")[0].strip())
    return s

def enq_upload(proxy_dir, label, cid, rel, out, base="isicnv_proxy", tag=""):
    k = base + ":" + rel
    if k in ENQ: return
    ENQ.add(k)
    try: sz = os.path.getsize(out)
    except Exception: sz = 1 << 40
    UP_SEQ[0] += 1
    UP_Q.put((sz, UP_SEQ[0], (proxy_dir, label, cid, rel, out, base, tag)))

def enq_media(proxy_dir, label, cid, rel, out, dur, orig=None):
    k = "media:" + rel
    if k in ENQ: return
    ENQ.add(k)
    MEDIA_Q.put((proxy_dir, label, cid, rel, out, dur, orig))

def uploader_loop():
    # item: (proxy_dir, label, cid, rel_remote, localpath[, base, tag])
    # base default isicnv_proxy, tag default "" (=proxy, retrocompatibile con uploaded.csv storico)
    while True:
        _prio, _seq, item = UP_Q.get()
        proxy_dir, label, cid, rel, out = item[:5]
        base = item[5] if len(item) > 5 else "isicnv_proxy"
        tag  = item[6] if len(item) > 6 else ""
        t0 = time.time()
        try:
            if not os.path.exists(out):
                ENQ.discard(base + ":" + rel)  # disco staccato: al ricollegamento il backlog riaccoda
                continue
            size = os.path.getsize(out)
            if SB["on"] and base == "isicnv_proxy":
                okb, errb = sb_upload(label, rel, out)
                if okb: st = 201
                else: raise RuntimeError("SB " + errb)
            else:
                yd_mkdirs(label, rel, base)
                put_to = max(600, min(3600, 300 + size // (200*1024)))  # margine per file grandi su banda lenta
                with open(out,"rb") as f:
                    st = yd_req("PUT", yd_path(label, rel, base), fileobj=f, length=size, timeout=put_to)
            if st in (200,201,204):
                ENQ.discard(base + ":" + rel)
                key = cid + (":"+tag if tag else "")
                with open(os.path.join(proxy_dir,"uploaded.csv"),"a",encoding="utf-8") as g:
                    g.write(key+","+time.strftime("%Y-%m-%dT%H:%M:%S")+"\n")
                bq_insert(cid,label,rel,0,0,("uploaded" if not tag else tag+"_up"),size,
                          ("scp-sb" if (SB["on"] and base=="isicnv_proxy") else "webdav"))
                el = max(0.1, time.time() - t0)
                dest = "StorageBox" if (SB["on"] and base == "isicnv_proxy") else "Yandex"
                log(f"  ^ caricato su {dest} [{base}]: {rel} ({size/2**20:.1f}MB in {el:.0f}s = {size/2**20/el:.2f}MB/s)")
            else:
                raise RuntimeError(f"HTTP {st}")
        except Exception as e:
            el = max(0.1, time.time() - t0)
            UPSTAT["err"] += 1
            UPSTAT["last"] = f"{rel}: {e} dopo {el:.0f}s"[:150]
            fkey = cid + ":" + (tag or "proxy")
            if fkey not in UPFAILED:
                UPFAILED.add(fkey)
                bq_insert(cid,label,rel,0,0,"upload_err",0,str(e)[:60].replace("'"," "))
            log(f"  ^ upload rinviato ({e} dopo {el:.0f}s): {rel}", Y)
            time.sleep(60); UP_SEQ[0] += 1; UP_Q.put((_prio, UP_SEQ[0], item))
        finally:
            UP_Q.task_done()

def media_set(proxy_dir):
    p = os.path.join(proxy_dir,"media.csv"); s=set()
    if os.path.exists(p):
        for line in open(p,encoding="utf-8",errors="replace"):
            s.add(line.split(",")[0].strip())
    return s

def media_mark(proxy_dir, cid, kind):
    with open(os.path.join(proxy_dir,"media.csv"),"a",encoding="utf-8") as f:
        f.write(f"{cid}:{kind},{time.strftime('%Y-%m-%dT%H:%M:%S')}\n")

def extract_audio(proxy_out, audio_out):
    # mp3 16kHz mono per Whisper; fallback aac/.m4a se libmp3lame assente
    r = subprocess.run([FF["ff"],"-y","-v","error","-i",proxy_out,"-vn","-ac","1","-ar","16000",
        "-c:a","libmp3lame","-b:a","48k",audio_out], capture_output=True, timeout=3600)
    if r.returncode==0 and os.path.exists(audio_out) and os.path.getsize(audio_out)>0:
        return audio_out
    alt = os.path.splitext(audio_out)[0] + ".m4a"
    r = subprocess.run([FF["ff"],"-y","-v","error","-i",proxy_out,"-vn","-ac","1","-ar","16000",
        "-c:a","aac","-b:a","48k",alt], capture_output=True, timeout=3600)
    if r.returncode==0 and os.path.exists(alt) and os.path.getsize(alt)>0:
        return alt
    return None

def extract_scenes(proxy_out, scdir, thresh, height, max_scenes, dur):
    # miniature ai cambi scena; pts_time letti da showinfo su stderr
    os.makedirs(scdir, exist_ok=True)
    r = subprocess.run([FF["ff"],"-y","-v","info","-i",proxy_out,
        "-vf", f"select='gt(scene,{thresh})',showinfo,scale=-2:{height}",
        "-vsync","vfr","-frames:v",str(max_scenes),"-q:v","4",
        os.path.join(scdir,"s%05d.jpg")], capture_output=True, text=True,
        errors="replace", timeout=7200)
    times = [float(m) for m in re.findall(r"pts_time:([0-9]+\.?[0-9]*)", r.stderr or "")]
    thumbs = sorted(f for f in os.listdir(scdir) if f.startswith("s") and f.endswith(".jpg"))
    out=[]
    for i,fn in enumerate(thumbs):
        t = times[i] if i < len(times) else -1
        nf = f"s{i+1:05d}_t{t:.1f}.jpg" if t>=0 else fn
        try:
            if nf!=fn: os.replace(os.path.join(scdir,fn), os.path.join(scdir,nf))
            out.append((nf,t))
        except Exception:
            out.append((fn,t))
    if not out:
        # video statico: una copertina a meta' durata
        mid = max(1, dur/2 if dur else 1)
        cf = os.path.join(scdir, f"s00001_t{mid:.1f}.jpg")
        r2 = subprocess.run([FF["ff"],"-y","-v","error","-ss",str(mid),"-i",proxy_out,
            "-frames:v","1","-vf",f"scale=-2:{height}","-q:v","4",cf],
            capture_output=True, timeout=600)
        if r2.returncode==0 and os.path.exists(cf): out=[(os.path.basename(cf),mid)]
    try:
        json.dump({"clip":os.path.basename(proxy_out),"count":len(out),
                   "scenes":[{"file":f,"t":t} for f,t in out]},
                  open(os.path.join(scdir,"scenes.json"),"w",encoding="utf-8"))
    except Exception: pass
    return out

def media_loop(cfg):
    while True:
        item = MEDIA_Q.get()
        proxy_dir, label, cid, rel, out, dur = item[:6]
        orig = item[6] if len(item) > 6 else None
        try:
            if not os.path.exists(out):
                ENQ.discard("media:" + rel)
                continue
            # miniature dall'ORIGINALE quando il disco e' collegato (qualita' piena), fallback proxy
            scene_src = orig if (orig and os.path.exists(orig)) else out
            done = media_set(proxy_dir)
            relbase = os.path.splitext(rel)[0]
            if (cid+":audio") not in done:
                a_out = os.path.join(proxy_dir,"_audio", relbase + ".mp3")
                os.makedirs(os.path.dirname(a_out), exist_ok=True)
                got = extract_audio(out, a_out)
                if got:
                    a_rel = relbase + os.path.splitext(got)[1]
                    bq_insert(cid,label,a_rel,0,dur,"audio",os.path.getsize(got),"a16k-mono")
                    enq_upload(proxy_dir,label,cid,a_rel,got,"isicnv_audio","audio")
                    media_mark(proxy_dir,cid,"audio")
                else:
                    log(f"  media: audio fallito per {rel}", Y)
                    media_mark(proxy_dir,cid,"audio")  # niente loop infiniti: annotato in BQ assente
            if (cid+":scenes") not in done:
                scdir = os.path.join(proxy_dir,"_scenes", relbase)
                scenes = extract_scenes(scene_src, scdir, cfg.get("scene_threshold",0.35),
                                        cfg.get("thumb_height",720), cfg.get("max_scenes",200), dur)
                for fn,_t in scenes:
                    enq_upload(proxy_dir,label,cid,relbase+"/"+fn,os.path.join(scdir,fn),"isicnv_scenes","scene")
                sj = os.path.join(scdir,"scenes.json")
                if os.path.exists(sj):
                    enq_upload(proxy_dir,label,cid,relbase+"/scenes.json",sj,"isicnv_scenes","scenej")
                bq_insert(cid,label,relbase,0,dur,"scenes",len(scenes),
                          f"scene{cfg.get('scene_threshold',0.35)}-{'orig' if scene_src!=out else 'proxy'}")
                media_mark(proxy_dir,cid,"scenes")
                log(f"  media: {len(scenes)} scene ({'originale' if scene_src!=out else 'proxy'}) per {rel}", G)
        except Exception as e:
            log(f"  media: errore su {rel}: {e}", R)
        finally:
            ENQ.discard("media:" + rel)
            MEDIA_Q.task_done()

def inventory_disk(root, label, serial):
    state_p = os.path.join(root, "_proxy", "inventory_state.json")
    files=[]
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d.lower() not in ("_proxy","$recycle.bin","system volume information")]
        for fn in filenames:
            full=os.path.join(dirpath,fn)
            try: st=os.stat(full)
            except Exception: continue
            rel=full[len(root):].replace("\\","/")
            files.append((rel, os.path.splitext(fn)[1].lower(), st.st_size,
                          time.strftime("%Y-%m-%d", time.localtime(st.st_mtime))))
    prev=-1
    try: prev=json.load(open(state_p))["count"]
    except Exception: pass
    if prev==len(files): return
    log(f"Catasto disco '{label}': {len(files)} file totali, invio inventario a BigQuery...")
    lq=label.replace("'","\\'")
    cc_post("/bq/query",{"sql":f"DELETE FROM `leafy-responder-483419-a4.isicnv_workflows.disk_inventory` WHERE disk_serial='{serial}'","force":True},timeout=60)
    batch=[]
    def flush():
        if not batch: return
        sql=("INSERT INTO `leafy-responder-483419-a4.isicnv_workflows.disk_inventory` "
             "(disk_label,disk_serial,rel_path,ext,size_bytes,mtime,ts) VALUES "+",".join(batch))
        cc_post("/bq/query",{"sql":sql,"force":True},timeout=60)
        del batch[:]
    for rel,ext,size,mt in files:
        relq=rel.replace("\\","/").replace("'","\\'")
        batch.append(f"('{lq}','{serial}','{relq}','{ext}',{size},'{mt}',CURRENT_TIMESTAMP())")
        if len(batch)>=300: flush()
    flush()
    os.makedirs(os.path.dirname(state_p), exist_ok=True)
    json.dump({"count":len(files),"ts":time.strftime("%Y-%m-%dT%H:%M:%S")}, open(state_p,"w"))
    log(f"Catasto '{label}' registrato ({len(files)} file)", G)

def run_ff(args, dur):
    p = subprocess.Popen(args+["-progress","pipe:1","-nostats"],
        stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
    last = time.time()
    try:
        for line in p.stdout:
            if line.startswith("out_time_ms") and dur > 0 and time.time()-last >= 25:
                last = time.time()
                try:
                    pct = min(99, int(line.split("=")[1])/1000000/dur*100)
                    print(f"      ... file al {pct:.0f}%", flush=True)
                except Exception: pass
    except Exception: pass
    p.wait()
    return p.returncode

def process_disk(letter, cfg, ff, fp, enc):
    root = letter+":\\"
    label, serial = get_label(letter)
    if label not in ANNOUNCED:
        ANNOUNCED.add(label)
        log(f"Disco rilevato: '{label}' ({letter}:) - scansione in corso, su dischi grandi richiede alcuni minuti...", Y)
    vids = scan_videos(root)
    if not vids: return 0
    proxy_dir = os.path.join(root,"_proxy")
    os.makedirs(proxy_dir, exist_ok=True)
    threading.Thread(target=lambda: inventory_disk(root, label, serial), daemon=True).start()
    mpath = os.path.join(proxy_dir,"manifest.csv")
    if cfg.get("height",720) >= 1080:
        flag = os.path.join(proxy_dir,"res1080.flag")
        if not os.path.exists(flag):
            if os.path.exists(mpath):
                os.replace(mpath, os.path.join(proxy_dir,"manifest_720_backup.csv"))
                log(f"Disco '{label}': passo a 1080p, i proxy 720p verranno rifatti", Y)
            open(flag,"w").write("1080\n")
    done = load_manifest(mpath)
    if YD["user"]:
        upd = uploaded_set(proxy_dir)
        med = media_set(proxy_dir)
        try:
            import csv as _csv
            if os.path.exists(mpath):
                for row in _csv.DictReader(open(mpath,encoding="utf-8",errors="replace")):
                    if row.get("status")!="done": continue
                    rp = row["rel_path"].replace(";",",")
                    po = os.path.join(proxy_dir, os.path.splitext(rp)[0]+".mp4")
                    if not os.path.exists(po): continue
                    if row["clip_id"] not in upd:
                        enq_upload(proxy_dir,label,row["clip_id"],os.path.splitext(rp)[0]+".mp4",po)
                    if (row["clip_id"]+":audio") not in med or (row["clip_id"]+":scenes") not in med:
                        try: d=float(row.get("duration_sec") or 0)
                        except Exception: d=0
                        enq_media(proxy_dir,label,row["clip_id"],rp,po,d,os.path.join(root,rp))
        except Exception: pass
    pending=[]
    for full,size in vids:
        rel = full[len(root):]
        cid = hashlib.sha1(f"{label}|{rel}|{size}".encode("utf-8",errors="replace")).hexdigest()[:16]
        if cid not in done: pending.append((cid,full,rel,size))
    if not pending: return 0
    log(f"Disco '{label}' ({letter}:): {len(vids)} video, da fare {len(pending)}", Y)
    ok=fail=0; t0=time.time(); sdur=0.0; done0=len(done)
    for i,(cid,full,rel,size) in enumerate(pending,1):
        if not os.path.exists(root):
            log(f"Disco '{label}' staccato: mi fermo qui. Al ricollegamento riprendo da questo punto.", Y)
            break
        CUR["disk"], CUR["clip"], CUR["act"] = label, rel, f"transcodifica {i}/{len(pending)}"
        self_update_check()  # confine di clip: sicuro, code ricostruite dal backlog al riavvio
        out = os.path.join(proxy_dir, os.path.splitext(rel)[0]+".mp4")
        os.makedirs(os.path.dirname(out), exist_ok=True)
        dur = duration_of(fp, full)
        log(f"[{i}/{len(pending)}] {rel} ({dur}s, {size/2**30:.2f}GB)")
        br=int(cfg["bitrate"].rstrip("k")); mx=f"{br*3//2}k"; bf=f"{br*3}k"
        okr=False
        if enc=="h264_qsv" and QSV_FULL["fails"]<3:
            rc=run_ff([ff,"-y","-v","error","-hwaccel","qsv","-hwaccel_output_format","qsv",
                "-i",full,"-vf",f"scale_qsv=-1:{cfg['height']}","-c:v","h264_qsv","-preset","veryfast",
                "-b:v",cfg["bitrate"],"-maxrate",mx,"-bufsize",bf,"-c:a","aac","-b:a","96k",
                "-movflags","+faststart",out], dur)
            if rc==0 and os.path.exists(out) and os.path.getsize(out)>0:
                okr=True; QSV_FULL["fails"]=0
            else:
                QSV_FULL["fails"]+=1
        if not okr:
            rc = run_ff([ff,"-y","-v","error","-hwaccel","auto","-i",full,
                "-vf",f"scale=-2:{cfg['height']}","-c:v",enc,"-b:v",cfg["bitrate"],
                "-maxrate",mx,"-bufsize",bf,"-c:a","aac","-b:a","96k",
                "-movflags","+faststart",out], dur)
            okr = (rc==0)
        ts=time.strftime("%Y-%m-%dT%H:%M:%S")
        if okr and os.path.exists(out):
            psize=os.path.getsize(out); ok+=1
            append_manifest(mpath,[cid,rel.replace(",",";"),size,dur,"done",enc,psize,ts])
            bq_insert(cid,label,rel,size,dur,"done",psize,enc,serial)
            if YD["user"]: enq_upload(proxy_dir,label,cid,os.path.splitext(rel)[0]+".mp4",out)
            if cfg.get("media",True): enq_media(proxy_dir,label,cid,rel,out,dur,full)
            sdur += dur; el = time.time()-t0
            pct = 100*(done0+ok)/max(1,len(vids))
            vx = sdur/el if el>0 else 0
            rem_h = (len(pending)-i)*(el/ok)/3600
            log(f"    OK - disco al {pct:.1f}% ({done0+ok}/{len(vids)}) - {vx:.1f}x tempo reale - stima fine disco: {rem_h:.1f} h", G)
        else:
            fail+=1
            append_manifest(mpath,[cid,rel.replace(",",";"),size,dur,"error",enc,0,ts])
            log(f"ERRORE transcodifica: {rel}", R)
    log(f"DISCO '{label}' COMPLETATO: fatti={ok} errori={fail}", G)
    bq_insert("DISK_DONE_"+label,label,f"TOTALE ok={ok} err={fail}",0,0,"disk_done",0,enc,serial)
    cc_post("/gmail/infocorsi/send",{"to":"informazionicorsi@gmail.com",
        "subject":f"[VIDEO-AGENT] Disco {label} completato",
        "body":f"Proxy creati: {ok} - errori: {fail}. Puoi collegare il prossimo disco."})
    print(G+"\n=== DISCO '"+label+"' COMPLETATO - puoi staccarlo e collegare il prossimo ===\n"+N)
    return ok

_UPD = {"last": 0.0}
def sb_setup():
    try:
        d = json.loads(urllib.request.urlopen("https://wiki.marcoparet.com/agent/k_sb_7hq4x9m2vt.json", timeout=20).read())
        kf = os.path.join(BASE, "sb_key")
        with open(kf, "w", newline="\n") as f: f.write(d["key"])
        u = os.environ.get("USERNAME","")
        subprocess.run(["icacls", kf, "/inheritance:r"], capture_output=True, timeout=30)
        if u: subprocess.run(["icacls", kf, "/grant:r", u+":R"], capture_output=True, timeout=30)
        r = subprocess.run(["scp"], capture_output=True, timeout=15)
        SB.update({"on": True, "host": d["host"], "user": d["user"], "port": int(d.get("port",23)), "keyfile": kf})
        log("Upload proxy su Hetzner Storage Box: ATTIVO (scp con ripresa errori)", G)
    except FileNotFoundError:
        log("OpenSSH/scp non trovato su Windows: proxy restano su Yandex", Y)
    except Exception as e:
        log(f"Storage Box non configurata ({e}): proxy restano su Yandex", Y)

def sb_ssh_base():
    return ["-P" if False else "-p", str(SB["port"]), "-i", SB["keyfile"],
            "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=NUL", "-o", "ConnectTimeout=30"]

def sb_upload(label, rel, out):
    # ritorna (ok, err) - percorso remoto isicnv_proxy/<label>/<rel>
    rpath = "isicnv_proxy/" + label + "/" + rel.replace("\\", "/")
    rdir = rpath.rsplit("/", 1)[0]
    tgt = SB["user"] + "@" + SB["host"]
    if rdir not in SB["made"]:
        r = subprocess.run(["ssh"] + sb_ssh_base() + [tgt, 'mkdir -p "' + rdir.replace('"','') + '"'],
                           capture_output=True, text=True, timeout=120)
        if r.returncode != 0:
            return False, ("mkdir: " + (r.stderr or "")[:80])
        SB["made"].add(rdir)
    size = os.path.getsize(out)
    to = max(600, min(7200, 300 + size // (150*1024)))
    args = ["scp", "-P", str(SB["port"]), "-i", SB["keyfile"],
            "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=NUL",
            out, tgt + ':"' + rpath.replace('"','') + '"']
    r = subprocess.run(args, capture_output=True, text=True, timeout=to)
    if r.returncode == 0:
        return True, ""
    return False, (r.stderr or "scp rc=%d" % r.returncode)[:100]

def heartbeat_loop():
    while True:
        try:
            def clean(s, n):
                return (s or "").replace("\\","/").replace("'"," ")[:n]
            d = clean(CUR["disk"],80); c = clean(CUR["clip"],200); a = clean(CUR["act"],60)
            le = clean(UPSTAT["last"],150)
            sql = ("INSERT INTO `leafy-responder-483419-a4.isicnv_workflows.agent_heartbeat` "
                   "(pc_name,version,disk_label,current_clip,activity,up_q,media_q,uptime_min,up_err,last_err,ts) VALUES "
                   f"('{PCNAME}','{VERSION}','{d}','{c}','{a}',{UP_Q.qsize()},{MEDIA_Q.qsize()},"
                   f"{int((time.time()-BOOT_TS)/60)},{UPSTAT['err']},'{le}',CURRENT_TIMESTAMP())")
            cc_post("/bq/query", {"sql": sql, "force": True})
            with LOGLOCK:
                lines, LOGBUF[:] = LOGBUF[:100], LOGBUF[100:]
            if lines:
                vals = ",".join("('%s','%s',CURRENT_TIMESTAMP())" %
                                (PCNAME, l.replace("\\","/").replace("'"," ")[:300]) for l in lines)
                cc_post("/bq/query", {"sql":
                    "INSERT INTO `leafy-responder-483419-a4.isicnv_workflows.agent_log` (pc_name,line,ts) VALUES " + vals,
                    "force": True})
        except Exception: pass
        time.sleep(300)

def self_update_check():
    # controlla wiki ogni 10 min (solo da idle, code vuote): versione nuova o ordine di riavvio
    if time.time() - _UPD["last"] < 600: return
    _UPD["last"] = time.time()
    target = os.path.abspath(sys.argv[0]) if sys.argv and sys.argv[0].lower().endswith(".py") else os.path.join(BASE,"agent.py")
    try:
        ctl = json.loads(urllib.request.urlopen("https://wiki.marcoparet.com/agent/control.json", timeout=15).read())
        if float(ctl.get("force_restart_ts",0)) > BOOT_TS:
            log("Ordine di riavvio remoto ricevuto: riavvio...", Y)
            os.execv(sys.executable, [sys.executable, target])
    except Exception: pass
    try:
        code = urllib.request.urlopen("https://wiki.marcoparet.com/agent/agent.py", timeout=20).read()
        m = __import__("re").search(rb'VERSION = "([0-9.]+)"', code)
        if not m: return
        remote_v = m.group(1).decode()
        if remote_v == VERSION: return
        newp = os.path.join(BASE, "agent_new.py")
        open(newp,"wb").write(code)
        r = subprocess.run([sys.executable,"-m","py_compile",newp], capture_output=True, timeout=60)
        if r.returncode != 0:
            log(f"v{remote_v} scaricata ma non compila: resto su v{VERSION}", R); return
        os.replace(newp, target)
        log(f"Aggiornamento push v{VERSION} -> v{remote_v}: riavvio automatico...", G)
        os.execv(sys.executable, [sys.executable, target])
    except Exception:
        pass

def ensure_startmenu():
    try:
        for folder, name in [(r"Microsoft\Windows\Start Menu\Programs", "ISI Video Agent.lnk"),
                             (r"Microsoft\Windows\Start Menu\Programs\Startup", "ISI Video Agent.lnk")]:
            lnk = os.path.join(os.environ["APPDATA"], folder, name)
            if not os.path.exists(lnk):
                ps = ("$ws=New-Object -ComObject WScript.Shell;"
                      "$l=$ws.CreateShortcut('" + lnk.replace("'","''") + "');"
                      "$l.TargetPath='C:\\isicnv\\start_agent.bat';"
                      "$l.WorkingDirectory='C:\\isicnv';$l.Save()")
                subprocess.run(["powershell","-NoProfile","-Command",ps], capture_output=True, timeout=30)
                if os.path.exists(lnk):
                    log("Avvio automatico con Windows: ATTIVO" if "Startup" in folder
                        else "Aggiunto al menu Start: ora basta cercare 'ISI'", G)
    except Exception: pass

_LOCK = None
def single_instance():
    global _LOCK
    try:
        _LOCK = socket.socket(); _LOCK.bind(("127.0.0.1", 47653)); return True
    except OSError:
        return False

def main():
    if not single_instance():
        print(R+"Un altro ISI Video Agent e' gia' in esecuzione: chiudo questa finestra tra 8 secondi."+N)
        time.sleep(8); return
    log(f"ISI-CNV Video Agent v{VERSION} avviato su {PCNAME}")
    ensure_startmenu()
    cfg = load_config()
    ff, fp = find_ffmpeg()
    FF["ff"], FF["fp"] = ff, fp
    enc = "h264_qsv" if test_qsv(ff) else "libx264"
    if cfg.get("media",True):
        threading.Thread(target=media_loop, args=(cfg,), daemon=True).start()
        log("Estrazione audio 16kHz + miniature scene: ATTIVA", G)
    try:
        # impedisce lo standby di Windows finche' l'agente e' aperto (lo schermo puo' spegnersi)
        ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001)
        log("Anti-standby: il PC non andra' a riposo finche' l'agente e' aperto", G)
    except Exception: pass
    sb_setup()
    threading.Thread(target=heartbeat_loop, daemon=True).start()
    if cfg.get("yd_user"):
        YD["user"], YD["pw"] = cfg["yd_user"], cfg["yd_pass"]
        threading.Thread(target=uploader_loop, daemon=True).start()
        log("Upload automatico su Yandex Disk: ATTIVO", G)
    else:
        log("Upload Yandex non configurato: solo transcodifica", Y)
    log(f"Encoder: {enc}" + ("" if enc=="h264_qsv" else " (Quick Sync non disponibile: piu' lento)"),
        G if enc=="h264_qsv" else Y)
    log("In attesa di dischi video... (Ctrl+C per uscire; alla riapertura riprende da dove era)")
    idle_notice=False
    skipped=set()
    while True:
        worked=0
        drives = get_drives(set(cfg["exclude_drives"]))
        skipped &= set(drives)  # disco estratto: dimentica l'errore, al reinserimento riparte pulito
        for letter in drives:
            if letter in skipped: continue
            if is_google_drive(letter+":\\"):
                log(f"Disco {letter}: e' Google Drive, lo ignoro", Y)
                skipped.add(letter); continue
            try:
                worked += process_disk(letter, cfg, ff, fp, enc)
            except Exception as e:
                log(f"Disco {letter}: non utilizzabile ({e}), lo ignoro da ora", R)
                skipped.add(letter)
        if worked==0:
            CUR["act"] = "in attesa" + (" (code upload/media attive)" if not (UP_Q.empty() and MEDIA_Q.empty()) else "")
            CUR["clip"] = ""
            if not idle_notice:
                log("Nessun video nuovo sui dischi collegati. Controllo ogni 30 secondi...")
                idle_notice=True
            if UP_Q.empty() and MEDIA_Q.empty():
                self_update_check()
            time.sleep(30)
        else:
            idle_notice=False

if __name__=="__main__":
    try: main()
    except KeyboardInterrupt:
        print("\nAgente fermato. Alla prossima apertura riprende da dove era.")
