feat: add macOS disc scanner + API ingest endpoint
- scanner/scan_disc.py: polls optical drive via drutil, detects disc type (DVD/Blu-ray/Audio CD/Data CD), reads volume label, file/track count, posts to remote API, auto-ejects. Pure Python + requests, no drivers. - scanner/requirements.txt + README.md: setup and usage docs - videodb/api_ingest.php: authenticated POST endpoint that writes disc records directly into the videoDB MySQL schema; token stored in config - docker-compose.yml: adds INGEST_API_TOKEN env var - docker-entrypoint.sh: writes ingest_api_token into config.inc.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ services:
|
||||
DB_PASSWORD: videodb_secret
|
||||
DB_NAME: videodb
|
||||
DB_PREFIX: videodb_
|
||||
INGEST_API_TOKEN: change_this_secret_token
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
56
scanner/README.md
Normal file
56
scanner/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# MeDBia Disc Scanner (macOS)
|
||||
|
||||
Runs on your **Mac** — polls the optical drive, reads the disc, sends the record to the remote Docker stack, ejects.
|
||||
|
||||
## One-time setup
|
||||
|
||||
```bash
|
||||
# Install Python dependency
|
||||
pip3 install requests
|
||||
|
||||
# Set your server URL and the API token from docker-compose.yml
|
||||
export VIDEODB_URL=http://your-server:6761
|
||||
export VIDEODB_TOKEN=change_this_secret_token
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd scanner
|
||||
python3 scan_disc.py
|
||||
```
|
||||
|
||||
Then insert discs one by one. Each disc is:
|
||||
1. Detected (polled every 5 s)
|
||||
2. Read — volume label, disc type, size, file/track count
|
||||
3. Submitted to the remote videoDB
|
||||
4. Ejected automatically
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|------------------|--------------------------------|---------------------------------------|
|
||||
| `VIDEODB_URL` | `http://your-server:6761` | Base URL of the Docker stack |
|
||||
| `VIDEODB_TOKEN` | `change_this_secret_token` | Must match `INGEST_API_TOKEN` in docker-compose.yml |
|
||||
| `POLL_INTERVAL` | `5` | Seconds between drive checks |
|
||||
|
||||
## What gets indexed per disc
|
||||
|
||||
| Field | Source |
|
||||
|--------------|---------------------------------------------|
|
||||
| Title | Volume label (disc name as burned) |
|
||||
| Media type | Auto-detected: DVD / Blu-ray / CD |
|
||||
| Comment | Track count (audio) or file count (data) |
|
||||
| File size | Total used space on disc |
|
||||
| Custom 1 | Raw drutil type string |
|
||||
| Custom 2 | Track or file count (number only) |
|
||||
|
||||
All entries are immediately visible and searchable in the videoDB web UI at `http://your-server:6761`.
|
||||
|
||||
## macOS permissions
|
||||
|
||||
If macOS asks for permission to control the disc drive or access `/Volumes/`, allow it. The script uses `drutil`, `diskutil`, and `df` — all standard macOS CLI tools, no third-party drivers needed.
|
||||
|
||||
## Blu-ray note
|
||||
|
||||
macOS does not include a Blu-ray filesystem driver by default. Blu-ray discs will show as unreadable in Finder but `drutil status` will still report `BD-ROM`. The scanner will detect the type from drutil and index it, but file listing will be unavailable. Volume label may show as the device node instead of the disc name in that case — you can edit the entry in the web UI afterwards.
|
||||
1
scanner/requirements.txt
Normal file
1
scanner/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests>=2.28
|
||||
304
scanner/scan_disc.py
Normal file
304
scanner/scan_disc.py
Normal file
@@ -0,0 +1,304 @@
|
||||
#!/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.")
|
||||
87
videodb/api_ingest.php
Normal file
87
videodb/api_ingest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Disc ingest API
|
||||
*
|
||||
* Called by the local macOS scanner (scanner/scan_disc.py).
|
||||
* Accepts a JSON POST with disc metadata and inserts a record
|
||||
* into the videoDB database.
|
||||
*
|
||||
* Auth: X-API-Token header must match INGEST_API_TOKEN in config.inc.php
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// ── Load config ───────────────────────────────────────────────────────────────
|
||||
$config = [];
|
||||
require_once './config.sample.php';
|
||||
if (!@include_once './config.inc.php') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['error' => 'config.inc.php not found']);
|
||||
exit;
|
||||
}
|
||||
require_once './core/constants.php';
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
$expected_token = $config['ingest_api_token'] ?? '';
|
||||
$provided_token = $_SERVER['HTTP_X_API_TOKEN'] ?? '';
|
||||
|
||||
if (!$expected_token || $provided_token !== $expected_token) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Method guard ─────────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'POST required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Parse body ───────────────────────────────────────────────────────────────
|
||||
$body = file_get_contents('php://input');
|
||||
$data = json_decode($body, true);
|
||||
|
||||
if (!$data || empty($data['title'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Missing required field: title']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Connect ───────────────────────────────────────────────────────────────────
|
||||
$dbh = @mysqli_connect(
|
||||
$config['db_server'],
|
||||
$config['db_user'],
|
||||
$config['db_password'],
|
||||
$config['db_database']
|
||||
);
|
||||
if (!$dbh) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'DB connection failed: ' . mysqli_connect_error()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Sanitize inputs ───────────────────────────────────────────────────────────
|
||||
$title = mysqli_real_escape_string($dbh, substr($data['title'], 0, 255));
|
||||
$mediatype = (int)($data['mediatype'] ?? 1); // 1=DVD, 16=Blu-ray, 18=CD
|
||||
$comment = mysqli_real_escape_string($dbh, substr($data['comment'] ?? '', 0, 255));
|
||||
$filesize = (int)($data['filesize'] ?? 0);
|
||||
$custom1 = mysqli_real_escape_string($dbh, substr($data['custom1'] ?? '', 0, 255)); // raw drutil type
|
||||
$custom2 = mysqli_real_escape_string($dbh, substr($data['custom2'] ?? '', 0, 255)); // track count / file count
|
||||
$disklabel = mysqli_real_escape_string($dbh, substr($data['disklabel'] ?? '', 0, 32));
|
||||
|
||||
// ── Insert ────────────────────────────────────────────────────────────────────
|
||||
$sql = "INSERT INTO " . TBL_DATA . "
|
||||
(title, mediatype, comment, filesize, disklabel, custom1, custom2, created, owner_id)
|
||||
VALUES
|
||||
('$title', $mediatype, '$comment', $filesize, '$disklabel', '$custom1', '$custom2', NOW(), 1)";
|
||||
|
||||
if (mysqli_query($dbh, $sql)) {
|
||||
$id = (int)mysqli_insert_id($dbh);
|
||||
echo json_encode(['ok' => true, 'id' => $id, 'title' => $data['title']]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Insert failed: ' . mysqli_error($dbh)]);
|
||||
}
|
||||
|
||||
mysqli_close($dbh);
|
||||
@@ -6,6 +6,7 @@ DB_USER="${DB_USER:-videodb}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-videodb_secret}"
|
||||
DB_NAME="${DB_NAME:-videodb}"
|
||||
DB_PREFIX="${DB_PREFIX:-videodb_}"
|
||||
INGEST_API_TOKEN="${INGEST_API_TOKEN:-changeme}"
|
||||
|
||||
CONFIG_FILE="/var/www/html/config.inc.php"
|
||||
|
||||
@@ -55,6 +56,7 @@ cat > "$CONFIG_FILE" <<PHP
|
||||
\$config['xls_extra_fields'] = 'title (plot), diskid, genres, language, mediatype, runtime, year, custom1, custom2, custom3, custom4, insertdate, owner, lent';
|
||||
\$config['dvdb_user'] = '';
|
||||
\$config['dvdb_password'] = '';
|
||||
\$config['ingest_api_token'] = '${INGEST_API_TOKEN}';
|
||||
PHP
|
||||
|
||||
chown www-data:www-data "$CONFIG_FILE"
|
||||
|
||||
Reference in New Issue
Block a user