trailframe 0.1.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.
Files changed (63) hide show
  1. trailframe/__init__.py +0 -0
  2. trailframe/__main__.py +3 -0
  3. trailframe/api/__init__.py +0 -0
  4. trailframe/api/about.py +84 -0
  5. trailframe/api/activities.py +205 -0
  6. trailframe/api/configuration.py +19 -0
  7. trailframe/api/events.py +45 -0
  8. trailframe/api/map_data.py +63 -0
  9. trailframe/api/models.py +52 -0
  10. trailframe/api/photos.py +338 -0
  11. trailframe/api/pipeline.py +37 -0
  12. trailframe/api/statistics.py +43 -0
  13. trailframe/api/tiles.py +16 -0
  14. trailframe/frontend/dist/assets/favicon-DuTzlsvV.png +0 -0
  15. trailframe/frontend/dist/assets/main-8Rix1cnZ.js +1 -0
  16. trailframe/frontend/dist/assets/main-vh-t_kPv.css +1 -0
  17. trailframe/frontend/dist/assets/slideshow-GzAXSY-n.js +1 -0
  18. trailframe/frontend/dist/assets/theme-CZT-v4sH.css +1 -0
  19. trailframe/frontend/dist/assets/theme-sVwLD9Y8.js +340 -0
  20. trailframe/frontend/dist/index.html +16 -0
  21. trailframe/frontend/dist/slideshow.html +15 -0
  22. trailframe/main.py +132 -0
  23. trailframe/models/__init__.py +0 -0
  24. trailframe/models/activity.py +411 -0
  25. trailframe/models/group.py +19 -0
  26. trailframe/models/photo.py +110 -0
  27. trailframe/models/scanner_stat.py +14 -0
  28. trailframe/services/__init__.py +0 -0
  29. trailframe/services/activity_service.py +224 -0
  30. trailframe/services/configuration_service.py +168 -0
  31. trailframe/services/database_service.py +137 -0
  32. trailframe/services/folder_service.py +208 -0
  33. trailframe/services/garmin_connect_service.py +201 -0
  34. trailframe/services/gpx_service.py +92 -0
  35. trailframe/services/location_service.py +435 -0
  36. trailframe/services/map_service.py +230 -0
  37. trailframe/services/pipeline_service.py +71 -0
  38. trailframe/services/pipelines/__init__.py +0 -0
  39. trailframe/services/pipelines/basic_pipeline.py +59 -0
  40. trailframe/services/pipelines/creation_pipeline.py +54 -0
  41. trailframe/services/pipelines/executor.py +15 -0
  42. trailframe/services/pipelines/pipeline.py +174 -0
  43. trailframe/services/scanners/__init__.py +0 -0
  44. trailframe/services/scanners/activity_scanner.py +76 -0
  45. trailframe/services/scanners/brisque_model_live.yml +9424 -0
  46. trailframe/services/scanners/brisque_range_live.yml +25 -0
  47. trailframe/services/scanners/brisque_scanner.py +28 -0
  48. trailframe/services/scanners/exif_scanner.py +96 -0
  49. trailframe/services/scanners/file_scanner.py +22 -0
  50. trailframe/services/scanners/location_scanner.py +36 -0
  51. trailframe/services/scanners/object_scanner.py +71 -0
  52. trailframe/services/scanners/perceptual_hash_scanner.py +19 -0
  53. trailframe/services/scanners/scanner.py +71 -0
  54. trailframe/services/scanners/thumbnail_scanner.py +14 -0
  55. trailframe/services/service.py +68 -0
  56. trailframe/services/statistics_service.py +96 -0
  57. trailframe/services/thumbnail_service.py +101 -0
  58. trailframe/services/tile_service.py +168 -0
  59. trailframe-0.1.0.dist-info/METADATA +130 -0
  60. trailframe-0.1.0.dist-info/RECORD +63 -0
  61. trailframe-0.1.0.dist-info/WHEEL +4 -0
  62. trailframe-0.1.0.dist-info/entry_points.txt +2 -0
  63. trailframe-0.1.0.dist-info/licenses/LICENSE +21 -0
trailframe/__init__.py ADDED
File without changes
trailframe/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from trailframe.main import main
2
+
3
+ main()
File without changes
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from importlib.metadata import Distribution, distributions, packages_distributions
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter
8
+ from pydantic import BaseModel
9
+
10
+ router = APIRouter(prefix="/api/about", tags=["about"])
11
+
12
+ _APP_ROOT = Path(__file__).resolve().parents[1]
13
+
14
+
15
+ class PackageInfo(BaseModel):
16
+ name: str
17
+ version: str
18
+ license: str
19
+
20
+
21
+ def _imported_module_names() -> set[str]:
22
+ names: set[str] = set()
23
+
24
+ for path in _APP_ROOT.rglob("*.py"):
25
+ try:
26
+ tree = ast.parse(path.read_text(encoding="utf-8"))
27
+ except (OSError, SyntaxError):
28
+ continue
29
+
30
+ for node in ast.walk(tree):
31
+ if isinstance(node, ast.Import):
32
+ for alias in node.names:
33
+ names.add(alias.name.split(".", 1)[0])
34
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
35
+ names.add(node.module.split(".", 1)[0])
36
+
37
+ return names
38
+
39
+
40
+ def _package_license(dist: Distribution) -> str:
41
+ metadata = dist.metadata
42
+
43
+ for classifier in metadata.get_all("Classifier") or []:
44
+ if classifier.startswith("License :: OSI Approved ::"):
45
+ return classifier.split("::")[-1].strip()
46
+
47
+ for key in ("License-Expression", "License"):
48
+ value = metadata.get(key)
49
+ if value:
50
+ value = value.strip()
51
+ if len(value) <= 80:
52
+ return value
53
+
54
+ return ""
55
+
56
+
57
+ @router.get("/packages", response_model=list[PackageInfo])
58
+ async def list_packages() -> list[PackageInfo]:
59
+ installed = {dist.metadata.get("Name", "").casefold(): dist for dist in distributions()}
60
+ module_distributions = packages_distributions()
61
+
62
+ distribution_names: set[str] = set()
63
+
64
+ for module in _imported_module_names():
65
+ distribution_names.update(
66
+ dist_name for dist_name in module_distributions.get(module, ()) if dist_name.casefold() in installed
67
+ )
68
+
69
+ packages: list[PackageInfo] = []
70
+
71
+ for key in sorted(distribution_names, key=str.casefold):
72
+ dist = installed.get(key.casefold())
73
+ if dist is None:
74
+ continue
75
+
76
+ packages.append(
77
+ PackageInfo(
78
+ name=dist.metadata.get("Name", ""),
79
+ version=dist.version,
80
+ license=_package_license(dist),
81
+ )
82
+ )
83
+
84
+ return packages
@@ -0,0 +1,205 @@
1
+ import re
2
+ import tempfile
3
+ import zipfile
4
+ from io import BytesIO
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ from fastapi import APIRouter, File, HTTPException, UploadFile
9
+ from fastapi.responses import FileResponse, Response
10
+ from pydantic import BaseModel
11
+
12
+ from trailframe.models.activity import Activity, ActivitySummary, GarminActivitySummary, GpxActivitySummary
13
+ from trailframe.models.photo import Photo, PhotoDetail
14
+ from trailframe.services.activity_service import ActivityService
15
+ from trailframe.services.folder_service import FolderService
16
+ from trailframe.services.garmin_connect_service import GarminConnectService
17
+ from trailframe.services.gpx_service import GpxService
18
+ from trailframe.services.map_service import MapService
19
+
20
+ router = APIRouter(prefix="/api/activities", tags=["activities"])
21
+
22
+
23
+ class ImportResult(BaseModel):
24
+ success: bool
25
+
26
+
27
+ @router.get("", response_model=list[ActivitySummary])
28
+ async def list_activities() -> list[ActivitySummary]:
29
+ return await ActivityService.list_activities()
30
+
31
+
32
+ @router.get("/garmin", response_model=list[GarminActivitySummary])
33
+ async def list_garmin_activities() -> list[GarminActivitySummary]:
34
+ return await GarminConnectService.list_summaries()
35
+
36
+
37
+ @router.post("/garmin/sync", response_model=ImportResult)
38
+ async def garmin_sync(credentials: dict[str, Any]) -> ImportResult:
39
+ success = await ActivityService.sync_garmin(credentials.get("email", ""), credentials.get("password", ""))
40
+
41
+ if not success:
42
+ raise HTTPException(status_code=401, detail="Garmin login failed")
43
+
44
+ return ImportResult(success=True)
45
+
46
+
47
+ @router.get("/gpx", response_model=list[GpxActivitySummary])
48
+ async def list_gpx_activities() -> list[GpxActivitySummary]:
49
+ return await GpxService.list_summaries()
50
+
51
+
52
+ @router.post("/gpx/upload", response_model=GpxActivitySummary)
53
+ async def upload_gpx(file: UploadFile = File(...)) -> GpxActivitySummary:
54
+ content = (await file.read()).decode("utf-8", errors="replace")
55
+
56
+ gpx_activity = await GpxService.save(content, file.filename or "activity.gpx")
57
+
58
+ if gpx_activity is None:
59
+ raise HTTPException(status_code=400, detail="Invalid GPX file")
60
+
61
+ return GpxActivitySummary(
62
+ id=gpx_activity.id,
63
+ filename=gpx_activity.filename,
64
+ name=gpx_activity.name,
65
+ activity_type=gpx_activity.activity_type,
66
+ start_time=gpx_activity.start_time,
67
+ distance=gpx_activity.distance,
68
+ duration=gpx_activity.duration,
69
+ )
70
+
71
+
72
+ @router.post("/gpx/import/{gpx_id}", response_model=Activity)
73
+ async def import_gpx(gpx_id: int) -> Activity:
74
+ activity = await ActivityService.import_gpx(gpx_id)
75
+
76
+ if activity is None:
77
+ raise HTTPException(status_code=404, detail="GPX activity not found")
78
+
79
+ return activity
80
+
81
+
82
+ @router.post("/import/{activity_id}", response_model=Activity)
83
+ async def import_activity(activity_id: int) -> Activity:
84
+ activity = await ActivityService.import_activity(activity_id)
85
+
86
+ if activity is None:
87
+ raise HTTPException(status_code=404, detail="Garmin activity not found")
88
+
89
+ return activity
90
+
91
+
92
+ @router.get("/{activity_id}/map")
93
+ async def activity_map(activity_id: int) -> FileResponse:
94
+ activity = await ActivityService.get_activity(activity_id)
95
+
96
+ if activity is None or not activity.activity_id:
97
+ raise HTTPException(status_code=404, detail="Activity not found")
98
+
99
+ path = MapService.get_map(activity.activity_id)
100
+
101
+ if not path.exists():
102
+ raise HTTPException(status_code=404, detail="Map not found")
103
+
104
+ return FileResponse(path, media_type="image/svg+xml")
105
+
106
+
107
+ @router.get("/{activity_id}/traces")
108
+ async def activity_traces(activity_id: int) -> FileResponse:
109
+ activity = await ActivityService.get_activity(activity_id)
110
+
111
+ if activity is None or not activity.activity_id:
112
+ raise HTTPException(status_code=404, detail="Activity not found")
113
+
114
+ path = MapService.get_trace(activity.activity_id)
115
+
116
+ if not path.exists():
117
+ raise HTTPException(status_code=404, detail="Trace not found")
118
+
119
+ return FileResponse(path, media_type="image/svg+xml")
120
+
121
+
122
+ @router.get("/{activity_id}/photos", response_model=list[PhotoDetail])
123
+ async def activity_photos(activity_id: int) -> list[PhotoDetail]:
124
+ photos = await ActivityService.list_activity_photos(activity_id)
125
+
126
+ return [PhotoDetail.from_photo(photo) for photo in photos]
127
+
128
+
129
+ def _build_zip_response(activity: Activity, photos: list[Photo], overlay_path: Path | None = None) -> Response:
130
+ buffer = BytesIO()
131
+
132
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
133
+ for photo in photos:
134
+ path = FolderService.resolve(photo.path)
135
+
136
+ if path.is_file():
137
+ archive.write(path, arcname=path.name)
138
+
139
+ if overlay_path is not None and overlay_path.is_file():
140
+ archive.write(overlay_path, arcname="map.png")
141
+
142
+ base = re.sub(r"[^A-Za-z0-9._-]+", "_", activity.name or f"activity-{activity.id}").strip("_")
143
+ filename = f"{base}.zip" if base else f"activity-{activity.id}.zip"
144
+
145
+ return Response(
146
+ content=buffer.getvalue(),
147
+ media_type="application/zip",
148
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
149
+ )
150
+
151
+
152
+ @router.get("/{activity_id}/zip")
153
+ async def activity_zip(activity_id: int) -> Response:
154
+ activity = await ActivityService.get_activity(activity_id)
155
+
156
+ if activity is None:
157
+ raise HTTPException(status_code=404, detail="Activity not found")
158
+
159
+ photos = await ActivityService.list_activity_photos(activity_id)
160
+
161
+ return _build_zip_response(activity, photos)
162
+
163
+
164
+ @router.post("/{activity_id}/zip")
165
+ async def activity_zip_with_overlay(
166
+ activity_id: int,
167
+ overlay: Annotated[UploadFile | None, File()] = None,
168
+ ) -> Response:
169
+ activity = await ActivityService.get_activity(activity_id)
170
+
171
+ if activity is None:
172
+ raise HTTPException(status_code=404, detail="Activity not found")
173
+
174
+ photos = await ActivityService.list_activity_photos(activity_id)
175
+
176
+ overlay_path = None
177
+
178
+ if overlay is not None:
179
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as file:
180
+ file.write(await overlay.read())
181
+ overlay_path = Path(file.name)
182
+
183
+ try:
184
+ return _build_zip_response(activity, photos, overlay_path)
185
+ finally:
186
+ if overlay_path is not None:
187
+ overlay_path.unlink(missing_ok=True)
188
+
189
+
190
+ @router.get("/{activity_id}", response_model=Activity)
191
+ async def get_activity(activity_id: int) -> Activity:
192
+ activity = await ActivityService.get_activity(activity_id)
193
+
194
+ if activity is None:
195
+ raise HTTPException(status_code=404, detail="Activity not found")
196
+
197
+ return activity
198
+
199
+
200
+ @router.delete("/{activity_id}")
201
+ async def delete_activity(activity_id: int) -> None:
202
+ deleted = await ActivityService.delete_activity(activity_id)
203
+
204
+ if not deleted:
205
+ raise HTTPException(status_code=404, detail="Activity not found")
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from trailframe.services.configuration_service import ConfigurationService
6
+
7
+ router = APIRouter(prefix="/api/configuration", tags=["configuration"])
8
+
9
+
10
+ @router.get("")
11
+ async def get_configuration() -> dict[str, Any]:
12
+ return ConfigurationService.to_json()
13
+
14
+
15
+ @router.post("")
16
+ async def update_configuration(values: dict[str, Any]) -> dict[str, Any]:
17
+ ConfigurationService.from_json(values)
18
+ ConfigurationService.save()
19
+ return ConfigurationService.to_json()
@@ -0,0 +1,45 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter
6
+ from fastapi.responses import StreamingResponse
7
+
8
+ from trailframe.services.pipeline_service import PipelineService
9
+
10
+ router = APIRouter(prefix="/api/events", tags=["events"])
11
+
12
+ _shutting_down = False
13
+
14
+
15
+ def stop_streams(*args) -> None:
16
+ global _shutting_down
17
+ _shutting_down = True
18
+
19
+
20
+ def _format_sse(event: str, data: dict[str, Any]) -> str:
21
+ payload = json.dumps(data)
22
+ return f"event: {event}\ndata: {payload}\n\n"
23
+
24
+
25
+ async def _event_stream():
26
+ last_message: dict[str, Any] | None = None
27
+
28
+ while not _shutting_down:
29
+ message = PipelineService.get_snapshot()
30
+
31
+ if message != last_message:
32
+ yield _format_sse("pipeline", message)
33
+ last_message = message
34
+
35
+ await asyncio.sleep(0.2)
36
+ yield _format_sse("pipeline", dict({"Text": "Server is shutting down"}))
37
+
38
+
39
+ @router.get("")
40
+ async def stream_events() -> StreamingResponse:
41
+ return StreamingResponse(
42
+ _event_stream(),
43
+ media_type="text/event-stream",
44
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
45
+ )
@@ -0,0 +1,63 @@
1
+ from datetime import datetime
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from pydantic import BaseModel
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlmodel import select
7
+
8
+ from trailframe.models.activity import Activity
9
+ from trailframe.models.photo import Photo
10
+ from trailframe.services.database_service import DatabaseService
11
+
12
+ router = APIRouter(prefix="/api/map-data", tags=["map-data"])
13
+
14
+
15
+ class PhotoPoint(BaseModel):
16
+ id: int
17
+ lat: float
18
+ lon: float
19
+
20
+
21
+ class ActivityTrace(BaseModel):
22
+ id: int
23
+ name: str | None
24
+ trace: list[list[float]]
25
+ start_time: datetime | None = None
26
+ distance: float | None = None
27
+ duration: float | None = None
28
+
29
+
30
+ class MapData(BaseModel):
31
+ photos: list[PhotoPoint]
32
+ activities: list[ActivityTrace]
33
+
34
+
35
+ @router.get("", response_model=MapData)
36
+ async def get_map_data(session: AsyncSession = Depends(DatabaseService.get_session)) -> MapData:
37
+ photo_rows = (
38
+ await session.execute(
39
+ select(Photo.id, Photo.latitude, Photo.longitude).where(
40
+ Photo.latitude.is_not(None), Photo.longitude.is_not(None)
41
+ )
42
+ )
43
+ ).all()
44
+
45
+ activity_rows = (
46
+ await session.execute(select(Activity.id, Activity.name, Activity.trace, Activity.start_time, Activity.distance, Activity.duration))
47
+ ).all()
48
+
49
+ return MapData(
50
+ photos=[PhotoPoint(id=p[0], lat=p[1], lon=p[2]) for p in photo_rows],
51
+ activities=[
52
+ ActivityTrace(
53
+ id=a[0],
54
+ name=a[1],
55
+ trace=[[pt["lat"], pt["lon"]] for pt in (a[2] or []) if "lat" in pt and "lon" in pt],
56
+ start_time=a[3],
57
+ distance=a[4],
58
+ duration=a[5],
59
+ )
60
+ for a in activity_rows
61
+ if a[2]
62
+ ],
63
+ )
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from trailframe.services.configuration_service import ConfigurationService
6
+
7
+ router = APIRouter(prefix="/api/models", tags=["models"])
8
+
9
+ YOLO_MODELS = {
10
+ "yolo26n.pt": "Nano",
11
+ "yolo26s.pt": "Small",
12
+ "yolo26m.pt": "Medium",
13
+ "yolo26l.pt": "Large",
14
+ "yolo26x.pt": "Extra Large",
15
+ }
16
+
17
+
18
+ def _models_folder() -> Path:
19
+ return Path(ConfigurationService.root().get_path_value("general.models_folder", default_value="models"))
20
+
21
+
22
+ @router.get("")
23
+ async def list_models() -> list[str]:
24
+ folder = _models_folder()
25
+
26
+ if not folder.exists():
27
+ return []
28
+
29
+ return [f.name for f in folder.iterdir() if f.suffix == ".pt"]
30
+
31
+
32
+ @router.post("/{name}/download")
33
+ async def download_model(name: str) -> str:
34
+ if name not in YOLO_MODELS:
35
+ raise HTTPException(status_code=400, detail=f"Unknown model: {name}")
36
+
37
+ folder = _models_folder()
38
+ folder.mkdir(parents=True, exist_ok=True)
39
+ target = folder / name
40
+
41
+ if target.exists():
42
+ return str(target)
43
+
44
+ from ultralytics.utils import checks
45
+
46
+ url = f"https://github.com/ultralytics/assets/releases/download/v8.4.0/{name}"
47
+ checks.check_file(url, download_dir=str(folder))
48
+
49
+ if not target.exists():
50
+ raise HTTPException(status_code=500, detail=f"Download failed for {name}")
51
+
52
+ return str(target)