#!/usr/bin/env python3 """Review a daily JSON video manifest and save its title.""" from __future__ import annotations import argparse import ctypes import json import os import platform import struct import sys import tkinter as tk from dataclasses import dataclass from pathlib import Path from tkinter import filedialog, messagebox, ttk DEFAULT_VLC_ROOT = Path(r"C:\PortableApps\VLCPortable3") TITLE_SAVE_DELAY_MS = 700 SEEK_SECONDS = 10 @dataclass class ReviewItem: manifest_value: str path: Path | None error: str = "" class WindowsShortcutPoller: """Poll review shortcuts on Tk's thread, including while VLC has focus.""" VK_LEFT = 0x25 VK_RIGHT = 0x27 VK_SPACE = 0x20 VK_J = 0x4A VK_L = 0x4C GA_ROOT = 2 POLL_INTERVAL_MS = 30 SHORTCUTS = { VK_LEFT: "Left", VK_RIGHT: "Right", VK_SPACE: "space", VK_J: "j", VK_L: "l", } def __init__(self, root: tk.Tk, title_entry: ttk.Entry, action): self.root = root self.title_entry = title_entry self.action = action self.after_id = None self.pressed = {key: False for key in self.SHORTCUTS} self.user32 = ctypes.WinDLL("user32", use_last_error=True) self.user32.GetForegroundWindow.restype = ctypes.c_void_p self.user32.GetAncestor.argtypes = (ctypes.c_void_p, ctypes.c_uint) self.user32.GetAncestor.restype = ctypes.c_void_p self.user32.GetAsyncKeyState.argtypes = (ctypes.c_int,) self.user32.GetAsyncKeyState.restype = ctypes.c_short def start(self): self.poll() def _reviewer_is_active(self) -> bool: foreground = self.user32.GetForegroundWindow() root_handle = self.root.winfo_id() app_window = self.user32.GetAncestor(root_handle, self.GA_ROOT) or root_handle return foreground == app_window def _title_entry_has_focus(self) -> bool: focus = self.root.focus_get() return focus is not None and str(focus) == str(self.title_entry) def poll(self): active = self._reviewer_is_active() title_has_focus = active and self._title_entry_has_focus() for virtual_key, shortcut in self.SHORTCUTS.items(): is_down = bool(self.user32.GetAsyncKeyState(virtual_key) & 0x8000) if is_down and not self.pressed[virtual_key] and active and not title_has_focus: self.action(shortcut) self.pressed[virtual_key] = is_down self.after_id = self.root.after(self.POLL_INTERVAL_MS, self.poll) def close(self): if self.after_id is not None: self.root.after_cancel(self.after_id) self.after_id = None def find_vlc_directory(root: Path) -> Path | None: """Accept a VLC directory or common portable-app root layouts.""" for candidate in (root, root / "App" / "vlc", root / "vlc"): if (candidate / "libvlc.dll").is_file(): return candidate return None def pe_architecture(path: Path) -> str | None: """Return the architecture recorded in a Windows executable or DLL.""" try: with path.open("rb") as source: if source.read(2) != b"MZ": return None source.seek(0x3C) header_offset = struct.unpack(" str: machine = platform.machine().lower() if machine in {"arm64", "aarch64"}: return "ARM64" return "x64" if struct.calcsize("P") == 8 else "x86" def load_vlc(vlc_root: Path | None): """Load python-vlc, configuring DLL and plugin lookup for portable VLC.""" if vlc_root is None: configured = os.environ.get("VLC_DIR") vlc_root = Path(configured) if configured else DEFAULT_VLC_ROOT vlc_dir = find_vlc_directory(vlc_root) if vlc_dir is None and vlc_root.exists(): raise RuntimeError(f"Could not find libvlc.dll under {vlc_root}") if vlc_dir is not None: library = vlc_dir / "libvlc.dll" plugins = vlc_dir / "plugins" if not plugins.is_dir(): raise RuntimeError(f"Could not find VLC plugins directory: {plugins}") dll_arch = pe_architecture(library) python_arch = python_architecture() if dll_arch is not None and dll_arch != python_arch: raise RuntimeError( f"VLC is {dll_arch}, but this Python is {python_arch}. " f"Install {python_arch} VLC or run the reviewer with {dll_arch} Python." ) os.environ["PYTHON_VLC_LIB_PATH"] = str(library) os.environ["PYTHON_VLC_MODULE_PATH"] = str(plugins) if hasattr(os, "add_dll_directory"): # Keep this handle alive for as long as libVLC can load plugins. dll_directory = os.add_dll_directory(str(vlc_dir)) else: dll_directory = None try: ctypes.CDLL(str(library)) except OSError as exc: raise RuntimeError(f"Windows could not load {library}: {exc}") from exc else: dll_directory = None try: import vlc except ImportError as exc: raise RuntimeError( "python-vlc is missing. Install it with: py -m pip install -r requirements-review.txt" ) from exc except (OSError, NotImplementedError) as exc: raise RuntimeError(f"Could not load VLC. Check the VLC runtime path: {exc}") from exc except SystemExit as exc: raise RuntimeError( "python-vlc found the configured DLL path but failed to load the VLC runtime. " "Check that VLC and Python have matching architectures." ) from exc return vlc, dll_directory, vlc_dir def resolve_item(value: object, manifest_path: Path) -> ReviewItem: if not isinstance(value, str): return ReviewItem(str(value), None, "Manifest entry is not a path string") path = Path(value).expanduser() if not path.is_absolute(): path = manifest_path.parent / path path = path.resolve() if path.suffix.lower() == ".llc": try: project = json.loads(path.read_text(encoding="utf-8")) media_name = project.get("mediaFileName") if isinstance(project, dict) else None if not isinstance(media_name, str) or not media_name: raise ValueError("project has no mediaFileName") media_path = Path(media_name).expanduser() if not media_path.is_absolute(): media_path = path.parent / media_path path = media_path.resolve() except (OSError, json.JSONDecodeError, ValueError) as exc: return ReviewItem(value, None, f"Could not read LosslessCut project: {exc}") return ReviewItem(value, path) class ManifestReviewer: def __init__(self, root: tk.Tk, vlc, vlc_dir: Path | None): self.root = root self.vlc = vlc self.vlc_dir = vlc_dir self.instance = vlc.Instance() if self.instance is None: raise RuntimeError("Could not initialize VLC. Check its plugins directory.") self.player = self.instance.media_player_new() # Let Tk own shortcuts and mouse handling for the embedded video area. self.player.video_set_key_input(False) self.player.video_set_mouse_input(False) self.current_media = None self.shortcut_poller: WindowsShortcutPoller | None = None self.manifest_path: Path | None = None self.manifest_data: dict = {} self.raw_videos: list = [] self.items: list[ReviewItem] = [] self.current_index = -1 self.completion_handled_index: int | None = None self.title_after_id: str | None = None self.loading_title = False self.title_dirty = False self.duration_ms = 0 self.dragging_seek = False self.root.title("Home Video Reviewer") self.root.geometry("1120x720") self.root.minsize(800, 520) self.root.protocol("WM_DELETE_WINDOW", self.close) outer = ttk.Frame(root, padding=10) outer.pack(fill="both", expand=True) title_row = ttk.Frame(outer) title_row.pack(fill="x", pady=(0, 8)) ttk.Label(title_row, text="Video title:").pack(side="left") self.title_var = tk.StringVar() self.title_entry = ttk.Entry(title_row, textvariable=self.title_var) self.title_entry.pack(side="left", fill="x", expand=True, padx=(8, 8)) self.title_entry.bind("", self.focus_title_entry, add="+") ttk.Button(title_row, text="Open manifest…", command=self.open_dialog).pack(side="right", padx=(0, 6)) ttk.Button(title_row, text="Save title", command=lambda: self.save_manifest(force=True)).pack(side="right") content = ttk.Frame(outer) content.pack(fill="both", expand=True) content.columnconfigure(1, weight=1) content.rowconfigure(0, weight=1) queue_frame = ttk.Frame(content, width=280) queue_frame.grid(row=0, column=0, sticky="nsw", padx=(0, 10)) queue_frame.grid_propagate(False) ttk.Label(queue_frame, text="Videos").pack(anchor="w", pady=(0, 5)) list_frame = ttk.Frame(queue_frame) list_frame.pack(fill="both", expand=True) self.video_list = tk.Listbox(list_frame, activestyle="dotbox", exportselection=False) self.video_list.pack(side="left", fill="both", expand=True) list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=self.video_list.yview) list_scroll.pack(side="right", fill="y") self.video_list.configure(yscrollcommand=list_scroll.set) self.video_list.bind("<>", self.on_list_select) self.video_frame = tk.Frame(content, background="black") self.video_frame.grid(row=0, column=1, sticky="nsew") control_row = ttk.Frame(outer) control_row.pack(fill="x", pady=(8, 4)) self.previous_button = ttk.Button(control_row, text="Previous", command=lambda: self.navigate(-1)) self.previous_button.pack(side="left") self.play_button = ttk.Button(control_row, text="Play", command=self.toggle_play) self.play_button.pack(side="left", padx=(6, 0)) self.next_button = ttk.Button(control_row, text="Next", command=lambda: self.navigate(1)) self.next_button.pack(side="left", padx=(6, 12)) self.time_var = tk.StringVar(value="0:00 / 0:00") ttk.Label(control_row, textvariable=self.time_var, width=13, anchor="center").pack(side="left") self.seek_var = tk.DoubleVar(value=0) self.seek_scale = ttk.Scale(control_row, from_=0, to=1, variable=self.seek_var, command=self.on_seek_drag) self.seek_scale.pack(side="left", fill="x", expand=True, padx=8) self.seek_scale.bind("", self.begin_seek) self.seek_scale.bind("", self.finish_seek) self.status_var = tk.StringVar(value="Open a daily JSON manifest to begin.") self.title_var.trace_add("write", self.schedule_title_save) ttk.Label(outer, textvariable=self.status_var, anchor="w").pack(fill="x", pady=(4, 0)) self.shortcut_tag = f"HomeVideoReviewerShortcuts{id(self)}" self.mouse_focus_tag = f"HomeVideoReviewerMouseFocus{id(self)}" self.root.bind_class(self.shortcut_tag, "", self.on_key_press) self.root.bind_class(self.mouse_focus_tag, "", self.on_mouse_down) self.install_shortcut_bindings(self.root) self.root.after(250, self.update_playback_status) def install_shortcut_bindings(self, widget): """Clear stale entry focus on clicks and run shortcuts before class bindings.""" tags = widget.bindtags() prefix = [] if self.mouse_focus_tag not in tags: prefix.append(self.mouse_focus_tag) if widget is not self.title_entry: if self.shortcut_tag not in tags: prefix.append(self.shortcut_tag) if prefix: widget.bindtags((*prefix, *tags)) for child in widget.winfo_children(): self.install_shortcut_bindings(child) def on_mouse_down(self, event): if event.widget is not self.title_entry: event.widget.focus_set() return None def focus_title_entry(self, _event=None): self.root.after_idle(self.title_entry.focus_force) def open_manifest(self, path: Path): try: data = json.loads(path.read_text(encoding="utf-8")) if isinstance(data, list): title = "" videos = data manifest_data = {} elif isinstance(data, dict): title = data.get("title", "") videos = data.get("videos", []) if not isinstance(title, str) or not isinstance(videos, list): raise ValueError("manifest needs a string 'title' and an array 'videos'") manifest_data = dict(data) else: raise ValueError("manifest must be a JSON object or a legacy array of paths") except (OSError, json.JSONDecodeError, ValueError) as exc: messagebox.showerror("Cannot open manifest", str(exc), parent=self.root) return if not self.save_manifest(): return self.stop_video() self.manifest_path = path.resolve() self.manifest_data = manifest_data self.raw_videos = videos self.items = [resolve_item(value, self.manifest_path) for value in videos] self.current_index = -1 self.completion_handled_index = None self.loading_title = True self.title_var.set(title) self.loading_title = False self.title_dirty = False self.video_list.delete(0, "end") for index, item in enumerate(self.items, start=1): name = item.path.name if item.path is not None else Path(item.manifest_value).name self.video_list.insert("end", f"{index:02d}. {name}") self.root.title(f"Home Video Reviewer — {self.manifest_path.name}") if self.items: self.select_video(0) self.video_list.focus_set() else: self.status_var.set("This manifest has no videos.") self.update_navigation_buttons() def open_dialog(self): selected = filedialog.askopenfilename( parent=self.root, title="Open daily manifest", filetypes=(("JSON manifests", "*.json"), ("All files", "*.*")), ) if selected: self.open_manifest(Path(selected)) def select_video(self, index: int): if index < 0 or index >= len(self.items): return self.current_index = index self.video_list.selection_clear(0, "end") self.video_list.selection_set(index) self.video_list.activate(index) self.video_list.see(index) self.play_item(self.items[index]) self.update_navigation_buttons() def on_list_select(self, _event): selected = self.video_list.curselection() if selected and selected[0] != self.current_index: self.select_video(selected[0]) def navigate(self, offset: int): target = self.current_index + offset if 0 <= target < len(self.items): self.select_video(target) def update_navigation_buttons(self): self.previous_button.configure(state="normal" if self.current_index > 0 else "disabled") self.next_button.configure(state="normal" if self.current_index + 1 < len(self.items) else "disabled") def play_item(self, item: ReviewItem): self.stop_video() self.completion_handled_index = None if item.path is None: self.status_var.set(item.error) self.play_button.configure(text="Play") return if not item.path.is_file(): self.status_var.set(f"File not found: {item.path}") self.play_button.configure(text="Play") return try: self.root.update_idletasks() self.attach_video_output() self.current_media = self.instance.media_new_path(str(item.path)) self.player.set_media(self.current_media) self.player.play() self.play_button.configure(text="Pause") self.duration_ms = 0 self.seek_scale.configure(to=1) self.seek_var.set(0) self.time_var.set("0:00 / 0:00") self.status_var.set(f"{self.current_index + 1} of {len(self.items)} — {item.path}") except (AttributeError, OSError, RuntimeError) as exc: self.status_var.set(f"Could not play {item.path.name}: {exc}") def attach_video_output(self): window_id = self.video_frame.winfo_id() if sys.platform.startswith("win"): self.player.set_hwnd(window_id) elif sys.platform == "darwin": self.player.set_nsobject(window_id) else: self.player.set_xwindow(window_id) def stop_video(self): try: self.player.stop() self.player.set_media(None) except (AttributeError, RuntimeError): pass self.current_media = None self.play_button.configure(text="Play") self.duration_ms = 0 self.seek_scale.configure(to=1) self.seek_var.set(0) self.time_var.set("0:00 / 0:00") def toggle_play(self): if self.current_index < 0 or self.items[self.current_index].path is None: return if self.player.is_playing(): self.player.pause() self.play_button.configure(text="Play") elif self.player.get_media() is not None: self.completion_handled_index = None self.player.play() self.play_button.configure(text="Pause") def seek_by(self, seconds: int): if self.player.get_media() is None: return current_ms = self.player.get_time() if current_ms < 0: current_ms = 0 target_ms = max(0, current_ms + seconds * 1000) if self.duration_ms > 0: target_ms = min(target_ms, self.duration_ms) self.player.set_time(target_ms) def begin_seek(self, _event): self.dragging_seek = True def on_seek_drag(self, value): if self.dragging_seek and self.player.get_media() is not None: self.player.set_time(int(float(value) * 1000)) def finish_seek(self, _event): self.dragging_seek = False if self.player.get_media() is not None: self.player.set_time(int(self.seek_var.get() * 1000)) @staticmethod def format_time(milliseconds: int) -> str: seconds = max(0, milliseconds // 1000) return f"{seconds // 60}:{seconds % 60:02d}" def update_playback_status(self): if self.player.get_media() is not None: if ( self.player.get_state() == self.vlc.State.Ended and self.completion_handled_index != self.current_index ): self.completion_handled_index = self.current_index if self.current_index + 1 < len(self.items): self.navigate(1) self.root.after(250, self.update_playback_status) return length = self.player.get_length() current = self.player.get_time() if length > 0: self.duration_ms = length self.seek_scale.configure(to=max(1, length / 1000)) if current >= 0: if not self.dragging_seek: self.seek_var.set(current / 1000) self.time_var.set(f"{self.format_time(current)} / {self.format_time(self.duration_ms)}") self.play_button.configure(text="Pause" if self.player.is_playing() else "Play") self.root.after(250, self.update_playback_status) def _title_entry_has_focus(self) -> bool: focus = self.root.focus_get() return focus is not None and str(focus) == str(self.title_entry) def on_key_press(self, event): if event.keysym == "Left": key = "Left" elif event.keysym == "Right": key = "Right" elif event.keysym == "space": key = "space" else: key = event.char.lower() if self.shortcut_poller is not None: if event.widget is self.title_entry: return None if key in WindowsShortcutPoller.SHORTCUTS.values(): return "break" return None if self.handle_shortcut(key): return "break" return None def handle_shortcut(self, key: str) -> bool: if self._title_entry_has_focus(): return False if key == "Left": self.navigate(-1) return True if key == "Right": self.navigate(1) return True if key == "j": self.seek_by(-SEEK_SECONDS) return True if key == "l": self.seek_by(SEEK_SECONDS) return True if key == "space": self.toggle_play() return True return False def schedule_title_save(self, *_args): if self.loading_title or self.manifest_path is None: return self.title_dirty = True self.status_var.set("Title changed; saving…") if self.title_after_id is not None: self.root.after_cancel(self.title_after_id) self.title_after_id = self.root.after(TITLE_SAVE_DELAY_MS, self.save_manifest) def save_manifest(self, force: bool = False): if self.manifest_path is None or (not self.title_dirty and not force): return True if self.title_after_id is not None: self.root.after_cancel(self.title_after_id) self.title_after_id = None self.manifest_data["title"] = self.title_var.get() self.manifest_data["videos"] = self.raw_videos temp_path = self.manifest_path.with_name(self.manifest_path.name + ".tmp") try: temp_path.write_text( json.dumps(self.manifest_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) os.replace(temp_path, self.manifest_path) except OSError as exc: self.status_var.set(f"Could not save title: {exc}") try: temp_path.unlink(missing_ok=True) except OSError: pass return False self.title_dirty = False self.status_var.set(f"Title saved to {self.manifest_path.name}") return True def close(self): if not self.save_manifest(): messagebox.showerror( "Title not saved", "The manifest could not be saved. Fix the file access problem and try closing again.", parent=self.root, ) return self.stop_video() if self.shortcut_poller is not None: self.shortcut_poller.close() self.player.release() self.instance.release() self.root.destroy() def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("manifest", nargs="?", type=Path, help="daily .json manifest to review") parser.add_argument( "--vlc-dir", type=Path, help="VLC install folder or portable app root (defaults to VLC_DIR or C:\\PortableApps\\VLCPortable3)", ) args = parser.parse_args() try: vlc, dll_directory, vlc_dir = load_vlc(args.vlc_dir) except RuntimeError as exc: print(f"Error: {exc}", file=sys.stderr) return 2 root = tk.Tk() try: reviewer = ManifestReviewer(root, vlc, vlc_dir) except Exception as exc: root.destroy() print(f"Error initializing reviewer: {exc}", file=sys.stderr) return 2 # Keep the Windows DLL search directory alive through VLC shutdown. reviewer.dll_directory = dll_directory if sys.platform == "win32": try: reviewer.shortcut_poller = WindowsShortcutPoller(root, reviewer.title_entry, reviewer.handle_shortcut) reviewer.shortcut_poller.start() except OSError as exc: reviewer.shortcut_poller = None reviewer.status_var.set(f"Windows keyboard shortcuts unavailable: {exc}") if args.manifest: reviewer.open_manifest(args.manifest.expanduser().resolve()) else: root.after(0, reviewer.open_dialog) root.mainloop() return 0 if __name__ == "__main__": raise SystemExit(main())