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.
@@ -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
- for directory in webui.image_directories:
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
- # Try to resolve folder_path relative to each allowed image directory
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
- for directory in webui.image_directories:
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
- # Always join user input to base, then normalize
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
- return jsonify({"error": "Access denied"}), 403
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
- return jsonify({"error": "Folder not found"}), 404
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
- images.append(
98
- {
99
- "filename": filename,
100
- "path": rel_file_path,
101
- "size": stat.st_size,
102
- "modified": stat.st_mtime,
103
- "created": stat.st_ctime,
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
@@ -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
- for directory in webui.image_directories:
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
- for directory in webui.image_directories:
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
- return jsonify({"error": "Access denied"}), 403
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
- return jsonify({"error": "File not found"}), 404
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
- return jsonify({"error": "Access denied"}), 403
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
- for directory in webui.image_directories:
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:
@@ -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
@@ -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
- # Resolve and validate within allowed directories
53
- abs_filepath = os.path.realpath(os.path.normpath(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 webui.image_directories:
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
- # Resolve and validate within allowed directories
96
- abs_filepath = os.path.realpath(os.path.normpath(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 webui.image_directories:
99
- # Ensure allowed directories are normalized and real paths
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 and os.path.isfile(
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
- if allowed and os.path.exists(abs_filepath):
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.post192
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. Run with
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
  ![Server Demo Session](docs/source/_static/server-demo.gif)
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=07Zc_rsRCoGM33CaLFaaauZu82lhldgmNe4JW8lm_Rs,6484
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=zOIaXu_Bx_fWGTo5EH0JcERbgUMivJ-QHDfeXgFs064,12895
8
- pumaguard/presets.py,sha256=zS1uuhUPLL-W720-xBeMy3gaNmw6BVMgejgRmZpcsuA,26205
9
- pumaguard/server.py,sha256=4joRqEJ6Z-7I8FJXlb5_gV6UVTv-GQzOaIxVWsmFcVA,14653
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=go2YxIkIznApPV9m39YueOGBNBR6G0vzIE8ayHwaWnk,15159
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=7QeGm6V2UDZDPCtnh_W18m48BieTSxZtPvOc987trro,9858
23
- pumaguard/pumaguard-ui/flutter_service_worker.js,sha256=8Ck8S_gAlNg9rBmbw-y-rGPm84HaBboq4PeYC8D1UdQ,8435
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=fFz7OvGMIyrSvIBcp1jK3P3x1xCh3i3anBpMHuaVgDo,2613988
26
- pumaguard/pumaguard-ui/main.dart.mjs,sha256=u8D9JVFd7gNLKK-L2s_IjAfLkymktS1DXV25sXo4BsQ,33768
27
- pumaguard/pumaguard-ui/main.dart.wasm,sha256=RWpO7GWyeDBt6v5dFdGKy5myYEqAC4VtsRYjfNPqCAY,2296637
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=1N1Z8z_DYdnHTCOG0CHYgpVFpj4NdkigcLN98aFWOQ8,1368490
34
- pumaguard/pumaguard-ui/assets/fonts/MaterialIcons-Regular.otf,sha256=2pgly6yxQSafvNoxPpLlgY_feABJwtQCeQwQZXGGdz0,10264
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=gAVp07llG9QFLIVTFOOVsLA2Ez6yujZmr1iHLyksM8g,2933
57
- pumaguard/web_routes/directories.py,sha256=BgzROI2BVONHMaCJjmn8QxQV6NiREqJQgibrJPlT4hM,2083
58
- pumaguard/web_routes/folders.py,sha256=GXplF4tap5pclwhwpkhzHZwAMOo4xNiewvCampw6daw,4181
59
- pumaguard/web_routes/photos.py,sha256=QJUfTfgbIAsZOTeUzF_iuyQWSH-KiN4lRJ5oHo6UvZ8,4192
60
- pumaguard/web_routes/settings.py,sha256=zvyPVo5_jRQfMEQ8HZng7FrRDX957tPNIRwaddVKD2M,3799
61
- pumaguard/web_routes/sync.py,sha256=zvAkNUnxM6fwtlFtWKI-S8A1svyse2ZNbi-V0BT-UxI,4593
62
- pumaguard-20.post192.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
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.post192.dist-info/METADATA,sha256=KcPKMieevuhakurUm1bps7DTY14zjTtaxInFyLL3JmU,7751
81
- pumaguard-20.post192.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
82
- pumaguard-20.post192.dist-info/entry_points.txt,sha256=rmCdBTPWrbJQvPPwABSVobXE9D7hrKsITGZ6nvCrko8,127
83
- pumaguard-20.post192.dist-info/top_level.txt,sha256=B-PzS4agkQNhOYbLLIrMVOyMD_pl5F-yujPBm5zYYjY,40
84
- pumaguard-20.post192.dist-info/RECORD,,
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,,