feat: Implement a new Windows GUI for NVR export.

This commit is contained in:
MrUnknownDE
2025-12-30 14:26:14 +01:00
parent 1b22018b8e
commit 3dcb44ef7e
4 changed files with 486 additions and 22 deletions

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch:
jobs:
build-windows:
build-windows-cli:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
@@ -23,7 +23,7 @@ jobs:
pip install pyinstaller
pip install -e .
- name: Build executable
- name: Build CLI executable
run: |
pyinstaller --onefile --name nvr-export --console src/tplink_nvr_export/cli.py
@@ -34,8 +34,34 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: nvr-export-windows
name: nvr-export-cli-windows
path: dist/nvr-export.exe
build-windows-gui:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -e .
- name: Build GUI executable
run: |
pyinstaller --onefile --name nvr-export-gui --windowed --icon=NONE src/tplink_nvr_export/gui.py
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: nvr-export-gui-windows
path: dist/nvr-export-gui.exe
build-linux:
runs-on: ubuntu-latest
@@ -53,7 +79,7 @@ jobs:
pip install pyinstaller
pip install -e .
- name: Build executable
- name: Build CLI executable
run: |
pyinstaller --onefile --name nvr-export --console src/tplink_nvr_export/cli.py
@@ -71,17 +97,23 @@ jobs:
path: dist/nvr-export
release:
needs: [build-windows, build-linux]
needs: [build-windows-cli, build-windows-gui, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download Windows artifact
- name: Download Windows CLI artifact
uses: actions/download-artifact@v4
with:
name: nvr-export-windows
path: windows
name: nvr-export-cli-windows
path: windows-cli
- name: Download Windows GUI artifact
uses: actions/download-artifact@v4
with:
name: nvr-export-gui-windows
path: windows-gui
- name: Download Linux artifact
uses: actions/download-artifact@v4
@@ -91,13 +123,29 @@ jobs:
- name: Rename artifacts
run: |
mv windows/nvr-export.exe nvr-export-windows.exe
mv windows-cli/nvr-export.exe nvr-export-cli-windows.exe
mv windows-gui/nvr-export-gui.exe nvr-export-gui-windows.exe
mv linux/nvr-export nvr-export-linux
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
nvr-export-windows.exe
nvr-export-cli-windows.exe
nvr-export-gui-windows.exe
nvr-export-linux
body: |
## Downloads
| File | Description |
|------|-------------|
| `nvr-export-gui-windows.exe` | 🖥️ Windows GUI (double-click to run) |
| `nvr-export-cli-windows.exe` | ⌨️ Windows CLI (for automation/scripts) |
| `nvr-export-linux` | 🐧 Linux CLI |
### Quick Start (Windows GUI)
1. Download `nvr-export-gui-windows.exe`
2. Double-click to run
3. Enter NVR IP, credentials, and time range
4. Click "Export"
generate_release_notes: true

View File

@@ -11,6 +11,7 @@ A CLI tool to export video recordings from TP-Link Vigi NVRs over a specified ti
- 🎯 Filter by recording type (continuous, motion, alarm)
- 📊 Progress bar during downloads
- 🔒 Secure authentication via OpenAPI
- 🖥️ **Windows GUI** - No command line needed!
## Requirements
@@ -20,22 +21,29 @@ A CLI tool to export video recordings from TP-Link Vigi NVRs over a specified ti
## Installation
### Option 1: Download Windows Executable (Recommended for Windows)
### Option 1: Download Windows GUI (Recommended)
1. Go to [Releases](https://github.com/johannes/tplink-nvr-export/releases)
2. Download `nvr-export-windows.exe`
1. Go to [Releases](https://github.com/mrunknownde/tplink-nvr-export/releases)
2. Download `nvr-export-gui-windows.exe`
3. Double-click to run - no installation needed!
![GUI Preview - Connection, Time Range, Export Settings with Progress](docs/gui-preview.png)
### Option 2: Download Windows CLI
1. Go to [Releases](https://github.com/mrunknownde/tplink-nvr-export/releases)
2. Download `nvr-export-cli-windows.exe`
3. Run from Command Prompt or PowerShell
```powershell
# Example usage
.\nvr-export-windows.exe export -h 192.168.1.100 -u admin -c 1 -s "2024-12-28" -e "2024-12-29" -o ./exports
.\nvr-export-cli-windows.exe export -h 192.168.1.100 -u admin -c 1 -s "2024-12-28" -e "2024-12-29" -o ./exports
```
### Option 2: Install with pip (Requires Python)
### Option 3: Install with pip (Requires Python)
```bash
# Clone the repository
git clone https://github.com/johannes/tplink-nvr-export.git
git clone https://github.com/mrunknownde/tplink-nvr-export.git
cd tplink-nvr-export
# Install with pip
@@ -45,16 +53,19 @@ pip install -e .
pipx install .
```
### Option 3: Build Windows Executable Locally
### Option 4: Build Windows Executable Locally
```bash
# Install dependencies
pip install -e ".[dev]"
# Build single-file executable
# Build CLI executable
pyinstaller --onefile --name nvr-export --console src/tplink_nvr_export/cli.py
# Executable will be in dist/nvr-export.exe
# Build GUI executable
pyinstaller --onefile --name nvr-export-gui --windowed src/tplink_nvr_export/gui.py
# Executables will be in dist/
```
## NVR Setup

View File

@@ -43,10 +43,11 @@ dev = [
[project.scripts]
nvr-export = "tplink_nvr_export.cli:main"
nvr-export-gui = "tplink_nvr_export.gui:main"
[project.urls]
Homepage = "https://github.com/johannes/tplink-nvr-export"
Repository = "https://github.com/johannes/tplink-nvr-export"
Homepage = "https://github.com/mrunknownde/tplink-nvr-export"
Repository = "https://github.com/mrunknownde/tplink-nvr-export"
[tool.hatch.build.targets.wheel]
packages = ["src/tplink_nvr_export"]

View File

@@ -0,0 +1,404 @@
"""
TP-Link Vigi NVR Export - GUI Application
A simple tkinter-based GUI for exporting video recordings from TP-Link Vigi NVRs.
"""
import threading
import tkinter as tk
from datetime import datetime, timedelta
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Optional
from .auth import AuthenticationError
from .nvr_client import NVRAPIError, NVRClient
class NVRExportGUI:
"""Main GUI application for NVR video export."""
def __init__(self):
self.root = tk.Tk()
self.root.title("TP-Link Vigi NVR Export")
self.root.geometry("550x650")
self.root.resizable(True, True)
# Set minimum size
self.root.minsize(500, 600)
# Configure style
self.style = ttk.Style()
self.style.theme_use('clam')
# Variables
self.host_var = tk.StringVar(value="192.168.1.100")
self.port_var = tk.StringVar(value="20443")
self.user_var = tk.StringVar(value="admin")
self.password_var = tk.StringVar()
self.channel_var = tk.StringVar(value="1")
self.output_var = tk.StringVar(value=str(Path.home() / "Downloads" / "nvr-exports"))
self.type_var = tk.StringVar(value="all")
# Date/time variables - default to last 24 hours
now = datetime.now()
yesterday = now - timedelta(days=1)
self.start_date_var = tk.StringVar(value=yesterday.strftime("%Y-%m-%d"))
self.start_time_var = tk.StringVar(value="00:00")
self.end_date_var = tk.StringVar(value=now.strftime("%Y-%m-%d"))
self.end_time_var = tk.StringVar(value="23:59")
# State
self.client: Optional[NVRClient] = None
self.is_exporting = False
self._create_widgets()
self._configure_grid()
def _create_widgets(self):
"""Create all GUI widgets."""
# Main container with padding
main_frame = ttk.Frame(self.root, padding="15")
main_frame.grid(row=0, column=0, sticky="nsew")
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
row = 0
# Title
title_label = ttk.Label(
main_frame,
text="🎥 TP-Link Vigi NVR Export",
font=("Segoe UI", 16, "bold")
)
title_label.grid(row=row, column=0, columnspan=3, pady=(0, 15))
row += 1
# === Connection Section ===
conn_frame = ttk.LabelFrame(main_frame, text="🔗 NVR Connection", padding="10")
conn_frame.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(0, 10))
conn_frame.columnconfigure(1, weight=1)
row += 1
# Host
ttk.Label(conn_frame, text="Host:").grid(row=0, column=0, sticky="w", pady=2)
ttk.Entry(conn_frame, textvariable=self.host_var, width=30).grid(row=0, column=1, sticky="ew", padx=5, pady=2)
# Port
ttk.Label(conn_frame, text="Port:").grid(row=0, column=2, sticky="w", padx=(10, 0), pady=2)
ttk.Entry(conn_frame, textvariable=self.port_var, width=8).grid(row=0, column=3, sticky="w", pady=2)
# Username
ttk.Label(conn_frame, text="User:").grid(row=1, column=0, sticky="w", pady=2)
ttk.Entry(conn_frame, textvariable=self.user_var).grid(row=1, column=1, sticky="ew", padx=5, pady=2)
# Password
ttk.Label(conn_frame, text="Password:").grid(row=1, column=2, sticky="w", padx=(10, 0), pady=2)
ttk.Entry(conn_frame, textvariable=self.password_var, show="*").grid(row=1, column=3, sticky="ew", pady=2)
# Test connection button
self.test_btn = ttk.Button(conn_frame, text="🔌 Test Connection", command=self._test_connection)
self.test_btn.grid(row=2, column=0, columnspan=4, pady=(10, 0))
# === Time Range Section ===
time_frame = ttk.LabelFrame(main_frame, text="📅 Time Range", padding="10")
time_frame.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(0, 10))
row += 1
# Start date/time
ttk.Label(time_frame, text="Start:").grid(row=0, column=0, sticky="w", pady=2)
ttk.Entry(time_frame, textvariable=self.start_date_var, width=12).grid(row=0, column=1, sticky="w", padx=5, pady=2)
ttk.Label(time_frame, text="YYYY-MM-DD").grid(row=0, column=2, sticky="w")
ttk.Entry(time_frame, textvariable=self.start_time_var, width=8).grid(row=0, column=3, sticky="w", padx=5, pady=2)
ttk.Label(time_frame, text="HH:MM").grid(row=0, column=4, sticky="w")
# End date/time
ttk.Label(time_frame, text="End:").grid(row=1, column=0, sticky="w", pady=2)
ttk.Entry(time_frame, textvariable=self.end_date_var, width=12).grid(row=1, column=1, sticky="w", padx=5, pady=2)
ttk.Label(time_frame, text="YYYY-MM-DD").grid(row=1, column=2, sticky="w")
ttk.Entry(time_frame, textvariable=self.end_time_var, width=8).grid(row=1, column=3, sticky="w", padx=5, pady=2)
ttk.Label(time_frame, text="HH:MM").grid(row=1, column=4, sticky="w")
# Quick select buttons
quick_frame = ttk.Frame(time_frame)
quick_frame.grid(row=2, column=0, columnspan=5, pady=(10, 0))
ttk.Button(quick_frame, text="Last 24h", command=lambda: self._set_quick_range(1)).pack(side="left", padx=2)
ttk.Button(quick_frame, text="Last 7 Days", command=lambda: self._set_quick_range(7)).pack(side="left", padx=2)
ttk.Button(quick_frame, text="Last 30 Days", command=lambda: self._set_quick_range(30)).pack(side="left", padx=2)
# === Export Settings Section ===
export_frame = ttk.LabelFrame(main_frame, text="⚙️ Export Settings", padding="10")
export_frame.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(0, 10))
export_frame.columnconfigure(1, weight=1)
row += 1
# Channel
ttk.Label(export_frame, text="Channel:").grid(row=0, column=0, sticky="w", pady=2)
channel_spin = ttk.Spinbox(export_frame, from_=1, to=32, textvariable=self.channel_var, width=5)
channel_spin.grid(row=0, column=1, sticky="w", padx=5, pady=2)
# Recording type
ttk.Label(export_frame, text="Type:").grid(row=0, column=2, sticky="w", padx=(20, 0), pady=2)
type_combo = ttk.Combobox(
export_frame,
textvariable=self.type_var,
values=["all", "continuous", "motion", "alarm"],
state="readonly",
width=12
)
type_combo.grid(row=0, column=3, sticky="w", pady=2)
# Output directory
ttk.Label(export_frame, text="Output:").grid(row=1, column=0, sticky="w", pady=2)
ttk.Entry(export_frame, textvariable=self.output_var).grid(row=1, column=1, columnspan=2, sticky="ew", padx=5, pady=2)
ttk.Button(export_frame, text="📁 Browse", command=self._browse_output).grid(row=1, column=3, sticky="w", pady=2)
# === Progress Section ===
progress_frame = ttk.LabelFrame(main_frame, text="📊 Progress", padding="10")
progress_frame.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(0, 10))
progress_frame.columnconfigure(0, weight=1)
row += 1
self.progress_var = tk.DoubleVar(value=0)
self.progress_bar = ttk.Progressbar(
progress_frame,
variable=self.progress_var,
maximum=100,
mode='determinate'
)
self.progress_bar.grid(row=0, column=0, sticky="ew", pady=5)
self.status_var = tk.StringVar(value="Ready")
self.status_label = ttk.Label(progress_frame, textvariable=self.status_var)
self.status_label.grid(row=1, column=0, sticky="w")
# === Action Buttons ===
btn_frame = ttk.Frame(main_frame)
btn_frame.grid(row=row, column=0, columnspan=3, pady=15)
row += 1
self.search_btn = ttk.Button(btn_frame, text="🔍 Search Recordings", command=self._search_recordings)
self.search_btn.pack(side="left", padx=5)
self.export_btn = ttk.Button(btn_frame, text="📥 Export", command=self._start_export)
self.export_btn.pack(side="left", padx=5)
self.cancel_btn = ttk.Button(btn_frame, text="❌ Cancel", command=self._cancel_export, state="disabled")
self.cancel_btn.pack(side="left", padx=5)
# === Log Section ===
log_frame = ttk.LabelFrame(main_frame, text="📝 Log", padding="10")
log_frame.grid(row=row, column=0, columnspan=3, sticky="nsew", pady=(0, 10))
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
main_frame.rowconfigure(row, weight=1)
row += 1
self.log_text = tk.Text(log_frame, height=8, wrap="word", state="disabled")
self.log_text.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.log_text.configure(yscrollcommand=scrollbar.set)
self._log("Ready. Configure NVR connection and click 'Test Connection' to start.")
def _configure_grid(self):
"""Configure grid weights for resizing."""
pass # Already configured in _create_widgets
def _log(self, message: str):
"""Add message to log."""
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_text.configure(state="normal")
self.log_text.insert("end", f"[{timestamp}] {message}\n")
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _set_quick_range(self, days: int):
"""Set quick time range."""
now = datetime.now()
start = now - timedelta(days=days)
self.start_date_var.set(start.strftime("%Y-%m-%d"))
self.start_time_var.set("00:00")
self.end_date_var.set(now.strftime("%Y-%m-%d"))
self.end_time_var.set("23:59")
def _browse_output(self):
"""Open folder browser for output directory."""
folder = filedialog.askdirectory(initialdir=self.output_var.get())
if folder:
self.output_var.set(folder)
def _get_client(self) -> NVRClient:
"""Get or create NVR client."""
return NVRClient(
host=self.host_var.get(),
username=self.user_var.get(),
password=self.password_var.get(),
port=int(self.port_var.get()),
verify_ssl=False,
)
def _parse_datetime(self, date_str: str, time_str: str) -> datetime:
"""Parse date and time strings."""
return datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
def _test_connection(self):
"""Test connection to NVR."""
self.status_var.set("Testing connection...")
self._log(f"Connecting to {self.host_var.get()}:{self.port_var.get()}...")
def test():
try:
with self._get_client() as client:
channels = client.get_channels()
self.root.after(0, lambda: self._on_test_success(channels))
except (AuthenticationError, NVRAPIError) as e:
self.root.after(0, lambda: self._on_test_error(str(e)))
except Exception as e:
self.root.after(0, lambda: self._on_test_error(f"Unexpected error: {e}"))
threading.Thread(target=test, daemon=True).start()
def _on_test_success(self, channels):
"""Handle successful connection test."""
self.status_var.set("Connected!")
self._log(f"✅ Connection successful! Found {len(channels)} channels.")
for ch in channels:
self._log(f" Channel {ch.id}: {ch.name}")
def _on_test_error(self, error: str):
"""Handle connection test error."""
self.status_var.set("Connection failed")
self._log(f"❌ Connection failed: {error}")
messagebox.showerror("Connection Error", error)
def _search_recordings(self):
"""Search for recordings without downloading."""
self.status_var.set("Searching...")
self._log("Searching for recordings...")
def search():
try:
start = self._parse_datetime(self.start_date_var.get(), self.start_time_var.get())
end = self._parse_datetime(self.end_date_var.get(), self.end_time_var.get())
channel = int(self.channel_var.get())
with self._get_client() as client:
recordings = client.search_recordings(channel, start, end, self.type_var.get())
self.root.after(0, lambda: self._on_search_success(recordings))
except Exception as e:
self.root.after(0, lambda: self._on_search_error(str(e)))
threading.Thread(target=search, daemon=True).start()
def _on_search_success(self, recordings):
"""Handle successful search."""
if not recordings:
self.status_var.set("No recordings found")
self._log("⚠️ No recordings found for the specified time range.")
return
total_size = sum(r.size_bytes for r in recordings) / (1024 * 1024)
total_duration = sum(r.duration_seconds for r in recordings)
hours = total_duration // 3600
minutes = (total_duration % 3600) // 60
self.status_var.set(f"Found {len(recordings)} recordings")
self._log(f"✅ Found {len(recordings)} recordings ({total_size:.1f} MB, {hours}h {minutes}m)")
def _on_search_error(self, error: str):
"""Handle search error."""
self.status_var.set("Search failed")
self._log(f"❌ Search failed: {error}")
def _start_export(self):
"""Start export process."""
if self.is_exporting:
return
self.is_exporting = True
self.export_btn.configure(state="disabled")
self.cancel_btn.configure(state="normal")
self.progress_var.set(0)
self.status_var.set("Exporting...")
self._log("Starting export...")
def export():
try:
start = self._parse_datetime(self.start_date_var.get(), self.start_time_var.get())
end = self._parse_datetime(self.end_date_var.get(), self.end_time_var.get())
channel = int(self.channel_var.get())
output_dir = Path(self.output_var.get())
with self._get_client() as client:
recordings = client.search_recordings(channel, start, end, self.type_var.get())
if not recordings:
self.root.after(0, lambda: self._log("⚠️ No recordings found."))
return
self.root.after(0, lambda: self._log(f"Downloading {len(recordings)} recordings..."))
for i, rec in enumerate(recordings):
if not self.is_exporting:
break
progress = ((i + 1) / len(recordings)) * 100
self.root.after(0, lambda p=progress: self.progress_var.set(p))
self.root.after(0, lambda r=rec: self.status_var.set(f"Downloading: {r.start_time:%H:%M}"))
try:
output_file = client.download_recording(rec, output_dir)
self.root.after(0, lambda f=output_file: self._log(f"✅ Downloaded: {f.name}"))
except NVRAPIError as e:
self.root.after(0, lambda e=e: self._log(f"⚠️ Failed: {e}"))
self.root.after(0, self._on_export_complete)
except Exception as e:
self.root.after(0, lambda: self._on_export_error(str(e)))
threading.Thread(target=export, daemon=True).start()
def _on_export_complete(self):
"""Handle export completion."""
self.is_exporting = False
self.export_btn.configure(state="normal")
self.cancel_btn.configure(state="disabled")
self.progress_var.set(100)
self.status_var.set("Export complete!")
self._log("🎉 Export completed!")
messagebox.showinfo("Export Complete", f"Recordings exported to:\n{self.output_var.get()}")
def _on_export_error(self, error: str):
"""Handle export error."""
self.is_exporting = False
self.export_btn.configure(state="normal")
self.cancel_btn.configure(state="disabled")
self.status_var.set("Export failed")
self._log(f"❌ Export failed: {error}")
messagebox.showerror("Export Error", error)
def _cancel_export(self):
"""Cancel ongoing export."""
self.is_exporting = False
self.status_var.set("Cancelled")
self._log("⚠️ Export cancelled by user.")
def run(self):
"""Start the GUI application."""
self.root.mainloop()
def main():
"""Entry point for GUI application."""
app = NVRExportGUI()
app.run()
if __name__ == "__main__":
main()