fix: add libonig-dev for mbstring; update scanner for data discs
Dockerfile: add libonig-dev (oniguruma) — required by mbstring extension scanner/scan_disc.py: - Treat all discs as data discs (mp4/mkv/etc), no VIDEO_TS/BDMV logic - List video files by extension (.mp4 .mkv .avi .mov .m4v .ts .m2ts …) - Infer media type from drutil type string, fall back to used_gb capacity (>8 GB → Blu-ray, >0.68 GB → DVD, smaller → CD) - Store video file names in comment field, count in custom2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@
|
|||||||
"""
|
"""
|
||||||
MeDBia Disc Scanner — macOS client
|
MeDBia Disc Scanner — macOS client
|
||||||
===================================
|
===================================
|
||||||
Polls the optical drive, reads disc metadata, posts to the remote
|
Polls the optical drive, reads the disc content (video/data files),
|
||||||
videoDB API, then ejects the disc.
|
posts to the remote videoDB API, then ejects the disc.
|
||||||
|
|
||||||
|
All discs are treated as data discs containing media files (mp4, mkv, etc.).
|
||||||
|
Media type is inferred from disc capacity reported by drutil.
|
||||||
|
|
||||||
Setup:
|
Setup:
|
||||||
pip3 install requests
|
pip3 install requests
|
||||||
@@ -24,174 +27,176 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
sys.exit("Install requests first: pip3 install requests")
|
sys.exit("Install requests first: pip3 install requests")
|
||||||
|
|
||||||
# ── Config (override with environment variables) ───────────────────────────────
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
API_URL = os.environ.get("VIDEODB_URL", "http://your-server:6761").rstrip("/") + "/api_ingest.php"
|
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")
|
API_TOKEN = os.environ.get("VIDEODB_TOKEN", "change_this_secret_token")
|
||||||
POLL_SEC = int(os.environ.get("POLL_INTERVAL", "5"))
|
POLL_SEC = int(os.environ.get("POLL_INTERVAL", "5"))
|
||||||
|
|
||||||
# videoDB mediatype IDs (must match install.sql)
|
# videoDB mediatype IDs (from install.sql)
|
||||||
MEDIATYPE = {
|
MT_DVD = 1
|
||||||
"dvd": 1,
|
MT_BLURAY = 16
|
||||||
"bluray": 16,
|
MT_CD = 18
|
||||||
"cd": 18,
|
|
||||||
"data_cd": 18,
|
# Video file extensions to list in the index
|
||||||
|
VIDEO_EXT = {
|
||||||
|
".mp4", ".mkv", ".avi", ".mov", ".m4v", ".ts", ".m2ts",
|
||||||
|
".wmv", ".flv", ".webm", ".vob", ".mpg", ".mpeg", ".iso",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Known system volumes to ignore when scanning /Volumes/
|
# System volumes to ignore when scanning /Volumes/
|
||||||
IGNORE_VOLUMES = {"Macintosh HD", "Preboot", "Recovery", "VM", "Data", "Update"}
|
IGNORE_VOLUMES = {"Macintosh HD", "Preboot", "Recovery", "VM", "Data", "Update"}
|
||||||
|
|
||||||
|
|
||||||
# ── Shell helpers ──────────────────────────────────────────────────────────────
|
# ── Shell helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def run(cmd: list) -> str:
|
def run(cmd: list) -> str:
|
||||||
"""Run a command, return stdout (empty string on error)."""
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||||
return r.stdout
|
return r.stdout
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ── Disc detection ─────────────────────────────────────────────────────────────
|
# ── Disc presence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def disc_status() -> dict | None:
|
def disc_status() -> dict | None:
|
||||||
"""
|
"""Returns disc info dict, or None when no disc is present."""
|
||||||
Returns a dict with keys: drutil_type, device, tracks
|
|
||||||
Returns None when no disc is present.
|
|
||||||
"""
|
|
||||||
out = run(["drutil", "status"])
|
out = run(["drutil", "status"])
|
||||||
if not out or "No Media" in out:
|
if not out or "No Media" in out:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
info: dict = {}
|
info: dict = {}
|
||||||
|
|
||||||
# Type: DVD-ROM / Audio CD / CD-ROM / BD-ROM …
|
|
||||||
m = re.search(r"Type:\s+(.+?)(?:\s{3,}|$)", out, re.MULTILINE)
|
m = re.search(r"Type:\s+(.+?)(?:\s{3,}|$)", out, re.MULTILINE)
|
||||||
if m:
|
if m:
|
||||||
info["drutil_type"] = m.group(1).strip()
|
info["drutil_type"] = m.group(1).strip()
|
||||||
|
|
||||||
# Device node: /dev/disk2
|
|
||||||
m = re.search(r"Name:\s+(/dev/\S+)", out)
|
m = re.search(r"Name:\s+(/dev/\S+)", out)
|
||||||
if m:
|
if m:
|
||||||
info["device"] = m.group(1)
|
info["device"] = m.group(1)
|
||||||
|
|
||||||
# Track count
|
# Space Used in GB (e.g. "Space Used: 7.88 GB")
|
||||||
m = re.search(r"Tracks:\s+(\d+)", out)
|
m = re.search(r"Space Used:\s+([\d.]+)\s*GB", out)
|
||||||
if m:
|
if m:
|
||||||
info["tracks"] = int(m.group(1))
|
info["used_gb"] = float(m.group(1))
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mount ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def find_mount(device: str) -> str | None:
|
def find_mount(device: str) -> str | None:
|
||||||
"""Find where the optical disc is mounted."""
|
"""Return the mount point for the optical disc."""
|
||||||
# Try 'mount' output first
|
|
||||||
for line in run(["mount"]).splitlines():
|
for line in run(["mount"]).splitlines():
|
||||||
if device in line:
|
if device in line:
|
||||||
# "... on /Volumes/FOO (…)"
|
m = re.search(r" on (/Volumes/[^\s(]+)", line)
|
||||||
m = re.search(r" on (/Volumes/\S+)", line)
|
|
||||||
if m:
|
if m:
|
||||||
return m.group(1).rstrip("()")
|
return m.group(1)
|
||||||
|
|
||||||
# Fallback: new entry in /Volumes/ that isn't a system volume
|
# Fallback: first non-system entry in /Volumes/
|
||||||
volumes = set(os.listdir("/Volumes/")) - IGNORE_VOLUMES
|
try:
|
||||||
if volumes:
|
volumes = set(os.listdir("/Volumes/")) - IGNORE_VOLUMES
|
||||||
# Return the first one alphabetically
|
if volumes:
|
||||||
return f"/Volumes/{sorted(volumes)[0]}"
|
return f"/Volumes/{sorted(volumes)[0]}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Disc classification ───────────────────────────────────────────────────────
|
# ── Media type from disc capacity ──────────────────────────────────────────────
|
||||||
|
|
||||||
def classify(mount: str | None, drutil_type: str) -> tuple[int, str]:
|
def mediatype_from_size(used_gb: float) -> tuple[int, str]:
|
||||||
"""
|
"""
|
||||||
Returns (mediatype_id, label) for videoDB.
|
Infer videoDB mediatype from used disc capacity.
|
||||||
Checks filesystem structure first, falls back to drutil type string.
|
Blu-ray discs hold 25/50 GB; DVDs hold ~4.7/8.5 GB; CDs ~0.7 GB.
|
||||||
"""
|
"""
|
||||||
if mount:
|
if used_gb > 8.0:
|
||||||
p = Path(mount)
|
return MT_BLURAY, "Blu-ray"
|
||||||
if (p / "BDMV").exists():
|
if used_gb > 0.68:
|
||||||
return MEDIATYPE["bluray"], "Blu-ray"
|
return MT_DVD, "DVD"
|
||||||
if (p / "VIDEO_TS").exists() or (p / "VIDEO_TS.IFO").exists():
|
return MT_CD, "CD"
|
||||||
return MEDIATYPE["dvd"], "DVD"
|
|
||||||
|
|
||||||
|
|
||||||
|
def mediatype_from_drutil(drutil_type: str) -> tuple[int, str] | None:
|
||||||
|
"""Parse drutil type string if available."""
|
||||||
t = drutil_type.upper()
|
t = drutil_type.upper()
|
||||||
if "AUDIO" in t:
|
|
||||||
return MEDIATYPE["cd"], "Audio CD"
|
|
||||||
if "BD" in t:
|
if "BD" in t:
|
||||||
return MEDIATYPE["bluray"], "Blu-ray"
|
return MT_BLURAY, "Blu-ray"
|
||||||
if "DVD" in t:
|
if "DVD" in t:
|
||||||
return MEDIATYPE["dvd"], "DVD"
|
return MT_DVD, "DVD"
|
||||||
|
if "CD" in t:
|
||||||
return MEDIATYPE["cd"], "CD/Data"
|
return MT_CD, "CD"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def volume_label(mount: str | None, device: str) -> str:
|
# ── File listing ───────────────────────────────────────────────────────────────
|
||||||
"""Get the disc's volume label."""
|
|
||||||
if mount:
|
|
||||||
label = os.path.basename(mount)
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
|
|
||||||
if device:
|
def list_video_files(mount: str) -> list[str]:
|
||||||
for line in run(["diskutil", "info", device]).splitlines():
|
"""Return relative paths of all video files on the disc."""
|
||||||
if "Volume Name" in line:
|
found = []
|
||||||
return line.split(":", 1)[-1].strip()
|
try:
|
||||||
|
for root, _dirs, files in os.walk(mount):
|
||||||
return "Unknown Disc"
|
for f in files:
|
||||||
|
if Path(f).suffix.lower() in VIDEO_EXT:
|
||||||
|
rel = os.path.relpath(os.path.join(root, f), mount)
|
||||||
|
found.append(rel)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
def disc_size_bytes(mount: str | None) -> int:
|
def all_files_count(mount: str) -> int:
|
||||||
"""Total used space on the disc in bytes."""
|
"""Count every file on the disc (for discs with no video files)."""
|
||||||
if not mount:
|
count = 0
|
||||||
return 0
|
try:
|
||||||
|
for _root, _dirs, files in os.walk(mount):
|
||||||
|
count += len(files)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def disc_size_bytes(mount: str) -> int:
|
||||||
|
"""Used space in bytes via df."""
|
||||||
out = run(["df", "-k", mount])
|
out = run(["df", "-k", mount])
|
||||||
for line in out.splitlines()[1:]:
|
for line in out.splitlines()[1:]:
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
try:
|
try:
|
||||||
return int(parts[2]) * 1024 # 'Used' column, KB→B
|
return int(parts[2]) * 1024
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def sample_files(mount: str | None, limit: int = 30) -> list[str]:
|
# ── Volume label ───────────────────────────────────────────────────────────────
|
||||||
"""Return a sample of file paths on the disc."""
|
|
||||||
if not mount or not os.path.exists(mount):
|
def volume_label(mount: str | None, device: str) -> str:
|
||||||
return []
|
if mount:
|
||||||
found = []
|
label = os.path.basename(mount)
|
||||||
try:
|
if label:
|
||||||
for root, _dirs, files in os.walk(mount):
|
return label
|
||||||
for f in files:
|
if device:
|
||||||
rel = os.path.relpath(os.path.join(root, f), mount)
|
for line in run(["diskutil", "info", device]).splitlines():
|
||||||
found.append(rel)
|
if "Volume Name" in line:
|
||||||
if len(found) >= limit:
|
return line.split(":", 1)[-1].strip()
|
||||||
return found
|
return "Unknown Disc"
|
||||||
except PermissionError:
|
|
||||||
pass
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
# ── Eject ─────────────────────────────────────────────────────────────────────
|
# ── Eject ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def eject(mount: str | None):
|
def eject(mount: str | None):
|
||||||
"""Eject the disc. Try diskutil first, fall back to drutil."""
|
|
||||||
if mount:
|
if mount:
|
||||||
result = subprocess.run(
|
r = subprocess.run(["diskutil", "eject", mount], capture_output=True)
|
||||||
["diskutil", "eject", mount],
|
if r.returncode == 0:
|
||||||
capture_output=True, text=True
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
return
|
||||||
run(["drutil", "eject"])
|
run(["drutil", "eject"])
|
||||||
|
|
||||||
|
|
||||||
# ── API submission ────────────────────────────────────────────────────────────
|
# ── API submission ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def submit(payload: dict) -> bool:
|
def submit(payload: dict) -> bool:
|
||||||
"""POST disc data to the videoDB API. Returns True on success."""
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
API_URL,
|
API_URL,
|
||||||
@@ -201,20 +206,21 @@ def submit(payload: dict) -> bool:
|
|||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
print(f" [OK] Indexed as entry #{data.get('id', '?')}: {payload['title']}")
|
print(f" [OK] Entry #{data.get('id', '?')}: {payload['title']}")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
print(f" [ERR] API {resp.status_code}: {resp.text[:200]}")
|
print(f" [ERR] API {resp.status_code}: {resp.text[:300]}")
|
||||||
return False
|
return False
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
print(f" [ERR] Cannot reach {API_URL} — check VIDEODB_URL and network")
|
print(f" [ERR] Cannot reach {API_URL}")
|
||||||
|
print(f" Check VIDEODB_URL and that the server is up.")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [ERR] {e}")
|
print(f" [ERR] {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Main scan ─────────────────────────────────────────────────────────────────
|
# ── Main scan routine ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def scan_and_submit():
|
def scan_and_submit():
|
||||||
status = disc_status()
|
status = disc_status()
|
||||||
@@ -223,58 +229,70 @@ def scan_and_submit():
|
|||||||
|
|
||||||
drutil_type = status.get("drutil_type", "")
|
drutil_type = status.get("drutil_type", "")
|
||||||
device = status.get("device", "")
|
device = status.get("device", "")
|
||||||
tracks = status.get("tracks", 0)
|
used_gb = status.get("used_gb", 0.0)
|
||||||
|
|
||||||
print(f" drutil type : {drutil_type}")
|
print(f" drutil type : {drutil_type}")
|
||||||
print(f" device : {device}")
|
print(f" device : {device}")
|
||||||
print(f" tracks : {tracks}")
|
print(f" used : {used_gb:.2f} GB")
|
||||||
|
|
||||||
# Give macOS a moment to finish mounting
|
# Give macOS a moment to finish mounting the filesystem
|
||||||
time.sleep(3)
|
time.sleep(4)
|
||||||
|
|
||||||
mount = find_mount(device)
|
mount = find_mount(device)
|
||||||
print(f" mount point : {mount or '(not mounted)'}")
|
print(f" mount : {mount or '(not mounted)'}")
|
||||||
|
|
||||||
mediatype_id, mediatype_name = classify(mount, drutil_type)
|
# Determine media type — prefer drutil string, fall back to size
|
||||||
title = volume_label(mount, device)
|
mt = mediatype_from_drutil(drutil_type)
|
||||||
size = disc_size_bytes(mount)
|
if mt:
|
||||||
files = sample_files(mount)
|
mediatype_id, mediatype_name = mt
|
||||||
|
|
||||||
# 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:
|
else:
|
||||||
summary = ""
|
mediatype_id, mediatype_name = mediatype_from_size(used_gb)
|
||||||
|
|
||||||
|
title = volume_label(mount, device)
|
||||||
|
size_b = disc_size_bytes(mount) if mount else int(used_gb * 1024**3)
|
||||||
|
|
||||||
|
# Build file listing
|
||||||
|
video_files = list_video_files(mount) if mount else []
|
||||||
|
total_files = all_files_count(mount) if mount else 0
|
||||||
|
|
||||||
|
if video_files:
|
||||||
|
# List video file names (not full paths) for the comment field
|
||||||
|
names = [Path(f).name for f in video_files]
|
||||||
|
summary = f"{len(video_files)} video files: " + ", ".join(names[:10])
|
||||||
|
if len(names) > 10:
|
||||||
|
summary += f" … +{len(names) - 10} more"
|
||||||
|
# Store full file list in custom2 (255 char limit — truncate gracefully)
|
||||||
|
file_detail = "\n".join(video_files)
|
||||||
|
else:
|
||||||
|
summary = f"{total_files} files (no video files detected)"
|
||||||
|
file_detail = ""
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"mediatype": mediatype_id,
|
"mediatype": mediatype_id,
|
||||||
"comment": summary[:255],
|
"comment": summary[:255],
|
||||||
"filesize": size,
|
"filesize": size_b,
|
||||||
"disklabel": title[:32],
|
"disklabel": title[:32],
|
||||||
"custom1": drutil_type[:255], # raw disc type string
|
"custom1": drutil_type[:255],
|
||||||
"custom2": str(tracks) if tracks else str(len(files)), # track/file count
|
"custom2": str(len(video_files) or total_files),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"\n Submitting [{mediatype_name}] \"{title}\" ({size // (1024*1024)} MB)")
|
print(f"\n [{mediatype_name}] \"{title}\"")
|
||||||
|
print(f" {summary[:120]}")
|
||||||
ok = submit(payload)
|
ok = submit(payload)
|
||||||
|
|
||||||
print(" Ejecting...")
|
print(" Ejecting disc...")
|
||||||
eject(mount)
|
eject(mount)
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("=" * 55)
|
print("=" * 55)
|
||||||
print(" MeDBia Disc Scanner")
|
print(" MeDBia Disc Scanner")
|
||||||
print(f" API : {API_URL}")
|
print(f" API : {API_URL}")
|
||||||
print(f" Poll: every {POLL_SEC}s")
|
print(f" Poll : every {POLL_SEC}s")
|
||||||
print("=" * 55)
|
print("=" * 55)
|
||||||
print("Insert a disc to index it. Ctrl-C to stop.\n")
|
print("Insert a disc to index it. Ctrl-C to stop.\n")
|
||||||
|
|
||||||
@@ -287,11 +305,10 @@ def main():
|
|||||||
was_present = True
|
was_present = True
|
||||||
print("Disc detected!")
|
print("Disc detected!")
|
||||||
scan_and_submit()
|
scan_and_submit()
|
||||||
was_present = False # reset — disc was ejected
|
was_present = False
|
||||||
print("\nReady — insert next disc.\n")
|
print("\nReady — insert next disc.\n")
|
||||||
|
|
||||||
elif not status and was_present:
|
elif not status and was_present:
|
||||||
# Disc manually removed before scan completed
|
|
||||||
was_present = False
|
was_present = False
|
||||||
|
|
||||||
time.sleep(POLL_SEC)
|
time.sleep(POLL_SEC)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libpng-dev \
|
libpng-dev \
|
||||||
libjpeg-dev \
|
libjpeg-dev \
|
||||||
libfreetype6-dev \
|
libfreetype6-dev \
|
||||||
|
libonig-dev \
|
||||||
default-mysql-client \
|
default-mysql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user