first commit

This commit is contained in:
2026-10-03 14:54:07 -04:00
commit 3a0691c2ad
19 changed files with 2332 additions and 0 deletions
+640
View File
@@ -0,0 +1,640 @@
#!/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("<I", source.read(4))[0]
source.seek(header_offset)
if source.read(4) != b"PE\0\0":
return None
machine = struct.unpack("<H", source.read(2))[0]
except (OSError, struct.error):
return None
return {0x014C: "x86", 0x8664: "x64", 0xAA64: "ARM64"}.get(machine)
def python_architecture() -> 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("<Button-1>", 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("<<ListboxSelect>>", 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("<ButtonPress-1>", self.begin_seek)
self.seek_scale.bind("<ButtonRelease-1>", 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, "<KeyPress>", self.on_key_press)
self.root.bind_class(self.mouse_focus_tag, "<Button-1>", 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())