pumaguard 20.post192__py3-none-any.whl → 20.post227__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.
- pumaguard/main.py +29 -15
- pumaguard/model_downloader.py +1 -1
- pumaguard/presets.py +17 -2
- pumaguard/pumaguard-ui/assets/NOTICES +297 -206
- pumaguard/pumaguard-ui/assets/fonts/MaterialIcons-Regular.otf +0 -0
- pumaguard/pumaguard-ui/flutter_bootstrap.js +1 -1
- pumaguard/pumaguard-ui/flutter_service_worker.js +6 -6
- pumaguard/pumaguard-ui/main.dart.js +54897 -52616
- pumaguard/pumaguard-ui/main.dart.mjs +182 -175
- pumaguard/pumaguard-ui/main.dart.wasm +0 -0
- pumaguard/server.py +4 -4
- pumaguard/web_routes/diagnostics.py +13 -2
- pumaguard/web_routes/directories.py +6 -0
- pumaguard/web_routes/folders.py +66 -15
- pumaguard/web_routes/photos.py +30 -8
- pumaguard/web_routes/settings.py +147 -0
- pumaguard/web_routes/sync.py +62 -13
- pumaguard/web_ui.py +13 -1
- {pumaguard-20.post192.dist-info → pumaguard-20.post227.dist-info}/METADATA +29 -2
- {pumaguard-20.post192.dist-info → pumaguard-20.post227.dist-info}/RECORD +24 -24
- {pumaguard-20.post192.dist-info → pumaguard-20.post227.dist-info}/WHEEL +0 -0
- {pumaguard-20.post192.dist-info → pumaguard-20.post227.dist-info}/entry_points.txt +0 -0
- {pumaguard-20.post192.dist-info → pumaguard-20.post227.dist-info}/licenses/LICENSE +0 -0
- {pumaguard-20.post192.dist-info → pumaguard-20.post227.dist-info}/top_level.txt +0 -0
pumaguard/web_routes/folders.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import (
|
|
|
9
9
|
import os
|
|
10
10
|
from typing import (
|
|
11
11
|
TYPE_CHECKING,
|
|
12
|
+
Any,
|
|
12
13
|
cast,
|
|
13
14
|
)
|
|
14
15
|
|
|
@@ -33,8 +34,13 @@ def register_folders_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
33
34
|
|
|
34
35
|
@app.route("/api/folders", methods=["GET"])
|
|
35
36
|
def get_folders():
|
|
37
|
+
"""Get all browsable folders (watched + classification outputs)."""
|
|
36
38
|
folders = []
|
|
37
|
-
|
|
39
|
+
# Combine both watched and classification directories
|
|
40
|
+
all_directories = (
|
|
41
|
+
webui.image_directories + webui.classification_directories
|
|
42
|
+
)
|
|
43
|
+
for directory in all_directories:
|
|
38
44
|
if not os.path.exists(directory):
|
|
39
45
|
continue
|
|
40
46
|
image_count = 0
|
|
@@ -55,12 +61,36 @@ def register_folders_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
55
61
|
|
|
56
62
|
@app.route("/api/folders/<path:folder_path>/images", methods=["GET"])
|
|
57
63
|
def get_folder_images(folder_path: str):
|
|
58
|
-
|
|
64
|
+
"""Get images from a folder (watched or classification output)."""
|
|
65
|
+
# Try to resolve folder_path relative to allowed directories
|
|
59
66
|
abs_folder = None
|
|
60
67
|
resolved_base = None
|
|
61
|
-
|
|
68
|
+
all_directories = (
|
|
69
|
+
webui.image_directories + webui.classification_directories
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Flask's <path:> converter strips leading slash, add it back
|
|
73
|
+
# if it looks like an absolute path was intended
|
|
74
|
+
if not folder_path.startswith("/") and not folder_path.startswith(
|
|
75
|
+
"\\"
|
|
76
|
+
):
|
|
77
|
+
folder_path = "/" + folder_path
|
|
78
|
+
|
|
79
|
+
# Normalize the requested path
|
|
80
|
+
normalized_folder_path = os.path.realpath(
|
|
81
|
+
os.path.normpath(folder_path)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
for directory in all_directories:
|
|
62
85
|
abs_directory = os.path.realpath(os.path.normpath(directory))
|
|
63
|
-
|
|
86
|
+
|
|
87
|
+
# Check if the requested path IS the directory itself
|
|
88
|
+
if normalized_folder_path == abs_directory:
|
|
89
|
+
abs_folder = abs_directory
|
|
90
|
+
resolved_base = abs_directory
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
# Check if requested path is a subdirectory
|
|
64
94
|
candidate_folder = os.path.realpath(
|
|
65
95
|
os.path.join(abs_directory, folder_path)
|
|
66
96
|
)
|
|
@@ -73,12 +103,30 @@ def register_folders_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
73
103
|
except ValueError:
|
|
74
104
|
# Different drives on Windows
|
|
75
105
|
continue
|
|
106
|
+
debug_paths = os.environ.get("PG_DEBUG_PATHS") in {"1", "true", "True"}
|
|
107
|
+
|
|
76
108
|
if abs_folder is None:
|
|
77
109
|
# Ensure file is within the allowed folder
|
|
78
|
-
|
|
110
|
+
error_response: dict[str, Any] = {"error": "Access denied"}
|
|
111
|
+
if debug_paths:
|
|
112
|
+
error_response["_requested"] = folder_path
|
|
113
|
+
error_response["_normalized"] = normalized_folder_path
|
|
114
|
+
error_response["_allowed_dirs"] = all_directories
|
|
115
|
+
return jsonify(error_response), 403
|
|
79
116
|
if not os.path.exists(abs_folder) or not os.path.isdir(abs_folder):
|
|
80
|
-
|
|
117
|
+
error_response = {"error": "Folder not found"}
|
|
118
|
+
if debug_paths:
|
|
119
|
+
error_response["_requested"] = folder_path
|
|
120
|
+
error_response["_resolved"] = abs_folder
|
|
121
|
+
error_response["_exists"] = os.path.exists(abs_folder)
|
|
122
|
+
error_response["_is_dir"] = (
|
|
123
|
+
os.path.isdir(abs_folder)
|
|
124
|
+
if os.path.exists(abs_folder)
|
|
125
|
+
else None
|
|
126
|
+
)
|
|
127
|
+
return jsonify(error_response), 404
|
|
81
128
|
images = []
|
|
129
|
+
debug_paths = os.environ.get("PG_DEBUG_PATHS") in {"1", "true", "True"}
|
|
82
130
|
for filename in os.listdir(abs_folder):
|
|
83
131
|
filepath = os.path.join(abs_folder, filename)
|
|
84
132
|
resolved_filepath = os.path.realpath(os.path.normpath(filepath))
|
|
@@ -94,15 +142,18 @@ def register_folders_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
94
142
|
rel_file_path = os.path.relpath(
|
|
95
143
|
resolved_filepath, resolved_base
|
|
96
144
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
item = {
|
|
146
|
+
"filename": filename,
|
|
147
|
+
"path": rel_file_path,
|
|
148
|
+
"size": stat.st_size,
|
|
149
|
+
"modified": stat.st_mtime,
|
|
150
|
+
"created": stat.st_ctime,
|
|
151
|
+
}
|
|
152
|
+
if debug_paths:
|
|
153
|
+
item["_abs"] = resolved_filepath
|
|
154
|
+
item["_base"] = resolved_base
|
|
155
|
+
item["_folder_abs"] = abs_folder
|
|
156
|
+
images.append(item)
|
|
106
157
|
|
|
107
158
|
images.sort(key=lambda x: cast(float, x["modified"]), reverse=True)
|
|
108
159
|
# Return only relative folder path to root and the root directory name
|
pumaguard/web_routes/photos.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import (
|
|
|
7
7
|
import os
|
|
8
8
|
from typing import (
|
|
9
9
|
TYPE_CHECKING,
|
|
10
|
+
Any,
|
|
10
11
|
)
|
|
11
12
|
|
|
12
13
|
from flask import (
|
|
@@ -31,8 +32,12 @@ def register_photos_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
31
32
|
|
|
32
33
|
@app.route("/api/photos", methods=["GET"])
|
|
33
34
|
def get_photos():
|
|
35
|
+
"""List all photos from watched and classification directories."""
|
|
34
36
|
photos: list[dict] = []
|
|
35
|
-
|
|
37
|
+
all_directories = (
|
|
38
|
+
webui.image_directories + webui.classification_directories
|
|
39
|
+
)
|
|
40
|
+
for directory in all_directories:
|
|
36
41
|
if not os.path.exists(directory):
|
|
37
42
|
continue
|
|
38
43
|
for filename in os.listdir(directory):
|
|
@@ -58,27 +63,41 @@ def register_photos_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
58
63
|
def get_photo(filepath: str):
|
|
59
64
|
# Safely resolve user provided path against allowed directories
|
|
60
65
|
abs_filepath = None
|
|
61
|
-
|
|
66
|
+
all_directories = (
|
|
67
|
+
webui.image_directories + webui.classification_directories
|
|
68
|
+
)
|
|
69
|
+
for directory in all_directories:
|
|
62
70
|
abs_directory = os.path.realpath(directory)
|
|
63
71
|
joined_path = os.path.join(abs_directory, filepath)
|
|
64
72
|
candidate = os.path.realpath(joined_path)
|
|
65
73
|
try:
|
|
66
74
|
common = os.path.commonpath([candidate, abs_directory])
|
|
67
|
-
if common == abs_directory:
|
|
75
|
+
if common == abs_directory and os.path.exists(candidate):
|
|
68
76
|
abs_filepath = candidate
|
|
69
77
|
break
|
|
70
78
|
except ValueError:
|
|
71
79
|
# Different drives on Windows
|
|
72
80
|
continue
|
|
81
|
+
debug_paths = os.environ.get("PG_DEBUG_PATHS") in {"1", "true", "True"}
|
|
73
82
|
if abs_filepath is None:
|
|
74
|
-
|
|
83
|
+
payload: dict[str, Any] = {"error": "Access denied"}
|
|
84
|
+
if debug_paths:
|
|
85
|
+
payload["_tried_bases"] = all_directories
|
|
86
|
+
payload["_requested"] = filepath
|
|
87
|
+
return jsonify(payload), 403
|
|
75
88
|
if not os.path.exists(abs_filepath) or not os.path.isfile(
|
|
76
89
|
abs_filepath
|
|
77
90
|
):
|
|
78
|
-
|
|
91
|
+
payload = {"error": "File not found"}
|
|
92
|
+
if debug_paths:
|
|
93
|
+
payload["_resolved"] = abs_filepath
|
|
94
|
+
return jsonify(payload), 404
|
|
79
95
|
ext = os.path.splitext(abs_filepath)[1].lower()
|
|
80
96
|
if ext not in IMAGE_EXTS:
|
|
81
|
-
|
|
97
|
+
payload = {"error": "Access denied"}
|
|
98
|
+
if debug_paths:
|
|
99
|
+
payload["_ext"] = ext
|
|
100
|
+
return jsonify(payload), 403
|
|
82
101
|
directory = os.path.dirname(abs_filepath)
|
|
83
102
|
filename = os.path.basename(abs_filepath)
|
|
84
103
|
return send_from_directory(directory, filename)
|
|
@@ -87,13 +106,16 @@ def register_photos_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
87
106
|
def delete_photo(filepath: str):
|
|
88
107
|
# Safely resolve user provided path against allowed directories
|
|
89
108
|
abs_filepath = None
|
|
90
|
-
|
|
109
|
+
all_directories = (
|
|
110
|
+
webui.image_directories + webui.classification_directories
|
|
111
|
+
)
|
|
112
|
+
for directory in all_directories:
|
|
91
113
|
abs_directory = os.path.realpath(directory)
|
|
92
114
|
joined_path = os.path.join(abs_directory, filepath)
|
|
93
115
|
candidate = os.path.realpath(joined_path)
|
|
94
116
|
try:
|
|
95
117
|
common = os.path.commonpath([candidate, abs_directory])
|
|
96
|
-
if common == abs_directory:
|
|
118
|
+
if common == abs_directory and os.path.exists(candidate):
|
|
97
119
|
abs_filepath = candidate
|
|
98
120
|
break
|
|
99
121
|
except ValueError:
|
pumaguard/web_routes/settings.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import (
|
|
|
5
5
|
)
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
import os
|
|
8
9
|
from typing import (
|
|
9
10
|
TYPE_CHECKING,
|
|
10
11
|
)
|
|
@@ -18,6 +19,16 @@ from yaml.representer import (
|
|
|
18
19
|
YAMLError,
|
|
19
20
|
)
|
|
20
21
|
|
|
22
|
+
from pumaguard.model_downloader import (
|
|
23
|
+
MODEL_REGISTRY,
|
|
24
|
+
get_models_directory,
|
|
25
|
+
list_available_models,
|
|
26
|
+
verify_file_checksum,
|
|
27
|
+
)
|
|
28
|
+
from pumaguard.sound import (
|
|
29
|
+
playsound,
|
|
30
|
+
)
|
|
31
|
+
|
|
21
32
|
if TYPE_CHECKING:
|
|
22
33
|
from flask import (
|
|
23
34
|
Flask,
|
|
@@ -114,3 +125,139 @@ def register_settings_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
114
125
|
webui.presets.load(filepath)
|
|
115
126
|
logger.info("Settings loaded from %s", filepath)
|
|
116
127
|
return jsonify({"success": True, "message": "Settings loaded"})
|
|
128
|
+
|
|
129
|
+
@app.route("/api/settings/test-sound", methods=["POST"])
|
|
130
|
+
def test_sound():
|
|
131
|
+
"""Test the configured deterrent sound."""
|
|
132
|
+
try:
|
|
133
|
+
sound_file = webui.presets.deterrent_sound_file
|
|
134
|
+
if not sound_file:
|
|
135
|
+
return (
|
|
136
|
+
jsonify({"error": "No sound file configured"}),
|
|
137
|
+
400,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Combine sound_path with deterrent_sound_file
|
|
141
|
+
sound_file_path = os.path.join(
|
|
142
|
+
webui.presets.sound_path, sound_file
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Check if file exists
|
|
146
|
+
if not os.path.exists(sound_file_path):
|
|
147
|
+
return (
|
|
148
|
+
jsonify(
|
|
149
|
+
{"error": (f"Sound file not found: {sound_file_path}")}
|
|
150
|
+
),
|
|
151
|
+
404,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Play the sound
|
|
155
|
+
logger.info("Testing sound playback: %s", sound_file_path)
|
|
156
|
+
playsound(sound_file_path)
|
|
157
|
+
return jsonify(
|
|
158
|
+
{
|
|
159
|
+
"success": True,
|
|
160
|
+
"message": f"Sound played: {sound_file}",
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
except Exception as e: # pylint: disable=broad-except
|
|
164
|
+
logger.exception("Error testing sound")
|
|
165
|
+
return jsonify({"error": str(e)}), 500
|
|
166
|
+
|
|
167
|
+
@app.route("/api/models/available", methods=["GET"])
|
|
168
|
+
def get_available_models():
|
|
169
|
+
"""Get list of available models with cache status.
|
|
170
|
+
|
|
171
|
+
Query parameters:
|
|
172
|
+
type: 'classifier' (*.h5 files) or 'yolo' (*.pt files)
|
|
173
|
+
Default: 'classifier'
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
model_type = request.args.get("type", "classifier")
|
|
177
|
+
models_dir = get_models_directory()
|
|
178
|
+
available_models = list_available_models()
|
|
179
|
+
|
|
180
|
+
# Filter based on model type
|
|
181
|
+
if model_type == "yolo":
|
|
182
|
+
filtered_models = [
|
|
183
|
+
model
|
|
184
|
+
for model in available_models
|
|
185
|
+
if model.endswith(".pt")
|
|
186
|
+
]
|
|
187
|
+
else: # Default to classifier
|
|
188
|
+
filtered_models = [
|
|
189
|
+
model
|
|
190
|
+
for model in available_models
|
|
191
|
+
if model.endswith(".h5")
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
model_list = []
|
|
195
|
+
for model_name in filtered_models:
|
|
196
|
+
model_path = models_dir / model_name
|
|
197
|
+
is_cached = False
|
|
198
|
+
|
|
199
|
+
# Check if model exists and verify checksum
|
|
200
|
+
if model_path.exists():
|
|
201
|
+
model_info = MODEL_REGISTRY[model_name]
|
|
202
|
+
sha256 = model_info.get("sha256")
|
|
203
|
+
if isinstance(sha256, str):
|
|
204
|
+
is_cached = verify_file_checksum(model_path, sha256)
|
|
205
|
+
|
|
206
|
+
# Get model size info
|
|
207
|
+
size_mb = None
|
|
208
|
+
if model_path.exists():
|
|
209
|
+
size_mb = model_path.stat().st_size / (1024 * 1024)
|
|
210
|
+
|
|
211
|
+
model_list.append(
|
|
212
|
+
{
|
|
213
|
+
"name": model_name,
|
|
214
|
+
"cached": is_cached,
|
|
215
|
+
"size_mb": size_mb,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Sort models: cached first, then by name
|
|
220
|
+
model_list.sort(key=lambda x: (not x["cached"], x["name"]))
|
|
221
|
+
|
|
222
|
+
return jsonify({"models": model_list})
|
|
223
|
+
|
|
224
|
+
except Exception as e: # pylint: disable=broad-except
|
|
225
|
+
logger.exception("Error getting available models")
|
|
226
|
+
return jsonify({"error": str(e)}), 500
|
|
227
|
+
|
|
228
|
+
@app.route("/api/sounds/available", methods=["GET"])
|
|
229
|
+
def get_available_sounds():
|
|
230
|
+
"""Get list of available sound files with file sizes."""
|
|
231
|
+
try:
|
|
232
|
+
sound_path = webui.presets.sound_path
|
|
233
|
+
if not os.path.exists(sound_path):
|
|
234
|
+
return (
|
|
235
|
+
jsonify({"error": f"Sound path not found: {sound_path}"}),
|
|
236
|
+
404,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Supported audio formats
|
|
240
|
+
audio_extensions = {".mp3", ".wav", ".ogg", ".flac", ".m4a"}
|
|
241
|
+
|
|
242
|
+
sound_files = []
|
|
243
|
+
for filename in os.listdir(sound_path):
|
|
244
|
+
filepath = os.path.join(sound_path, filename)
|
|
245
|
+
if os.path.isfile(filepath):
|
|
246
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
247
|
+
if ext in audio_extensions:
|
|
248
|
+
size_mb = os.path.getsize(filepath) / (1024 * 1024)
|
|
249
|
+
sound_files.append(
|
|
250
|
+
{
|
|
251
|
+
"name": filename,
|
|
252
|
+
"size_mb": size_mb,
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Sort by name
|
|
257
|
+
sound_files.sort(key=lambda x: x["name"])
|
|
258
|
+
|
|
259
|
+
return jsonify({"sounds": sound_files})
|
|
260
|
+
|
|
261
|
+
except Exception as e: # pylint: disable=broad-except
|
|
262
|
+
logger.exception("Error getting available sounds")
|
|
263
|
+
return jsonify({"error": str(e)}), 500
|
pumaguard/web_routes/sync.py
CHANGED
|
@@ -49,10 +49,36 @@ def register_sync_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
49
49
|
client_files = data["files"]
|
|
50
50
|
files_to_download = []
|
|
51
51
|
for filepath, client_checksum in client_files.items():
|
|
52
|
-
#
|
|
53
|
-
abs_filepath =
|
|
52
|
+
# Try to resolve filepath - could be absolute or relative
|
|
53
|
+
abs_filepath = None
|
|
54
|
+
|
|
55
|
+
# First try as absolute path
|
|
56
|
+
candidate = os.path.realpath(os.path.normpath(filepath))
|
|
57
|
+
if os.path.isabs(filepath) and os.path.isfile(candidate):
|
|
58
|
+
abs_filepath = candidate
|
|
59
|
+
else:
|
|
60
|
+
# Try as relative path against each allowed directory
|
|
61
|
+
for directory in (
|
|
62
|
+
webui.image_directories + webui.classification_directories
|
|
63
|
+
):
|
|
64
|
+
abs_directory = os.path.realpath(
|
|
65
|
+
os.path.normpath(directory)
|
|
66
|
+
)
|
|
67
|
+
candidate = os.path.realpath(
|
|
68
|
+
os.path.normpath(os.path.join(abs_directory, filepath))
|
|
69
|
+
)
|
|
70
|
+
if os.path.isfile(candidate):
|
|
71
|
+
abs_filepath = candidate
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
if abs_filepath is None:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
# Validate the resolved path is within allowed directories
|
|
54
78
|
allowed = False
|
|
55
|
-
for directory in
|
|
79
|
+
for directory in (
|
|
80
|
+
webui.image_directories + webui.classification_directories
|
|
81
|
+
):
|
|
56
82
|
abs_directory = os.path.realpath(os.path.normpath(directory))
|
|
57
83
|
try:
|
|
58
84
|
common = os.path.commonpath([abs_filepath, abs_directory])
|
|
@@ -62,10 +88,9 @@ def register_sync_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
62
88
|
except ValueError:
|
|
63
89
|
# Different drives on Windows
|
|
64
90
|
continue
|
|
91
|
+
|
|
65
92
|
if not allowed:
|
|
66
93
|
continue
|
|
67
|
-
if not os.path.exists(abs_filepath):
|
|
68
|
-
continue
|
|
69
94
|
server_checksum = _calculate_file_checksum(abs_filepath)
|
|
70
95
|
if server_checksum != client_checksum:
|
|
71
96
|
stat = os.stat(abs_filepath)
|
|
@@ -92,23 +117,47 @@ def register_sync_routes(app: "Flask", webui: "WebUI") -> None:
|
|
|
92
117
|
file_paths = data["files"]
|
|
93
118
|
validated_files = []
|
|
94
119
|
for filepath in file_paths:
|
|
95
|
-
#
|
|
96
|
-
abs_filepath =
|
|
120
|
+
# Try to resolve filepath - could be absolute or relative
|
|
121
|
+
abs_filepath = None
|
|
122
|
+
|
|
123
|
+
# First try as absolute path
|
|
124
|
+
candidate = os.path.realpath(os.path.normpath(filepath))
|
|
125
|
+
if os.path.isabs(filepath) and os.path.isfile(candidate):
|
|
126
|
+
abs_filepath = candidate
|
|
127
|
+
else:
|
|
128
|
+
# Try as relative path against each allowed directory
|
|
129
|
+
for directory in (
|
|
130
|
+
webui.image_directories + webui.classification_directories
|
|
131
|
+
):
|
|
132
|
+
abs_directory = os.path.realpath(
|
|
133
|
+
os.path.normpath(directory)
|
|
134
|
+
)
|
|
135
|
+
candidate = os.path.realpath(
|
|
136
|
+
os.path.normpath(os.path.join(abs_directory, filepath))
|
|
137
|
+
)
|
|
138
|
+
if os.path.isfile(candidate):
|
|
139
|
+
abs_filepath = candidate
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
if abs_filepath is None:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Validate the resolved path is within allowed directories
|
|
97
146
|
allowed = False
|
|
98
|
-
for directory in
|
|
99
|
-
|
|
147
|
+
for directory in (
|
|
148
|
+
webui.image_directories + webui.classification_directories
|
|
149
|
+
):
|
|
100
150
|
abs_directory = os.path.realpath(os.path.normpath(directory))
|
|
101
151
|
try:
|
|
102
152
|
common = os.path.commonpath([abs_filepath, abs_directory])
|
|
103
|
-
if common == abs_directory
|
|
104
|
-
abs_filepath
|
|
105
|
-
):
|
|
153
|
+
if common == abs_directory:
|
|
106
154
|
allowed = True
|
|
107
155
|
break
|
|
108
156
|
except ValueError:
|
|
109
157
|
# Different drives on Windows
|
|
110
158
|
continue
|
|
111
|
-
|
|
159
|
+
|
|
160
|
+
if allowed:
|
|
112
161
|
validated_files.append(abs_filepath)
|
|
113
162
|
if not validated_files:
|
|
114
163
|
return jsonify({"error": "No valid files to download"}), 400
|
pumaguard/web_ui.py
CHANGED
|
@@ -142,6 +142,7 @@ class WebUI:
|
|
|
142
142
|
self._running: bool = False
|
|
143
143
|
self.presets: Preset = presets
|
|
144
144
|
self.image_directories: list[str] = []
|
|
145
|
+
self.classification_directories: list[str] = []
|
|
145
146
|
|
|
146
147
|
# mDNS/Zeroconf support
|
|
147
148
|
self.zeroconf: Zeroconf | None = None
|
|
@@ -266,7 +267,7 @@ class WebUI:
|
|
|
266
267
|
|
|
267
268
|
def add_image_directory(self, directory: str):
|
|
268
269
|
"""
|
|
269
|
-
Add a directory to scan for images.
|
|
270
|
+
Add a directory to scan for images (watched folder).
|
|
270
271
|
|
|
271
272
|
Args:
|
|
272
273
|
directory: Path to the directory containing captured images
|
|
@@ -275,6 +276,17 @@ class WebUI:
|
|
|
275
276
|
self.image_directories.append(directory)
|
|
276
277
|
logger.info("Added image directory: %s", directory)
|
|
277
278
|
|
|
279
|
+
def add_classification_directory(self, directory: str):
|
|
280
|
+
"""
|
|
281
|
+
Add a classification output directory (not watched, browse-only).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
directory: Path to classification output directory
|
|
285
|
+
"""
|
|
286
|
+
if directory not in self.classification_directories:
|
|
287
|
+
self.classification_directories.append(directory)
|
|
288
|
+
logger.info("Added classification directory: %s", directory)
|
|
289
|
+
|
|
278
290
|
def _get_local_ip(self) -> str:
|
|
279
291
|
"""
|
|
280
292
|
Get the local IP address of this machine.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pumaguard
|
|
3
|
-
Version: 20.
|
|
3
|
+
Version: 20.post227
|
|
4
4
|
Author-email: Nicolas Bock <nicolasbock@gmail.com>
|
|
5
5
|
Project-URL: Homepage, http://pumaguard.rtfd.io/
|
|
6
6
|
Project-URL: Repository, https://github.com/PEEC-Nature-Youth-Group/pumaguard
|
|
@@ -202,7 +202,9 @@ For detailed mDNS setup instructions including Docker/container configurations,
|
|
|
202
202
|
## Running the server
|
|
203
203
|
|
|
204
204
|
The `pumaguard-server` watches a folder and classifies new files as they are
|
|
205
|
-
added to that folder.
|
|
205
|
+
added to that folder.
|
|
206
|
+
|
|
207
|
+
### Basic Usage
|
|
206
208
|
|
|
207
209
|
**Using uv:**
|
|
208
210
|
|
|
@@ -218,6 +220,31 @@ poetry run pumaguard-server FOLDER
|
|
|
218
220
|
|
|
219
221
|
Where `FOLDER` is the folder to watch.
|
|
220
222
|
|
|
223
|
+
### Common Command-Line Options
|
|
224
|
+
|
|
225
|
+
All PumaGuard commands support these global options:
|
|
226
|
+
|
|
227
|
+
- `--log-file PATH` - Specify a custom log file location (default: `~/.cache/pumaguard/pumaguard.log`)
|
|
228
|
+
- `--settings PATH` - Load settings from a specific YAML file (default: `~/.config/pumaguard/settings.yaml`)
|
|
229
|
+
- `--debug` - Enable debug logging
|
|
230
|
+
- `--model-path PATH` - Specify where models are stored
|
|
231
|
+
- `--version` - Show version information
|
|
232
|
+
|
|
233
|
+
**Examples:**
|
|
234
|
+
|
|
235
|
+
```console
|
|
236
|
+
# Use custom log file location
|
|
237
|
+
uv run pumaguard --log-file /var/log/pumaguard.log server FOLDER
|
|
238
|
+
|
|
239
|
+
# Combine custom settings and log file
|
|
240
|
+
uv run pumaguard --settings my-config.yaml --log-file /tmp/debug.log server FOLDER
|
|
241
|
+
|
|
242
|
+
# Enable debug logging
|
|
243
|
+
uv run pumaguard --debug server FOLDER
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
For more details on configuration and XDG directory support, see [docs/XDG_MIGRATION.md](docs/XDG_MIGRATION.md).
|
|
247
|
+
|
|
221
248
|

|
|
222
249
|
|
|
223
250
|
## Training new models
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
pumaguard/__init__.py,sha256=QUsYaH1hG9RKygFZfaS5tpe9pgN_1Kf7EMroQCVlQfE,421
|
|
2
2
|
pumaguard/classify.py,sha256=QnHfnIlkzSxHGy0dgqT6IeTbRBboRT3c3B_YC3YpqfI,1057
|
|
3
3
|
pumaguard/lock_manager.py,sha256=sYLl8Z7miOr_9R7ehoSFlKf8tToHNNUT6aH9Mlw2M9g,1608
|
|
4
|
-
pumaguard/main.py,sha256=
|
|
4
|
+
pumaguard/main.py,sha256=1Wazv1wjwb46yNlqgWt88HQwKSxGmY24X5OsUv8gYyE,7029
|
|
5
5
|
pumaguard/model-registry.yaml,sha256=V-pTaqJrk_jJo568okBDaVhs51qTHttSqKe6PqdX6Bc,10318
|
|
6
6
|
pumaguard/model_cli.py,sha256=nzDv0lXSvRKpLxs579tiHInJPPV-AFO4jzeLk5t2GaA,1394
|
|
7
|
-
pumaguard/model_downloader.py,sha256=
|
|
8
|
-
pumaguard/presets.py,sha256=
|
|
9
|
-
pumaguard/server.py,sha256=
|
|
7
|
+
pumaguard/model_downloader.py,sha256=zJQgCMOF2AfhB30VsfOMYtgRxcxVxkZBAdtG8KznPyY,12895
|
|
8
|
+
pumaguard/presets.py,sha256=Nof9G1iCnetxGtbUavHG1YxtBpKqT5qHE_vawaw1Kjk,26913
|
|
9
|
+
pumaguard/server.py,sha256=gpn1Lco61OO0IPYzG5eWQ4BaxfIpMFN-AwpBip5csSQ,14684
|
|
10
10
|
pumaguard/sound.py,sha256=dBylHyuS8ro9tFJH5y3s6Bn2kSGnfrOyGeGYZlgYTsU,369
|
|
11
11
|
pumaguard/stats.py,sha256=ZwocfnFCQ-ky7me-YTTrEoJqsIHOWAgSzeoJHItsIU4,927
|
|
12
12
|
pumaguard/utils.py,sha256=w1EgOLSZGyjq_b49hvVZhBESy-lVP0yRtNHe-sXBoIU,19735
|
|
13
13
|
pumaguard/verify.py,sha256=vfw3PRzDt1uuH5FKV9F5vb1PH7KQ6AEgVNhJ6jck_hQ,5513
|
|
14
|
-
pumaguard/web_ui.py,sha256=
|
|
14
|
+
pumaguard/web_ui.py,sha256=2TuJs3Wcn9HHr9FQBl2HHtS9Ki-qg0ufXc35Up3TQiY,15665
|
|
15
15
|
pumaguard/completions/pumaguard-classify-completions.sh,sha256=5QySg-2Jdinj15qpUYa5UzHbTgYzi2gmPVYYyyXny4c,1353
|
|
16
16
|
pumaguard/completions/pumaguard-completions.sh,sha256=bRx3Q3_gM__3w0PyfQSCVdxylhhr3QlzaLCav24dfNc,1196
|
|
17
17
|
pumaguard/completions/pumaguard-server-completions.sh,sha256=33c6GjbTImBOHn0SSNUOJoxqJ2mMHuDv3P3GQJGGHhA,1161
|
|
@@ -19,19 +19,19 @@ pumaguard/completions/pumaguard-train-completions.sh,sha256=lI8LG-QrncvhUqCeKtfr
|
|
|
19
19
|
pumaguard/pumaguard-ui/.last_build_id,sha256=2z9bkdHZlfzXSpHBHs18Lo4GUD8oTSkZDZxqAeZW6KE,32
|
|
20
20
|
pumaguard/pumaguard-ui/favicon.png,sha256=erJSX0uGtl0-THA1ihfloar29Df5nLzARtrXPVm7kBU,917
|
|
21
21
|
pumaguard/pumaguard-ui/flutter.js,sha256=7V1ZIKmGiouT15CpquQWWmKWJyjUq77FoU9gDXPFO9M,9412
|
|
22
|
-
pumaguard/pumaguard-ui/flutter_bootstrap.js,sha256=
|
|
23
|
-
pumaguard/pumaguard-ui/flutter_service_worker.js,sha256=
|
|
22
|
+
pumaguard/pumaguard-ui/flutter_bootstrap.js,sha256=Jw8H_S9CdbFRoQDDbeRDE3uv49xQUK5gNWw8lWLD9Fs,9858
|
|
23
|
+
pumaguard/pumaguard-ui/flutter_service_worker.js,sha256=2e__UQGLkyO5aqlajzwIva-QqbaknYp73wJdlege2Zw,8435
|
|
24
24
|
pumaguard/pumaguard-ui/index.html,sha256=901-ZY0WysVAZWPwj2xGatoezwm9TX9IV_jpMrlsaXg,1205
|
|
25
|
-
pumaguard/pumaguard-ui/main.dart.js,sha256=
|
|
26
|
-
pumaguard/pumaguard-ui/main.dart.mjs,sha256=
|
|
27
|
-
pumaguard/pumaguard-ui/main.dart.wasm,sha256=
|
|
25
|
+
pumaguard/pumaguard-ui/main.dart.js,sha256=syrslNJdpGl3SqIysSuvhtWLYbFbr8a_advX8RpHTxk,2673614
|
|
26
|
+
pumaguard/pumaguard-ui/main.dart.mjs,sha256=DGelAD_MAL2eTgxpZkJvHcW0MeMq2LEE7sC7kekK3JU,34043
|
|
27
|
+
pumaguard/pumaguard-ui/main.dart.wasm,sha256=uatV1MK3TGtgllvGaTUIhOdx96XraXOCAcoDE9xYb6o,2360177
|
|
28
28
|
pumaguard/pumaguard-ui/manifest.json,sha256=Hhnw_eLUivdrOlL7O9KGBsGXCKKt3lix17Fh3GB0g-s,920
|
|
29
29
|
pumaguard/pumaguard-ui/version.json,sha256=uXZ6musTJUZaO0N2bEbr3cy9rpx2aesAS2YFMcu2WF8,94
|
|
30
30
|
pumaguard/pumaguard-ui/assets/AssetManifest.bin,sha256=AK9VrT1vIYmP534P8JLRoc2lLJQbaGDpko1FyK-MCV0,117
|
|
31
31
|
pumaguard/pumaguard-ui/assets/AssetManifest.bin.json,sha256=hGC19Cmcoe1nfFpUyAOK6c8fJvbpGWaxt2fKbLbS5dI,158
|
|
32
32
|
pumaguard/pumaguard-ui/assets/FontManifest.json,sha256=zX4DZFvESy3Ue3y2JvUcTsv1Whl6t3JBYotHrBZfviE,208
|
|
33
|
-
pumaguard/pumaguard-ui/assets/NOTICES,sha256=
|
|
34
|
-
pumaguard/pumaguard-ui/assets/fonts/MaterialIcons-Regular.otf,sha256=
|
|
33
|
+
pumaguard/pumaguard-ui/assets/NOTICES,sha256=36KDhv5JjUTHLHSv3M-zMkJyjezJjCQB-20DGbpx7JM,1372380
|
|
34
|
+
pumaguard/pumaguard-ui/assets/fonts/MaterialIcons-Regular.otf,sha256=R8-aO9K-VgimZR7FNPwS9yffsSwG_MlvUwqMEcH0Syg,10768
|
|
35
35
|
pumaguard/pumaguard-ui/assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,sha256=PZDDcKpM8A3FfuK5AvZlIUfA-B4D5IPfWEqcCLFofJ0,1472
|
|
36
36
|
pumaguard/pumaguard-ui/assets/shaders/ink_sparkle.frag,sha256=4Bths5TPZqZBvqh-O0_jeH03L2Nsi8iipnQ4zcKdpvY,8867
|
|
37
37
|
pumaguard/pumaguard-ui/assets/shaders/stretch_effect.frag,sha256=-pzPjkKWMaRhx6fGRNq6gRpTRj94qirQI-DpyBuBW0o,6714
|
|
@@ -53,13 +53,13 @@ pumaguard/pumaguard-ui/icons/Icon-maskable-192.png,sha256=0shC4iqfTsnZlrIzc6kFyI
|
|
|
53
53
|
pumaguard/pumaguard-ui/icons/Icon-maskable-512.png,sha256=au4Gzcq2sq73Sxc0xHePRCHS2hALD_nlKyG1UkAgKSk,20998
|
|
54
54
|
pumaguard/web_routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
55
55
|
pumaguard/web_routes/artifacts.py,sha256=IpnMLdbgAYkwU3TuYJE-JHGnC_x5_XNCrc-1M_n2YKk,3879
|
|
56
|
-
pumaguard/web_routes/diagnostics.py,sha256=
|
|
57
|
-
pumaguard/web_routes/directories.py,sha256=
|
|
58
|
-
pumaguard/web_routes/folders.py,sha256=
|
|
59
|
-
pumaguard/web_routes/photos.py,sha256=
|
|
60
|
-
pumaguard/web_routes/settings.py,sha256=
|
|
61
|
-
pumaguard/web_routes/sync.py,sha256=
|
|
62
|
-
pumaguard-20.
|
|
56
|
+
pumaguard/web_routes/diagnostics.py,sha256=EIIbjuixJyGXdnVQf8RQ6xQxJar0UHZO8dF-9zQLY9g,3294
|
|
57
|
+
pumaguard/web_routes/directories.py,sha256=yy5TghCEyB4reRGAcVHIEfr2vlHnuiDChIXl9ZFquRM,2410
|
|
58
|
+
pumaguard/web_routes/folders.py,sha256=Z63ap6dRi6NWye70HYurpCnsSXmFgzTbTsFKYdZ1Bjk,6305
|
|
59
|
+
pumaguard/web_routes/photos.py,sha256=Tac_CbaZSeZzOfaJ73vlp3iyZbvfD7ei1YM3tsb0nTY,5106
|
|
60
|
+
pumaguard/web_routes/settings.py,sha256=kVkKdaBLitKCGJiePlv34H_-E0yu_HUC3Wnxxynw5QU,8982
|
|
61
|
+
pumaguard/web_routes/sync.py,sha256=Zvv6VARGE5xP29C5gWH3ul81PISRxoF8n472DITItE0,6378
|
|
62
|
+
pumaguard-20.post227.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
63
63
|
pumaguard-sounds/cougar_call.mp3,sha256=jdPzi7Qneect3ez2G6XAeHWtetU5vSOSB6pceuB26Wc,129048
|
|
64
64
|
pumaguard-sounds/cougarsounds.wav,sha256=hwVmmQ75dkOP3qd07YAvVOSm1neYtxLSzxw3Ulvs2cM,96346
|
|
65
65
|
pumaguard-sounds/dark-engine-logo-141942.mp3,sha256=Vw-qyLTMPJZvsgQcZtH0DpGcP1dd7nJq-9BnHuNPGug,372819
|
|
@@ -77,8 +77,8 @@ pumaguard-sounds/mixkit-vintage-telephone-ringtone-1356.wav,sha256=zWWY2uFF0-l7P
|
|
|
77
77
|
pumaguard-sounds/pumaguard-warning.mp3,sha256=wcCfHsulPo5P5s8MjpQAG2NYHQDsRpjqoMig1-o_MDI,232249
|
|
78
78
|
pumaguard-sounds/short-round-110940.mp3,sha256=vdskGD94SeH1UJyJyR0Ek_7xGXPIZfnPdoBvxGnUt98,450816
|
|
79
79
|
pumaguard-ui/ios/Flutter/ephemeral/flutter_lldb_helper.py,sha256=Bc_jl3_e5ZPvrSBJpPYtN05VxpztyKq-7lVms3rLg4Q,1276
|
|
80
|
-
pumaguard-20.
|
|
81
|
-
pumaguard-20.
|
|
82
|
-
pumaguard-20.
|
|
83
|
-
pumaguard-20.
|
|
84
|
-
pumaguard-20.
|
|
80
|
+
pumaguard-20.post227.dist-info/METADATA,sha256=s9C0SQ4WPbGdgFC3JD-dM4kSC5wNtBXNusRJL4MhW4Q,8618
|
|
81
|
+
pumaguard-20.post227.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
82
|
+
pumaguard-20.post227.dist-info/entry_points.txt,sha256=rmCdBTPWrbJQvPPwABSVobXE9D7hrKsITGZ6nvCrko8,127
|
|
83
|
+
pumaguard-20.post227.dist-info/top_level.txt,sha256=B-PzS4agkQNhOYbLLIrMVOyMD_pl5F-yujPBm5zYYjY,40
|
|
84
|
+
pumaguard-20.post227.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|