641 lines
25 KiB
Python
641 lines
25 KiB
Python
#!/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())
|