#!/usr/bin/env python3 """ MeDBia Disc Scanner — macOS client =================================== Polls the optical drive, reads disc metadata, posts to the remote videoDB API, then ejects the disc. Setup: pip3 install requests export VIDEODB_URL=http://your-server:6761 export VIDEODB_TOKEN=change_this_secret_token python3 scan_disc.py """ import os import re import sys import time import subprocess from pathlib import Path try: import requests except ImportError: sys.exit("Install requests first: pip3 install requests") # ── Config (override with environment variables) ─────────────────────────────── API_URL = os.environ.get("VIDEODB_URL", "http://your-server:6761").rstrip("/") + "/api_ingest.php" API_TOKEN = os.environ.get("VIDEODB_TOKEN", "change_this_secret_token") POLL_SEC = int(os.environ.get("POLL_INTERVAL", "5")) # videoDB mediatype IDs (must match install.sql) MEDIATYPE = { "dvd": 1, "bluray": 16, "cd": 18, "data_cd": 18, } # Known system volumes to ignore when scanning /Volumes/ IGNORE_VOLUMES = {"Macintosh HD", "Preboot", "Recovery", "VM", "Data", "Update"} # ── Shell helpers ────────────────────────────────────────────────────────────── def run(cmd: list) -> str: """Run a command, return stdout (empty string on error).""" try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=15) return r.stdout except Exception: return "" # ── Disc detection ───────────────────────────────────────────────────────────── def disc_status() -> dict | None: """ Returns a dict with keys: drutil_type, device, tracks Returns None when no disc is present. """ out = run(["drutil", "status"]) if not out or "No Media" in out: return None info: dict = {} # Type: DVD-ROM / Audio CD / CD-ROM / BD-ROM … m = re.search(r"Type:\s+(.+?)(?:\s{3,}|$)", out, re.MULTILINE) if m: info["drutil_type"] = m.group(1).strip() # Device node: /dev/disk2 m = re.search(r"Name:\s+(/dev/\S+)", out) if m: info["device"] = m.group(1) # Track count m = re.search(r"Tracks:\s+(\d+)", out) if m: info["tracks"] = int(m.group(1)) return info def find_mount(device: str) -> str | None: """Find where the optical disc is mounted.""" # Try 'mount' output first for line in run(["mount"]).splitlines(): if device in line: # "... on /Volumes/FOO (…)" m = re.search(r" on (/Volumes/\S+)", line) if m: return m.group(1).rstrip("()") # Fallback: new entry in /Volumes/ that isn't a system volume volumes = set(os.listdir("/Volumes/")) - IGNORE_VOLUMES if volumes: # Return the first one alphabetically return f"/Volumes/{sorted(volumes)[0]}" return None # ── Disc classification ─────────────────────────────────────────────────────── def classify(mount: str | None, drutil_type: str) -> tuple[int, str]: """ Returns (mediatype_id, label) for videoDB. Checks filesystem structure first, falls back to drutil type string. """ if mount: p = Path(mount) if (p / "BDMV").exists(): return MEDIATYPE["bluray"], "Blu-ray" if (p / "VIDEO_TS").exists() or (p / "VIDEO_TS.IFO").exists(): return MEDIATYPE["dvd"], "DVD" t = drutil_type.upper() if "AUDIO" in t: return MEDIATYPE["cd"], "Audio CD" if "BD" in t: return MEDIATYPE["bluray"], "Blu-ray" if "DVD" in t: return MEDIATYPE["dvd"], "DVD" return MEDIATYPE["cd"], "CD/Data" def volume_label(mount: str | None, device: str) -> str: """Get the disc's volume label.""" if mount: label = os.path.basename(mount) if label: return label if device: for line in run(["diskutil", "info", device]).splitlines(): if "Volume Name" in line: return line.split(":", 1)[-1].strip() return "Unknown Disc" def disc_size_bytes(mount: str | None) -> int: """Total used space on the disc in bytes.""" if not mount: return 0 out = run(["df", "-k", mount]) for line in out.splitlines()[1:]: parts = line.split() if len(parts) >= 3: try: return int(parts[2]) * 1024 # 'Used' column, KB→B except ValueError: pass return 0 def sample_files(mount: str | None, limit: int = 30) -> list[str]: """Return a sample of file paths on the disc.""" if not mount or not os.path.exists(mount): return [] found = [] try: for root, _dirs, files in os.walk(mount): for f in files: rel = os.path.relpath(os.path.join(root, f), mount) found.append(rel) if len(found) >= limit: return found except PermissionError: pass return found # ── Eject ───────────────────────────────────────────────────────────────────── def eject(mount: str | None): """Eject the disc. Try diskutil first, fall back to drutil.""" if mount: result = subprocess.run( ["diskutil", "eject", mount], capture_output=True, text=True ) if result.returncode == 0: return run(["drutil", "eject"]) # ── API submission ──────────────────────────────────────────────────────────── def submit(payload: dict) -> bool: """POST disc data to the videoDB API. Returns True on success.""" try: resp = requests.post( API_URL, json=payload, headers={"X-API-Token": API_TOKEN}, timeout=15, ) if resp.status_code == 200: data = resp.json() print(f" [OK] Indexed as entry #{data.get('id', '?')}: {payload['title']}") return True else: print(f" [ERR] API {resp.status_code}: {resp.text[:200]}") return False except requests.ConnectionError: print(f" [ERR] Cannot reach {API_URL} — check VIDEODB_URL and network") return False except Exception as e: print(f" [ERR] {e}") return False # ── Main scan ───────────────────────────────────────────────────────────────── def scan_and_submit(): status = disc_status() if not status: return False drutil_type = status.get("drutil_type", "") device = status.get("device", "") tracks = status.get("tracks", 0) print(f" drutil type : {drutil_type}") print(f" device : {device}") print(f" tracks : {tracks}") # Give macOS a moment to finish mounting time.sleep(3) mount = find_mount(device) print(f" mount point : {mount or '(not mounted)'}") mediatype_id, mediatype_name = classify(mount, drutil_type) title = volume_label(mount, device) size = disc_size_bytes(mount) files = sample_files(mount) # Build a short content summary for the 'comment' field if tracks and mediatype_id == MEDIATYPE["cd"]: summary = f"{tracks} audio tracks" elif files: summary = f"{len(files)} files" if len(files) <= 10: summary += ": " + ", ".join(Path(f).name for f in files[:10]) else: summary = "" payload = { "title": title, "mediatype": mediatype_id, "comment": summary[:255], "filesize": size, "disklabel": title[:32], "custom1": drutil_type[:255], # raw disc type string "custom2": str(tracks) if tracks else str(len(files)), # track/file count } print(f"\n Submitting [{mediatype_name}] \"{title}\" ({size // (1024*1024)} MB)") ok = submit(payload) print(" Ejecting...") eject(mount) return ok # ── Entry point ─────────────────────────────────────────────────────────────── def main(): print("=" * 55) print(" MeDBia Disc Scanner") print(f" API : {API_URL}") print(f" Poll: every {POLL_SEC}s") print("=" * 55) print("Insert a disc to index it. Ctrl-C to stop.\n") was_present = False while True: status = disc_status() if status and not was_present: was_present = True print("Disc detected!") scan_and_submit() was_present = False # reset — disc was ejected print("\nReady — insert next disc.\n") elif not status and was_present: # Disc manually removed before scan completed was_present = False time.sleep(POLL_SEC) if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\nStopped.")