pumaguard 20.post172__py3-none-any.whl → 20.post189__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 +7 -1
- pumaguard/presets.py +14 -1
- pumaguard/pumaguard-ui/flutter_bootstrap.js +1 -1
- pumaguard/pumaguard-ui/flutter_service_worker.js +1 -1
- pumaguard/server.py +18 -0
- pumaguard/web_routes/__init__.py +0 -0
- pumaguard/web_routes/artifacts.py +114 -0
- pumaguard/web_routes/diagnostics.py +89 -0
- pumaguard/web_routes/directories.py +67 -0
- pumaguard/web_routes/folders.py +121 -0
- pumaguard/web_routes/photos.py +112 -0
- pumaguard/web_routes/settings.py +116 -0
- pumaguard/web_routes/sync.py +130 -0
- pumaguard/web_ui.py +48 -519
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/METADATA +1 -1
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/RECORD +20 -12
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/WHEEL +0 -0
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/entry_points.txt +0 -0
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/licenses/LICENSE +0 -0
- {pumaguard-20.post172.dist-info → pumaguard-20.post189.dist-info}/top_level.txt +0 -0
pumaguard/web_ui.py
CHANGED
|
@@ -4,13 +4,10 @@ Web-UI for Pumaguard.
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import hashlib
|
|
7
|
-
import io
|
|
8
7
|
import logging
|
|
9
|
-
import os
|
|
10
8
|
import socket
|
|
11
9
|
import threading
|
|
12
10
|
import time
|
|
13
|
-
import zipfile
|
|
14
11
|
from pathlib import (
|
|
15
12
|
Path,
|
|
16
13
|
)
|
|
@@ -19,20 +16,17 @@ from typing import (
|
|
|
19
16
|
TypedDict,
|
|
20
17
|
)
|
|
21
18
|
|
|
22
|
-
import yaml
|
|
23
19
|
from flask import (
|
|
24
20
|
Flask,
|
|
25
21
|
jsonify,
|
|
26
|
-
request,
|
|
27
22
|
send_file,
|
|
28
23
|
send_from_directory,
|
|
29
24
|
)
|
|
30
25
|
from flask_cors import (
|
|
31
26
|
CORS,
|
|
32
27
|
)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
28
|
+
|
|
29
|
+
# Note: YAMLError and yaml operations are handled in route modules
|
|
36
30
|
from zeroconf import (
|
|
37
31
|
NonUniqueNameException,
|
|
38
32
|
ServiceInfo,
|
|
@@ -42,6 +36,27 @@ from zeroconf import (
|
|
|
42
36
|
from pumaguard.presets import (
|
|
43
37
|
Preset,
|
|
44
38
|
)
|
|
39
|
+
from pumaguard.web_routes.artifacts import (
|
|
40
|
+
register_artifacts_routes,
|
|
41
|
+
)
|
|
42
|
+
from pumaguard.web_routes.diagnostics import (
|
|
43
|
+
register_diagnostics_routes,
|
|
44
|
+
)
|
|
45
|
+
from pumaguard.web_routes.directories import (
|
|
46
|
+
register_directories_routes,
|
|
47
|
+
)
|
|
48
|
+
from pumaguard.web_routes.folders import (
|
|
49
|
+
register_folders_routes,
|
|
50
|
+
)
|
|
51
|
+
from pumaguard.web_routes.photos import (
|
|
52
|
+
register_photos_routes,
|
|
53
|
+
)
|
|
54
|
+
from pumaguard.web_routes.settings import (
|
|
55
|
+
register_settings_routes,
|
|
56
|
+
)
|
|
57
|
+
from pumaguard.web_routes.sync import (
|
|
58
|
+
register_sync_routes,
|
|
59
|
+
)
|
|
45
60
|
|
|
46
61
|
if TYPE_CHECKING:
|
|
47
62
|
from pumaguard.server import (
|
|
@@ -62,6 +77,18 @@ class PhotoDict(TypedDict):
|
|
|
62
77
|
created: float
|
|
63
78
|
|
|
64
79
|
|
|
80
|
+
class ArtifactDict(TypedDict):
|
|
81
|
+
"""Type definition for artifact metadata dictionary."""
|
|
82
|
+
|
|
83
|
+
filename: str
|
|
84
|
+
path: str
|
|
85
|
+
ext: str
|
|
86
|
+
kind: str
|
|
87
|
+
size: int
|
|
88
|
+
modified: float
|
|
89
|
+
created: float
|
|
90
|
+
|
|
91
|
+
|
|
65
92
|
class WebUI:
|
|
66
93
|
"""
|
|
67
94
|
The class for the WebUI.
|
|
@@ -182,9 +209,7 @@ class WebUI:
|
|
|
182
209
|
return sha256_hash.hexdigest()
|
|
183
210
|
|
|
184
211
|
def _setup_routes(self):
|
|
185
|
-
"""
|
|
186
|
-
Set up Flask routes to serve the Flutter web app and API.
|
|
187
|
-
"""
|
|
212
|
+
"""Set up Flask routes for the Flutter web app and API."""
|
|
188
213
|
|
|
189
214
|
@self.app.route("/")
|
|
190
215
|
def index():
|
|
@@ -200,522 +225,23 @@ class WebUI:
|
|
|
200
225
|
)
|
|
201
226
|
return send_file(self.build_dir / "index.html")
|
|
202
227
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"""
|
|
206
|
-
Get current settings.
|
|
207
|
-
"""
|
|
208
|
-
return jsonify(dict(self.presets))
|
|
209
|
-
|
|
210
|
-
@self.app.route("/api/settings", methods=["PUT"])
|
|
211
|
-
def update_settings():
|
|
212
|
-
"""Update settings."""
|
|
213
|
-
try:
|
|
214
|
-
data = request.json
|
|
215
|
-
if not data:
|
|
216
|
-
return jsonify({"error": "No data provided"}), 400
|
|
217
|
-
|
|
218
|
-
allowed_settings = [
|
|
219
|
-
"YOLO-min-size",
|
|
220
|
-
"YOLO-conf-thresh",
|
|
221
|
-
"YOLO-max-dets",
|
|
222
|
-
"YOLO-model-filename",
|
|
223
|
-
"classifier-model-filename",
|
|
224
|
-
"deterrent-sound-file",
|
|
225
|
-
"file-stabilization-extra-wait",
|
|
226
|
-
"play-sound",
|
|
227
|
-
]
|
|
228
|
-
|
|
229
|
-
if len(data) == 0:
|
|
230
|
-
raise ValueError("Did not receive any settings")
|
|
231
|
-
|
|
232
|
-
for key, value in data.items():
|
|
233
|
-
if key in allowed_settings:
|
|
234
|
-
logger.debug("Updating %s with %s", key, value)
|
|
235
|
-
# Convert hyphenated names to underscored attribute
|
|
236
|
-
# names
|
|
237
|
-
attr_name = key.replace("-", "_").replace(
|
|
238
|
-
"YOLO_", "yolo_"
|
|
239
|
-
)
|
|
240
|
-
setattr(self.presets, attr_name, value)
|
|
241
|
-
else:
|
|
242
|
-
logger.debug(
|
|
243
|
-
"Skipping unknown/read-only setting: %s", key
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
# Auto-save settings to disk after updating
|
|
247
|
-
try:
|
|
248
|
-
filepath = self.presets.settings_file
|
|
249
|
-
settings_dict = {}
|
|
250
|
-
for key, value in self.presets:
|
|
251
|
-
settings_dict[key] = value
|
|
252
|
-
|
|
253
|
-
with open(filepath, "w", encoding="utf-8") as f:
|
|
254
|
-
yaml.dump(settings_dict, f, default_flow_style=False)
|
|
255
|
-
|
|
256
|
-
logger.info("Settings updated and saved to %s", filepath)
|
|
257
|
-
except YAMLError:
|
|
258
|
-
logger.exception(
|
|
259
|
-
"Error saving settings"
|
|
260
|
-
) # logs stack trace too
|
|
261
|
-
return (
|
|
262
|
-
jsonify(
|
|
263
|
-
{
|
|
264
|
-
"error": "Settings updated but failed to save due to an internal error" # pylint: disable=line-too-long
|
|
265
|
-
}
|
|
266
|
-
),
|
|
267
|
-
500,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
return jsonify(
|
|
271
|
-
{"success": True, "message": "Settings updated and saved"}
|
|
272
|
-
)
|
|
273
|
-
except ValueError as e:
|
|
274
|
-
logger.error("Error updating settings: %s", e)
|
|
275
|
-
return jsonify({"error": str(e)}), 500
|
|
276
|
-
|
|
277
|
-
@self.app.route("/api/settings/save", methods=["POST"])
|
|
278
|
-
def save_settings():
|
|
279
|
-
"""Save current settings to a YAML file."""
|
|
280
|
-
data = request.json
|
|
281
|
-
filepath = data.get("filepath") if data else None
|
|
282
|
-
|
|
283
|
-
if not filepath:
|
|
284
|
-
# Use default settings file
|
|
285
|
-
filepath = self.presets.settings_file
|
|
286
|
-
|
|
287
|
-
settings_dict = {}
|
|
288
|
-
for key, value in self.presets:
|
|
289
|
-
settings_dict[key] = value
|
|
290
|
-
|
|
291
|
-
with open(filepath, "w", encoding="utf-8") as f:
|
|
292
|
-
yaml.dump(settings_dict, f, default_flow_style=False)
|
|
293
|
-
|
|
294
|
-
logger.info("Settings saved to %s", filepath)
|
|
295
|
-
return jsonify({"success": True, "filepath": filepath})
|
|
296
|
-
|
|
297
|
-
@self.app.route("/api/settings/load", methods=["POST"])
|
|
298
|
-
def load_settings():
|
|
299
|
-
"""Load settings from a YAML file."""
|
|
300
|
-
data = request.json
|
|
301
|
-
filepath = data.get("filepath") if data else None
|
|
302
|
-
|
|
303
|
-
if not filepath:
|
|
304
|
-
return jsonify({"error": "No filepath provided"}), 400
|
|
305
|
-
|
|
306
|
-
self.presets.load(filepath)
|
|
307
|
-
logger.info("Settings loaded from %s", filepath)
|
|
308
|
-
return jsonify({"success": True, "message": "Settings loaded"})
|
|
309
|
-
|
|
310
|
-
# Photos/Images API
|
|
311
|
-
@self.app.route("/api/photos", methods=["GET"])
|
|
312
|
-
def get_photos():
|
|
313
|
-
"""Get list of captured photos."""
|
|
314
|
-
photos: list[PhotoDict] = []
|
|
315
|
-
for directory in self.image_directories:
|
|
316
|
-
if not os.path.exists(directory):
|
|
317
|
-
continue
|
|
318
|
-
|
|
319
|
-
for filename in os.listdir(directory):
|
|
320
|
-
filepath = os.path.join(directory, filename)
|
|
321
|
-
if os.path.isfile(filepath):
|
|
322
|
-
# Check if it's an image file
|
|
323
|
-
ext = os.path.splitext(filename)[1].lower()
|
|
324
|
-
if ext in [
|
|
325
|
-
".jpg",
|
|
326
|
-
".jpeg",
|
|
327
|
-
".png",
|
|
328
|
-
".gif",
|
|
329
|
-
".bmp",
|
|
330
|
-
".webp",
|
|
331
|
-
]:
|
|
332
|
-
stat = os.stat(filepath)
|
|
333
|
-
photos.append(
|
|
334
|
-
{
|
|
335
|
-
"filename": filename,
|
|
336
|
-
"path": filepath,
|
|
337
|
-
"directory": directory,
|
|
338
|
-
"size": stat.st_size,
|
|
339
|
-
"modified": stat.st_mtime,
|
|
340
|
-
"created": stat.st_ctime,
|
|
341
|
-
}
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
# Sort by modified time, newest first
|
|
345
|
-
photos.sort(key=lambda x: x["modified"], reverse=True)
|
|
346
|
-
|
|
347
|
-
return jsonify({"photos": photos, "total": len(photos)})
|
|
348
|
-
|
|
349
|
-
@self.app.route("/api/photos/<path:filepath>", methods=["GET"])
|
|
350
|
-
def get_photo(filepath):
|
|
351
|
-
"""Get a specific photo."""
|
|
352
|
-
# Security check: ensure the file is in one of the allowed
|
|
353
|
-
# directories
|
|
354
|
-
abs_filepath = os.path.abspath(filepath)
|
|
355
|
-
allowed = False
|
|
356
|
-
for directory in self.image_directories:
|
|
357
|
-
abs_directory = os.path.abspath(directory)
|
|
358
|
-
if abs_filepath.startswith(abs_directory):
|
|
359
|
-
allowed = True
|
|
360
|
-
break
|
|
361
|
-
|
|
362
|
-
if not allowed:
|
|
363
|
-
return jsonify({"error": "Access denied"}), 403
|
|
364
|
-
|
|
365
|
-
if not os.path.exists(abs_filepath):
|
|
366
|
-
return jsonify({"error": "File not found"}), 404
|
|
367
|
-
|
|
368
|
-
directory = os.path.dirname(abs_filepath)
|
|
369
|
-
filename = os.path.basename(abs_filepath)
|
|
370
|
-
return send_from_directory(directory, filename)
|
|
371
|
-
|
|
372
|
-
@self.app.route("/api/photos/<path:filepath>", methods=["DELETE"])
|
|
373
|
-
def delete_photo(filepath):
|
|
374
|
-
"""Delete a photo."""
|
|
375
|
-
# Security check: ensure the file is in one of the allowed
|
|
376
|
-
# directories
|
|
377
|
-
abs_filepath = os.path.abspath(filepath)
|
|
378
|
-
allowed = False
|
|
379
|
-
for directory in self.image_directories:
|
|
380
|
-
abs_directory = os.path.abspath(directory)
|
|
381
|
-
if abs_filepath.startswith(abs_directory):
|
|
382
|
-
allowed = True
|
|
383
|
-
break
|
|
384
|
-
|
|
385
|
-
if not allowed:
|
|
386
|
-
return jsonify({"error": "Access denied"}), 403
|
|
387
|
-
|
|
388
|
-
if not os.path.exists(abs_filepath):
|
|
389
|
-
return jsonify({"error": "File not found"}), 404
|
|
390
|
-
|
|
391
|
-
os.remove(abs_filepath)
|
|
392
|
-
logger.info("Deleted photo: %s", abs_filepath)
|
|
393
|
-
return jsonify({"success": True, "message": "Photo deleted"})
|
|
394
|
-
|
|
395
|
-
# Folder Browser API
|
|
396
|
-
@self.app.route("/api/folders", methods=["GET"])
|
|
397
|
-
def get_folders():
|
|
398
|
-
"""Get list of watched folders with image counts."""
|
|
399
|
-
folders = []
|
|
400
|
-
for directory in self.image_directories:
|
|
401
|
-
if not os.path.exists(directory):
|
|
402
|
-
continue
|
|
403
|
-
|
|
404
|
-
# Count images in folder
|
|
405
|
-
image_count = 0
|
|
406
|
-
for filename in os.listdir(directory):
|
|
407
|
-
filepath = os.path.join(directory, filename)
|
|
408
|
-
if os.path.isfile(filepath):
|
|
409
|
-
ext = os.path.splitext(filename)[1].lower()
|
|
410
|
-
if ext in [
|
|
411
|
-
".jpg",
|
|
412
|
-
".jpeg",
|
|
413
|
-
".png",
|
|
414
|
-
".gif",
|
|
415
|
-
".bmp",
|
|
416
|
-
".webp",
|
|
417
|
-
]:
|
|
418
|
-
image_count += 1
|
|
419
|
-
|
|
420
|
-
folders.append(
|
|
421
|
-
{
|
|
422
|
-
"path": directory,
|
|
423
|
-
"name": os.path.basename(directory),
|
|
424
|
-
"image_count": image_count,
|
|
425
|
-
}
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
return jsonify({"folders": folders})
|
|
429
|
-
|
|
430
|
-
@self.app.route(
|
|
431
|
-
"/api/folders/<path:folder_path>/images", methods=["GET"]
|
|
432
|
-
)
|
|
433
|
-
def get_folder_images(folder_path):
|
|
434
|
-
"""Get list of images in a specific folder."""
|
|
435
|
-
# Security check: ensure the folder is in allowed directories
|
|
436
|
-
# Normalize and resolve symlinks for both folder_path and allowed
|
|
437
|
-
# directories
|
|
438
|
-
abs_folder = os.path.realpath(os.path.normpath(folder_path))
|
|
439
|
-
allowed = False
|
|
440
|
-
for directory in self.image_directories:
|
|
441
|
-
abs_directory = os.path.realpath(os.path.normpath(directory))
|
|
442
|
-
# Ensure abs_folder is contained in abs_directory
|
|
443
|
-
common = os.path.commonpath([abs_folder, abs_directory])
|
|
444
|
-
if common == abs_directory:
|
|
445
|
-
allowed = True
|
|
446
|
-
break
|
|
447
|
-
|
|
448
|
-
if not allowed:
|
|
449
|
-
return jsonify({"error": "Access denied"}), 403
|
|
450
|
-
|
|
451
|
-
if not os.path.exists(abs_folder):
|
|
452
|
-
return jsonify({"error": "Folder not found"}), 404
|
|
453
|
-
|
|
454
|
-
images = []
|
|
455
|
-
for filename in os.listdir(abs_folder):
|
|
456
|
-
filepath = os.path.join(abs_folder, filename)
|
|
457
|
-
# Security: resolve and ensure file is in allowed folder
|
|
458
|
-
resolved_filepath = os.path.realpath(
|
|
459
|
-
os.path.normpath(filepath)
|
|
460
|
-
)
|
|
461
|
-
if (
|
|
462
|
-
os.path.commonpath([resolved_filepath, abs_folder])
|
|
463
|
-
!= abs_folder
|
|
464
|
-
):
|
|
465
|
-
continue
|
|
466
|
-
if os.path.isfile(resolved_filepath):
|
|
467
|
-
ext = os.path.splitext(filename)[1].lower()
|
|
468
|
-
if ext in [
|
|
469
|
-
".jpg",
|
|
470
|
-
".jpeg",
|
|
471
|
-
".png",
|
|
472
|
-
".gif",
|
|
473
|
-
".bmp",
|
|
474
|
-
".webp",
|
|
475
|
-
]:
|
|
476
|
-
stat = os.stat(resolved_filepath)
|
|
477
|
-
images.append(
|
|
478
|
-
{
|
|
479
|
-
"filename": filename,
|
|
480
|
-
"path": resolved_filepath,
|
|
481
|
-
"size": stat.st_size,
|
|
482
|
-
"modified": stat.st_mtime,
|
|
483
|
-
"created": stat.st_ctime,
|
|
484
|
-
}
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
# Sort by modified time, newest first
|
|
488
|
-
images.sort(key=lambda x: x["modified"], reverse=True)
|
|
489
|
-
|
|
490
|
-
return jsonify({"images": images, "folder": abs_folder})
|
|
491
|
-
|
|
492
|
-
@self.app.route("/api/sync/checksums", methods=["POST"])
|
|
493
|
-
def calculate_checksums():
|
|
494
|
-
"""
|
|
495
|
-
Calculate checksums for requested files.
|
|
496
|
-
Client sends list of files with their checksums,
|
|
497
|
-
server returns which files need to be downloaded.
|
|
498
|
-
"""
|
|
499
|
-
data = request.json
|
|
500
|
-
if not data or "files" not in data:
|
|
501
|
-
return jsonify({"error": "No files provided"}), 400
|
|
502
|
-
|
|
503
|
-
client_files = data["files"] # Dict of {filepath: checksum}
|
|
504
|
-
files_to_download = []
|
|
505
|
-
|
|
506
|
-
for filepath, client_checksum in client_files.items():
|
|
507
|
-
# Security check
|
|
508
|
-
abs_filepath = os.path.abspath(filepath)
|
|
509
|
-
allowed = False
|
|
510
|
-
for directory in self.image_directories:
|
|
511
|
-
abs_directory = os.path.abspath(directory)
|
|
512
|
-
if abs_filepath.startswith(abs_directory):
|
|
513
|
-
allowed = True
|
|
514
|
-
break
|
|
515
|
-
|
|
516
|
-
if not allowed:
|
|
517
|
-
continue
|
|
518
|
-
|
|
519
|
-
if not os.path.exists(abs_filepath):
|
|
520
|
-
continue
|
|
521
|
-
|
|
522
|
-
# Calculate server-side checksum
|
|
523
|
-
server_checksum = self._calculate_file_checksum(abs_filepath)
|
|
524
|
-
|
|
525
|
-
# If checksums don't match or client doesn't have it, mark for
|
|
526
|
-
# download
|
|
527
|
-
if server_checksum != client_checksum:
|
|
528
|
-
stat = os.stat(abs_filepath)
|
|
529
|
-
files_to_download.append(
|
|
530
|
-
{
|
|
531
|
-
"path": filepath,
|
|
532
|
-
"checksum": server_checksum,
|
|
533
|
-
"size": stat.st_size,
|
|
534
|
-
"modified": stat.st_mtime,
|
|
535
|
-
}
|
|
536
|
-
)
|
|
537
|
-
|
|
538
|
-
return jsonify(
|
|
539
|
-
{
|
|
540
|
-
"files_to_download": files_to_download,
|
|
541
|
-
"total": len(files_to_download),
|
|
542
|
-
}
|
|
543
|
-
)
|
|
544
|
-
|
|
545
|
-
@self.app.route("/api/sync/download", methods=["POST"])
|
|
546
|
-
def download_files():
|
|
547
|
-
"""
|
|
548
|
-
Download multiple files as a ZIP archive.
|
|
549
|
-
"""
|
|
550
|
-
data = request.json
|
|
551
|
-
if not data or "files" not in data:
|
|
552
|
-
return jsonify({"error": "No files provided"}), 400
|
|
553
|
-
|
|
554
|
-
file_paths = data["files"]
|
|
555
|
-
|
|
556
|
-
# Validate all files
|
|
557
|
-
validated_files = []
|
|
558
|
-
for filepath in file_paths:
|
|
559
|
-
abs_filepath = os.path.abspath(filepath)
|
|
560
|
-
allowed = False
|
|
561
|
-
for directory in self.image_directories:
|
|
562
|
-
abs_directory = os.path.abspath(directory)
|
|
563
|
-
if abs_filepath.startswith(abs_directory):
|
|
564
|
-
allowed = True
|
|
565
|
-
break
|
|
566
|
-
|
|
567
|
-
if allowed and os.path.exists(abs_filepath):
|
|
568
|
-
validated_files.append(abs_filepath)
|
|
569
|
-
|
|
570
|
-
if not validated_files:
|
|
571
|
-
return jsonify({"error": "No valid files to download"}), 400
|
|
572
|
-
|
|
573
|
-
# For single file, return it directly
|
|
574
|
-
if len(validated_files) == 1:
|
|
575
|
-
directory = os.path.dirname(validated_files[0])
|
|
576
|
-
filename = os.path.basename(validated_files[0])
|
|
577
|
-
return send_from_directory(
|
|
578
|
-
directory, filename, as_attachment=True
|
|
579
|
-
)
|
|
580
|
-
|
|
581
|
-
memory_file = io.BytesIO()
|
|
582
|
-
with zipfile.ZipFile(memory_file, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
583
|
-
for filepath in validated_files:
|
|
584
|
-
# Use relative path in zip to maintain folder structure
|
|
585
|
-
arcname = os.path.basename(filepath)
|
|
586
|
-
zf.write(filepath, arcname)
|
|
587
|
-
|
|
588
|
-
memory_file.seek(0)
|
|
589
|
-
return send_file(
|
|
590
|
-
memory_file,
|
|
591
|
-
mimetype="application/zip",
|
|
592
|
-
as_attachment=True,
|
|
593
|
-
download_name="pumaguard_images.zip",
|
|
594
|
-
)
|
|
595
|
-
|
|
596
|
-
# Directories API
|
|
597
|
-
@self.app.route("/api/directories", methods=["GET"])
|
|
598
|
-
def get_directories():
|
|
599
|
-
"""Get list of image directories being monitored."""
|
|
600
|
-
return jsonify({"directories": self.image_directories})
|
|
601
|
-
|
|
602
|
-
@self.app.route("/api/directories", methods=["POST"])
|
|
603
|
-
def add_directory():
|
|
604
|
-
"""Add a directory to monitor for images."""
|
|
605
|
-
data = request.json
|
|
606
|
-
directory = data.get("directory") if data else None
|
|
607
|
-
|
|
608
|
-
if not directory:
|
|
609
|
-
return jsonify({"error": "No directory provided"}), 400
|
|
610
|
-
|
|
611
|
-
if not os.path.exists(directory):
|
|
612
|
-
return jsonify({"error": "Directory does not exist"}), 400
|
|
613
|
-
|
|
614
|
-
if directory not in self.image_directories:
|
|
615
|
-
self.image_directories.append(directory)
|
|
616
|
-
logger.info("Added image directory: %s", directory)
|
|
228
|
+
# Register modular route groups
|
|
229
|
+
register_settings_routes(self.app, self)
|
|
617
230
|
|
|
618
|
-
|
|
619
|
-
if self.folder_manager is not None:
|
|
620
|
-
self.folder_manager.register_folder(
|
|
621
|
-
directory, self.watch_method
|
|
622
|
-
)
|
|
623
|
-
logger.info(
|
|
624
|
-
"Registered folder with manager: %s (method: %s)",
|
|
625
|
-
directory,
|
|
626
|
-
self.watch_method,
|
|
627
|
-
)
|
|
231
|
+
register_photos_routes(self.app, self)
|
|
628
232
|
|
|
629
|
-
|
|
630
|
-
{"success": True, "directories": self.image_directories}
|
|
631
|
-
)
|
|
233
|
+
register_folders_routes(self.app, self)
|
|
632
234
|
|
|
633
|
-
|
|
634
|
-
def remove_directory(index):
|
|
635
|
-
"""Remove a directory from watch list."""
|
|
636
|
-
if 0 <= index < len(self.image_directories):
|
|
637
|
-
removed = self.image_directories.pop(index)
|
|
638
|
-
logger.info("Removed image directory: %s", removed)
|
|
639
|
-
return jsonify(
|
|
640
|
-
{
|
|
641
|
-
"success": True,
|
|
642
|
-
"directories": self.image_directories,
|
|
643
|
-
}
|
|
644
|
-
)
|
|
645
|
-
return jsonify({"error": "Invalid index"}), 400
|
|
646
|
-
|
|
647
|
-
# System/Status API
|
|
648
|
-
@self.app.route("/api/status", methods=["GET"])
|
|
649
|
-
def get_status():
|
|
650
|
-
"""Get server status."""
|
|
651
|
-
# Add request info to help debug CORS and origin issues
|
|
652
|
-
origin = request.headers.get("Origin", "No Origin header")
|
|
653
|
-
host = request.headers.get("Host", "No Host header")
|
|
654
|
-
|
|
655
|
-
logger.debug(
|
|
656
|
-
"API status called - Origin: %s, Host: %s", origin, host
|
|
657
|
-
)
|
|
235
|
+
register_sync_routes(self.app, self)
|
|
658
236
|
|
|
659
|
-
|
|
660
|
-
{
|
|
661
|
-
"status": "running",
|
|
662
|
-
"version": "1.0.0",
|
|
663
|
-
"directories_count": len(self.image_directories),
|
|
664
|
-
"host": self.host,
|
|
665
|
-
"port": self.port,
|
|
666
|
-
"request_origin": origin,
|
|
667
|
-
"request_host": host,
|
|
668
|
-
}
|
|
669
|
-
)
|
|
237
|
+
# Routes delegated to web_routes.artifacts
|
|
670
238
|
|
|
671
|
-
|
|
672
|
-
def get_diagnostic():
|
|
673
|
-
"""
|
|
674
|
-
Get diagnostic information to help debug URL detection issues.
|
|
675
|
-
"""
|
|
676
|
-
# Get all relevant request information
|
|
677
|
-
diagnostic_info = {
|
|
678
|
-
"server": {
|
|
679
|
-
"host": self.host,
|
|
680
|
-
"port": self.port,
|
|
681
|
-
"flutter_dir": str(self.flutter_dir),
|
|
682
|
-
"build_dir": str(self.build_dir),
|
|
683
|
-
"build_exists": self.build_dir.exists(),
|
|
684
|
-
"mdns_enabled": self.mdns_enabled,
|
|
685
|
-
"mdns_name": self.mdns_name if self.mdns_enabled else None,
|
|
686
|
-
"mdns_url": (
|
|
687
|
-
f"http://{self.mdns_name}.local:{self.port}"
|
|
688
|
-
if self.mdns_enabled
|
|
689
|
-
else None
|
|
690
|
-
),
|
|
691
|
-
"local_ip": self._get_local_ip(),
|
|
692
|
-
},
|
|
693
|
-
"request": {
|
|
694
|
-
"url": request.url,
|
|
695
|
-
"base_url": request.base_url,
|
|
696
|
-
"host": request.headers.get("Host", "N/A"),
|
|
697
|
-
"origin": request.headers.get("Origin", "N/A"),
|
|
698
|
-
"referer": request.headers.get("Referer", "N/A"),
|
|
699
|
-
"user_agent": request.headers.get("User-Agent", "N/A"),
|
|
700
|
-
},
|
|
701
|
-
"expected_behavior": {
|
|
702
|
-
"flutter_app_should_detect": f"{request.scheme}://{request.host}", # pylint: disable=line-too-long
|
|
703
|
-
"api_calls_should_go_to": f"{request.scheme}://{request.host}/api/...", # pylint: disable=line-too-long
|
|
704
|
-
},
|
|
705
|
-
"troubleshooting": {
|
|
706
|
-
"if_api_calls_go_to_localhost": "Browser is using cached old JavaScript - clear cache", # pylint: disable=line-too-long
|
|
707
|
-
"if_page_doesnt_load": "Check that Flutter app is built: make build-ui", # pylint: disable=line-too-long
|
|
708
|
-
"if_cors_errors": "Check browser console for details",
|
|
709
|
-
},
|
|
710
|
-
}
|
|
239
|
+
register_directories_routes(self.app, self)
|
|
711
240
|
|
|
712
|
-
|
|
713
|
-
"Diagnostic endpoint called from: %s", request.remote_addr
|
|
714
|
-
)
|
|
715
|
-
return jsonify(diagnostic_info)
|
|
241
|
+
register_diagnostics_routes(self.app, self)
|
|
716
242
|
|
|
717
243
|
@self.app.route("/<path:path>")
|
|
718
|
-
def serve_static(path):
|
|
244
|
+
def serve_static(path: str):
|
|
719
245
|
"""
|
|
720
246
|
Serve static files (JS, CSS, assets, etc.).
|
|
721
247
|
"""
|
|
@@ -735,6 +261,9 @@ class WebUI:
|
|
|
735
261
|
return send_from_directory(self.build_dir, path)
|
|
736
262
|
return send_file(self.build_dir / "index.html")
|
|
737
263
|
|
|
264
|
+
# Register artifacts after core routes
|
|
265
|
+
register_artifacts_routes(self.app, self)
|
|
266
|
+
|
|
738
267
|
def add_image_directory(self, directory: str):
|
|
739
268
|
"""
|
|
740
269
|
Add a directory to scan for images.
|