#!/usr/bin/env python3 """Build chronological, per-day editing manifests for home videos.""" import argparse from collections import defaultdict from datetime import datetime, timezone import json import os import re import subprocess import sys from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".mkv", ".avi", ".webm", ".mts", ".m2ts", ".3gp"} NAME_DATE = re.compile(r"(? 0), None) if duration is None: raise ValueError("no positive duration") return data, duration def recorded_at(path, data, local_zone, fallback): # QuickTime metadata is typically UTC. An embedded offset takes precedence. tags = [data.get("format", {}).get("tags", {})] tags += [s.get("tags", {}) for s in data.get("streams", [])] for key in ("com.apple.quicktime.creationdate", "creation_time", "date"): for group in tags: value = next((v for k, v in group.items() if k.lower() == key), None) if not value: continue try: stamp = datetime.fromisoformat(value.replace("Z", "+00:00")) if stamp.tzinfo is None: stamp = stamp.replace(tzinfo=local_zone if key != "creation_time" else timezone.utc) return stamp.astimezone(local_zone), "metadata" except ValueError: pass match = NAME_DATE.search(path.stem) if match: try: y, m, d, h, minute, second = match.groups() return datetime(int(y), int(m), int(d), int(h or 0), int(minute or 0), int(second or 0), tzinfo=local_zone), "filename" except ValueError: pass if fallback == "mtime": return datetime.fromtimestamp(path.stat().st_mtime, local_zone), "mtime" raise ValueError("no recording timestamp; add a dated filename or use --fallback mtime") def write_json(path, value): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def existing_manifest_title(path): """Return a title already set in a daily manifest, if one exists.""" try: manifest = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return "" if isinstance(manifest, dict) and isinstance(manifest.get("title"), str): return manifest["title"] return "" def normalized_path(value): return os.path.normcase(os.path.abspath(str(value))) def find_existing_project(project_root, source): """Find a project from an earlier layout, preferring a direct path match.""" wanted = normalized_path(source) for candidate in project_root.rglob("*.llc"): try: data = json.loads(candidate.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue media_name = data.get("mediaFileName", "") if media_name and normalized_path(media_name) == wanted: return candidate, data metadata_path = candidate.parent / "source.json" try: metadata = json.loads(metadata_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): metadata = {} if metadata.get("source_path") and normalized_path(metadata["source_path"]) == wanted: return candidate, data return None, None def remove_old_project_artifacts(old_project, source, project_root): if old_project is None or not old_project.exists(): return old_dir = old_project.parent alias = old_dir / source.name if alias.exists(): try: if alias.samefile(source): alias.unlink() print(f"Removed obsolete hard-link entry: {alias}") except OSError as exc: print(f"Warning: could not remove old media link {alias}: {exc}", file=sys.stderr) metadata_path = old_dir / "source.json" try: metadata = json.loads(metadata_path.read_text(encoding="utf-8")) if metadata.get("source_path") and normalized_path(metadata["source_path"]) == normalized_path(source): metadata_path.unlink() except (OSError, json.JSONDecodeError): pass old_project.unlink(missing_ok=True) current = old_dir while current != project_root: try: current.rmdir() except OSError: break current = current.parent def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("input", type=Path, help="folder containing videos (searched recursively)") parser.add_argument("output", type=Path, help="folder for daily JSON manifests and .llc files") parser.add_argument("--timezone", default="UTC", help="IANA filming timezone, e.g. America/New_York (default: UTC)") parser.add_argument("--fallback", choices=("skip", "mtime"), default="skip", help="date source when metadata and filename lack a date") parser.add_argument("--date", help="only produce one filming day, YYYY-MM-DD") args = parser.parse_args() try: local_zone = ZoneInfo(args.timezone) except ZoneInfoNotFoundError: parser.error("unknown timezone; install tzdata if your system lacks zone information") if args.date: try: datetime.strptime(args.date, "%Y-%m-%d") except ValueError: parser.error("--date must be YYYY-MM-DD") source = args.input.expanduser().resolve() dest = args.output.expanduser().resolve() if not source.is_dir(): parser.error("input must be an existing directory") if source == dest or source in dest.parents: parser.error("output must be outside input so generated media is not rescanned") days = defaultdict(list) excluded = 0 failed = 0 for path in sorted(source.rglob("*")): if not path.is_file() or path.suffix.lower() not in VIDEO_EXTENSIONS: continue try: data, duration = probe(path) stamp, date_source = recorded_at(path, data, local_zone, args.fallback) day = stamp.date().isoformat() if args.date and day != args.date: continue if duration < 5: excluded += 1 print(f"Excluded (<5s): {path}") continue days[day].append((stamp, path.resolve(), duration, date_source)) except (subprocess.CalledProcessError, json.JSONDecodeError, ValueError, OSError) as exc: failed += 1 print(f"Skipped {path}: {exc}", file=sys.stderr) project_root = dest for day, videos in sorted(days.items()): entries = [] for stamp, path, duration, date_source in sorted(videos, key=lambda row: (row[0], str(row[1]))): if duration > 120: base_name = f"{day}__{path.stem}" old_project, existing = find_existing_project(project_root, path) suffix = 1 while True: numbered_name = base_name if suffix == 1 else f"{base_name}__{suffix}" llc = project_root / f"{numbered_name}.llc" if not llc.exists(): break try: current = json.loads(llc.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): current = {} if current.get("mediaFileName") and normalized_path(current["mediaFileName"]) == normalized_path(path): old_project, existing = llc, current break suffix += 1 try: if existing is None: existing = {"version": 2, "mediaFileName": str(path), "cutSegments": [{"start": 0, "end": duration, "name": "", "selected": True}]} # This LosslessCut build supports an absolute mediaFileName. existing["version"] = 2 existing["mediaFileName"] = str(path) segments = existing.setdefault("cutSegments", []) for segment in segments: segment.setdefault("name", "") segment.setdefault("selected", True) write_json(llc, existing) if old_project != llc: remove_old_project_artifacts(old_project, path, project_root) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"cannot reuse existing project {llc}: {exc}") from exc target = llc else: target = path entries.append(str(target)) if date_source == "mtime": print(f"Warning: filesystem modified time used for {path}", file=sys.stderr) manifest = dest / f"{day}.json" write_json(manifest, {"title": existing_manifest_title(manifest), "videos": entries}) print(f"{manifest}: {len(entries)} videos") print(f"Finished: {len(days)} days, {excluded} short clips excluded, {failed} files skipped") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())