#!/usr/bin/env python3
"""VLC iOS Browser - a local web app for the files in VLC for iOS "Sharing via Wi-Fi".

Run:  python3 vlc_web.py        (opens http://localhost:8099)
Python 3.8+ standard library only. Works on macOS, Windows, Linux.

The Mac runs this helper; the browser page talks to it, and it talks to the phone.
(A hosted page cannot reach a LAN IP: browsers block https->http and VLC sends no CORS.)

VLC's server can only list, download and upload. It has no delete/rename/move/mkdir.
"""
import http.client, json, os, re, sys, threading, time, urllib.parse, urllib.request, webbrowser
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PORT = int(os.environ.get("PORT", "8099"))
DEFAULT_DEST = os.path.join(os.path.expanduser("~"), "Downloads", "VLC")

S = {"host": None, "files": [], "job": None}  # shared state
LOCK = threading.Lock()


def safe_name(n):
    n = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", n).rstrip(" .")
    return n or "unnamed"


def fetch(path, headers=None, timeout=30):
    req = urllib.request.Request("http://" + S["host"] + path, headers=headers or {})
    return urllib.request.urlopen(req, timeout=timeout)


def load_library(host, passcode=None):
    S["host"] = host
    if passcode:
        try:
            fetch("/public/auth.html?code=" + urllib.parse.quote(passcode)).read()
        except Exception:
            pass
    root = ET.fromstring(fetch("/libMediaVLC.xml").read())
    files, seen = [], set()
    for m in root.iter("Media"):
        for attr, sub in (("pathfile", False), ("pathSubtitle", True)):
            u = m.get(attr)
            if not u or u in seen:
                continue
            seen.add(u)
            p = urllib.parse.urlsplit(u).path
            name = safe_name(urllib.parse.unquote(p.rsplit("/", 1)[-1]))
            thumb = urllib.parse.urlsplit(m.get("thumb") or "").path if not sub else ""
            files.append({"id": len(files), "name": name, "path": p, "thumb": thumb,
                          "size": int(m.get("size") or 0) if not sub else 0,
                          "duration": m.get("duration") or "", "subtitle": sub,
                          "title": m.get("title") or name})
    S["files"] = files
    return files


def unique(dest, name, used):
    stem, ext = os.path.splitext(name)
    c, n = name, 1
    while c.lower() in used:
        n += 1
        c = "%s (%d)%s" % (stem, n, ext)
    used.add(c.lower())
    return os.path.join(dest, c)


def run_save(ids, dest):
    job = S["job"]
    os.makedirs(dest, exist_ok=True)
    used = set(x.lower() for x in os.listdir(dest)) if False else set()
    for i in ids:
        if job["cancel"]:
            break
        f = S["files"][i]
        path = unique(dest, f["name"], used)
        job["current"] = f["name"]
        job["cur_done"], job["cur_total"] = 0, f["size"]
        for attempt in range(5):
            have = os.path.getsize(path) if os.path.exists(path) else 0
            if f["size"] and have == f["size"]:
                break
            try:
                hdr = {"Range": "bytes=%d-" % have} if have else {}
                with fetch(f["path"], hdr, timeout=60) as r:
                    resume = have and r.status == 206
                    done = have if resume else 0
                    with open(path, "ab" if resume else "wb") as out:
                        while not job["cancel"]:
                            b = r.read(1 << 20)
                            if not b:
                                break
                            out.write(b)
                            done += len(b)
                            job["cur_done"] = done
                    break
            except Exception as e:
                job["errors"].append("%s: %s" % (f["name"], e))
                time.sleep(2 * (attempt + 1))
        else:
            job["failed"] += 1
            continue
        job["finished"] += 1
    job["state"] = "cancelled" if job["cancel"] else "done"


def upload_to_phone(name, length, rfile):
    """Stream a raw request body to the phone as multipart/form-data -> /upload.json."""
    boundary = "----vlcweb%d" % int(time.time() * 1000)
    name = safe_name(name)
    head = ('--%s\r\nContent-Disposition: form-data; name="files[]"; filename="%s"\r\n'
            'Content-Type: application/octet-stream\r\n\r\n' % (boundary, name)).encode()
    tail = ("\r\n--%s--\r\n" % boundary).encode()
    host, _, port = S["host"].partition(":")
    c = http.client.HTTPConnection(host, int(port or 80), timeout=300)
    c.putrequest("POST", "/upload.json")
    c.putheader("Content-Type", "multipart/form-data; boundary=" + boundary)
    c.putheader("Content-Length", str(len(head) + length + len(tail)))
    c.endheaders()
    c.send(head)
    left = length
    while left:
        b = rfile.read(min(1 << 20, left))
        if not b:
            raise IOError("client closed during upload")
        c.send(b)
        left -= len(b)
    c.send(tail)
    r = c.getresponse()
    r.read()
    c.close()
    return r.status


class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def log_message(self, *a):
        pass

    def json(self, obj, code=200):
        b = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def body(self):
        n = int(self.headers.get("Content-Length") or 0)
        return json.loads(self.rfile.read(n) or b"{}")

    def proxy(self, path, name=None, attach=False):
        hdr = {}
        if self.headers.get("Range"):
            hdr["Range"] = self.headers["Range"]
        try:
            r = fetch(path, hdr, timeout=60)
        except urllib.error.HTTPError as e:
            self.send_response(e.code); self.send_header("Content-Length", "0"); self.end_headers(); return
        except Exception:
            self.send_response(502); self.send_header("Content-Length", "0"); self.end_headers(); return
        self.send_response(r.status)
        for k in ("Content-Length", "Content-Range", "Accept-Ranges"):
            if r.headers.get(k):
                self.send_header(k, r.headers[k])
        ctype = "application/octet-stream" if attach else (r.headers.get("Content-Type") or "application/octet-stream")
        self.send_header("Content-Type", ctype)
        if attach:
            self.send_header("Content-Disposition", "attachment; filename*=UTF-8''" + urllib.parse.quote(name))
        self.end_headers()
        try:
            while True:
                b = r.read(1 << 18)
                if not b:
                    break
                self.wfile.write(b)
        except (BrokenPipeError, ConnectionResetError):
            pass
        finally:
            r.close()

    def do_GET(self):
        u = urllib.parse.urlsplit(self.path)
        if u.path == "/":
            b = PAGE.encode()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(b)))
            self.end_headers(); self.wfile.write(b)
        elif u.path == "/api/state":
            self.json({"host": S["host"], "files": S["files"], "dest": DEFAULT_DEST, "job": S["job"]})
        elif u.path == "/api/progress":
            self.json(S["job"] or {})
        elif u.path.startswith("/api/thumb/"):
            f = S["files"][int(u.path.rsplit("/", 1)[-1])]
            if f["thumb"]:
                self.proxy(f["thumb"])
            else:
                self.send_response(404); self.send_header("Content-Length", "0"); self.end_headers()
        elif u.path.startswith("/api/file/"):
            f = S["files"][int(u.path.rsplit("/", 1)[-1])]
            self.proxy(f["path"], f["name"], attach="dl=1" in u.query)
        else:
            self.send_response(404); self.send_header("Content-Length", "0"); self.end_headers()

    def do_POST(self):
        u = urllib.parse.urlsplit(self.path)
        try:
            if u.path == "/api/connect":
                d = self.body()
                host = d["host"].replace("http://", "").strip().rstrip("/")
                files = load_library(host, d.get("passcode"))
                self.json({"ok": True, "count": len(files)})
            elif u.path == "/api/refresh":
                load_library(S["host"]); self.json({"ok": True})
            elif u.path == "/api/save":
                d = self.body()
                with LOCK:
                    if S["job"] and S["job"]["state"] == "running":
                        return self.json({"ok": False, "error": "A download is already running"}, 409)
                    ids = d.get("ids") or [f["id"] for f in S["files"]]
                    dest = os.path.expanduser(d.get("dest") or DEFAULT_DEST)
                    S["job"] = {"state": "running", "total": len(ids), "finished": 0, "failed": 0,
                                "current": "", "cur_done": 0, "cur_total": 0, "errors": [],
                                "cancel": False, "dest": dest}
                    threading.Thread(target=run_save, args=(ids, dest), daemon=True).start()
                self.json({"ok": True, "dest": dest})
            elif u.path == "/api/cancel":
                if S["job"]:
                    S["job"]["cancel"] = True
                self.json({"ok": True})
            elif u.path == "/api/upload":
                q = urllib.parse.parse_qs(u.query)
                st = upload_to_phone(q["name"][0], int(self.headers["Content-Length"]), self.rfile)
                self.json({"ok": st < 400, "status": st})
            else:
                self.json({"ok": False}, 404)
        except Exception as e:
            self.json({"ok": False, "error": str(e)}, 500)


PAGE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>VLC iOS Browser</title>
<style>
:root{--bg:#f6f7f9;--fg:#1c2330;--mut:#667085;--card:#fff;--bd:#d9dee7;--ac:#e8700a;--ac2:#b85600}
@media(prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e8ebf0;--mut:#98a2b3;--card:#1d222b;--bd:#323946;--ac:#ff8a2b;--ac2:#ffa458}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,system-ui,Segoe UI,sans-serif}
header{padding:12px 16px;border-bottom:1px solid var(--bd);background:var(--card);display:flex;gap:10px;flex-wrap:wrap;align-items:center;position:sticky;top:0;z-index:2}
h1{font-size:17px;margin:0 8px 0 0}input,button{font:inherit;padding:7px 10px;border-radius:8px;border:1px solid var(--bd);background:var(--card);color:var(--fg)}
button{cursor:pointer}button.p{background:var(--ac);color:#fff;border-color:var(--ac);font-weight:600}button:disabled{opacity:.45;cursor:default}
main{padding:16px;max-width:1100px;margin:auto}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.mut{color:var(--mut);font-size:13px}table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--bd);border-radius:10px;overflow:hidden}
td,th{padding:6px 10px;border-bottom:1px solid var(--bd);text-align:left;vertical-align:middle}th{font-size:12px;color:var(--mut);text-transform:uppercase;cursor:pointer}
td.n{word-break:break-all}img.t{width:56px;height:36px;object-fit:cover;border-radius:4px;background:var(--bd);display:block}
a{color:var(--ac2)}#drop{border:2px dashed var(--bd);border-radius:10px;padding:14px;text-align:center;color:var(--mut);margin-bottom:12px}#drop.on{border-color:var(--ac);color:var(--ac)}
.prog{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:10px 12px;margin-bottom:12px}.meter{height:8px;border-radius:4px;background:var(--bd);overflow:hidden;margin-top:6px}.meter i{display:block;height:100%;background:var(--ac)}
.err{color:#c0392b}.empty{padding:30px;text-align:center;color:var(--mut)}@media(max-width:640px){.hm{display:none}}
</style></head><body>
<header><h1>VLC iOS Browser</h1>
<input id="host" placeholder="Phone IP:port  e.g. 192.168.1.50:8080" size="28">
<input id="pass" placeholder="Passcode (if set)" size="14" type="password">
<button class="p" id="go">Connect</button><span id="status" class="mut"></span></header>
<main>
<div class="bar"><button id="all" disabled>Download everything</button><button id="sel" disabled>Download selected</button>
<input id="dest" size="34" title="Folder on this computer"><button id="refresh" disabled>Refresh</button>
<span class="mut" id="summary"></span></div>
<div id="prog" class="prog" hidden></div>
<div id="drop">Drop files here to upload them to the phone</div>
<table><thead><tr><th><input type="checkbox" id="chk"></th><th></th><th data-s="name">Name</th><th data-s="size" class="hm">Size</th><th class="hm" data-s="duration">Length</th><th></th></tr></thead>
<tbody id="rows"><tr><td colspan="6" class="empty">Turn on Sharing via Wi-Fi in VLC, enter the address above, and press Connect.</td></tr></tbody></table>
<p class="mut">VLC's Wi-Fi server can list, download and upload only. It cannot delete, rename, move or create folders on the phone.</p>
</main>
<script>
const $=id=>document.getElementById(id);let files=[],sortBy='name',asc=true,sel=new Set();
const fmt=b=>b>=1e9?(b/1e9).toFixed(2)+' GB':b>=1e6?(b/1e6).toFixed(1)+' MB':b>=1e3?(b/1e3).toFixed(0)+' KB':b+' B';
const esc=s=>s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function api(p,b){const r=await fetch(p,b?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}:{});return r.json()}
function render(){const f=[...files].sort((a,b)=>{let x=a[sortBy],y=b[sortBy];return (x>y?1:x<y?-1:0)*(asc?1:-1)});
 $('rows').innerHTML=f.length?f.map(x=>`<tr><td><input type="checkbox" data-i="${x.id}" ${sel.has(x.id)?'checked':''}></td>
 <td>${x.thumb?`<img class="t" loading="lazy" src="/api/thumb/${x.id}">`:''}</td><td class="n">${esc(x.name)}</td>
 <td class="hm">${x.size?fmt(x.size):''}</td><td class="hm">${esc(x.duration)}</td><td><a href="/api/file/${x.id}?dl=1">Save</a></td></tr>`).join(''):'<tr><td colspan="6" class="empty">No files found</td></tr>';
 const tot=files.reduce((a,x)=>a+x.size,0);$('summary').textContent=files.length?`${files.length} files, ${fmt(tot)}`:'';
 $('all').disabled=$('refresh').disabled=!files.length;$('sel').disabled=!sel.size;$('sel').textContent=sel.size?`Download selected (${sel.size})`:'Download selected'}
async function connect(){$('status').textContent='Connecting...';$('status').className='mut';
 const r=await api('/api/connect',{host:$('host').value,passcode:$('pass').value});
 if(!r.ok){$('status').textContent='Failed: '+(r.error||'unknown')+' (is Sharing via Wi-Fi on, VLC open, same network?)';$('status').className='err';return}
 $('status').textContent='Connected';load()}
async function load(){const s=await api('/api/state');files=s.files;if(s.host)$('host').value=s.host;if(!$('dest').value)$('dest').value=s.dest;sel.clear();render();if(s.job&&s.job.state)poll()}
$('go').onclick=connect;$('host').onkeydown=$('pass').onkeydown=e=>{if(e.key==='Enter')connect()};
$('refresh').onclick=async()=>{await api('/api/refresh',{});load()};
$('rows').onchange=e=>{const i=+e.target.dataset.i;e.target.checked?sel.add(i):sel.delete(i);render()};
$('chk').onchange=e=>{sel=e.target.checked?new Set(files.map(x=>x.id)):new Set();render()};
document.querySelectorAll('th[data-s]').forEach(t=>t.onclick=()=>{asc=sortBy===t.dataset.s?!asc:true;sortBy=t.dataset.s;render()});
async function save(ids){const r=await api('/api/save',{ids,dest:$('dest').value});if(!r.ok)alert(r.error);else poll()}
$('all').onclick=()=>save(null);$('sel').onclick=()=>save([...sel]);
let timer;async function poll(){clearInterval(timer);timer=setInterval(async()=>{const j=await api('/api/progress');if(!j.state)return;
 const p=$('prog');p.hidden=false;const pct=j.cur_total?Math.round(100*j.cur_done/j.cur_total):0;
 p.innerHTML=`<b>${j.state==='running'?'Downloading':j.state==='done'?'Finished':'Cancelled'}</b> ${j.finished}/${j.total} files${j.failed?` <span class="err">(${j.failed} failed)</span>`:''} -> ${esc(j.dest)}
 ${j.state==='running'?`<div class="mut">${esc(j.current)} ${fmt(j.cur_done)}${j.cur_total?' / '+fmt(j.cur_total):''}</div><div class="meter"><i style="width:${pct}%"></i></div><button id="cx">Cancel</button>`:''}
 ${j.errors.length?`<div class="err mut">${esc(j.errors.slice(-3).join(' | '))}</div>`:''}`;
 if($('cx'))$('cx').onclick=()=>api('/api/cancel',{});if(j.state!=='running')clearInterval(timer)},700)}
const drop=$('drop');['dragover','dragenter'].forEach(ev=>document.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('on')}));
['dragleave','drop'].forEach(ev=>document.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('on')}));
document.addEventListener('drop',async e=>{if(!files.length&&!$('host').value)return alert('Connect first');
 for(const f of e.dataTransfer.files){drop.textContent='Uploading '+f.name+' ...';
  const r=await fetch('/api/upload?name='+encodeURIComponent(f.name),{method:'POST',body:f}).then(r=>r.json());
  if(!r.ok)alert('Upload failed: '+(r.error||r.status))}
 drop.textContent='Upload done. Drop more files here, then press Refresh.'});
load();
</script></body></html>"""


if __name__ == "__main__":
    srv = ThreadingHTTPServer(("127.0.0.1", PORT), H)
    url = "http://localhost:%d" % PORT
    print("VLC iOS Browser running at", url, " (Ctrl+C to stop)")
    threading.Timer(0.6, lambda: webbrowser.open(url)).start()
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass
