Fix segment effort duplicates; auto-scan on segment creation

- detect.py: truncate started_at to seconds so dedup key survives JSON round-trip
- store.py: dedup by (activity_id, iso-started_at) string key, not object equality
- server.py: extract _scan_segment_for_user helper; trigger background scan
  for the creating user's activities when a new segment is saved
This commit is contained in:
Davide Scaini
2026-05-13 15:58:57 +02:00
parent cb3c9b6e41
commit 2395a6e566
3 changed files with 31 additions and 23 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ def _extract_effort(
j: int, j: int,
) -> SegmentEffort: ) -> SegmentEffort:
elapsed_s = track.times[j] - track.times[i] elapsed_s = track.times[j] - track.times[i]
started_at = track.started_at + timedelta(seconds=track.times[i]) started_at = (track.started_at + timedelta(seconds=track.times[i])).replace(microsecond=0)
avg_speed = _avg_nonnull(track.speeds, i, j) avg_speed = _avg_nonnull(track.speeds, i, j)
avg_hr_raw = _avg_nonnull(track.hrs, i, j) avg_hr_raw = _avg_nonnull(track.hrs, i, j)
avg_hr = int(round(avg_hr_raw)) if avg_hr_raw is not None else None avg_hr = int(round(avg_hr_raw)) if avg_hr_raw is not None else None
+3 -3
View File
@@ -172,10 +172,10 @@ def save_efforts(data_dir: Path, handle: str, segment_id: str, efforts: list[Seg
def add_effort(data_dir: Path, handle: str, segment_id: str, effort: SegmentEffort) -> None: def add_effort(data_dir: Path, handle: str, segment_id: str, effort: SegmentEffort) -> None:
"""Append an effort, replacing any existing effort for the same activity.""" """Append an effort, replacing any existing effort with the same activity + start time."""
efforts = load_efforts(data_dir, handle, segment_id) efforts = load_efforts(data_dir, handle, segment_id)
efforts = [e for e in efforts if e.activity_id != effort.activity_id or key = (effort.activity_id, _iso(effort.started_at))
e.started_at != effort.started_at] efforts = [e for e in efforts if (e.activity_id, _iso(e.started_at)) != key]
efforts.append(effort) efforts.append(effort)
efforts.sort(key=lambda e: e.started_at, reverse=True) efforts.sort(key=lambda e: e.started_at, reverse=True)
save_efforts(data_dir, handle, segment_id, efforts) save_efforts(data_dir, handle, segment_id, efforts)
+27 -19
View File
@@ -23,7 +23,7 @@ from typing import Any, Optional
log = logging.getLogger("bincio.serve") log = logging.getLogger("bincio.serve")
from fastapi import Cookie, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi import BackgroundTasks, Cookie, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
@@ -2459,6 +2459,7 @@ async def get_segment(segment_id: str) -> JSONResponse:
@app.post("/api/segments") @app.post("/api/segments")
async def create_segment( async def create_segment(
body: CreateSegmentRequest, body: CreateSegmentRequest,
background_tasks: BackgroundTasks,
bincio_session: Optional[str] = Cookie(default=None), bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
user = _require_user(bincio_session) user = _require_user(bincio_session)
@@ -2483,7 +2484,9 @@ async def create_segment(
created_by=user.handle, created_by=user.handle,
created_at=datetime.now(_tz.utc), created_at=datetime.now(_tz.utc),
) )
_seg_store.save_segment(_get_data_dir(), seg) dd = _get_data_dir()
_seg_store.save_segment(dd, seg)
background_tasks.add_task(_scan_segment_for_user, dd, user.handle, seg_id)
return JSONResponse({"id": seg_id}, status_code=201) return JSONResponse({"id": seg_id}, status_code=201)
@@ -2529,30 +2532,22 @@ async def get_segment_efforts(
]) ])
@app.post("/api/segments/{segment_id}/detect") def _scan_segment_for_user(dd: Path, handle: str, segment_id: str) -> int:
async def trigger_detect( """Scan all of a user's activities against one segment. Returns effort count."""
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Retroactively detect efforts on a segment for the logged-in user."""
user = _require_user(bincio_session)
dd = _get_data_dir()
seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
raise HTTPException(404, "Segment not found")
from datetime import datetime as _datetime from datetime import datetime as _datetime
from bincio.segments.detect import track_from_timeseries_json, detect_one from bincio.segments.detect import track_from_timeseries_json, detect_one
import json as _json
user_dir = dd / user.handle seg = _seg_store.load_segment(dd, segment_id)
if seg is None:
return 0
user_dir = dd / handle
acts_dir = user_dir / "activities" acts_dir = user_dir / "activities"
total = 0 total = 0
for detail_path in sorted(acts_dir.glob("*.json")): for detail_path in sorted(acts_dir.glob("*.json")):
if ".timeseries." in detail_path.name: if ".timeseries." in detail_path.name:
continue continue
try: try:
detail = _json.loads(detail_path.read_text(encoding="utf-8")) detail = json.loads(detail_path.read_text(encoding="utf-8"))
except Exception: except Exception:
continue continue
ts_url = detail.get("timeseries_url") ts_url = detail.get("timeseries_url")
@@ -2562,7 +2557,7 @@ async def trigger_detect(
if not ts_path.exists(): if not ts_path.exists():
continue continue
try: try:
ts = _json.loads(ts_path.read_text(encoding="utf-8")) ts = json.loads(ts_path.read_text(encoding="utf-8"))
except Exception: except Exception:
continue continue
started_raw = detail.get("started_at") started_raw = detail.get("started_at")
@@ -2578,9 +2573,22 @@ async def trigger_detect(
continue continue
efforts = detect_one(track, seg) efforts = detect_one(track, seg)
for effort in efforts: for effort in efforts:
_seg_store.add_effort(dd, user.handle, segment_id, effort) _seg_store.add_effort(dd, handle, segment_id, effort)
total += len(efforts) total += len(efforts)
return total
@app.post("/api/segments/{segment_id}/detect")
async def trigger_detect(
segment_id: str,
bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
"""Retroactively detect efforts on a segment for the logged-in user."""
user = _require_user(bincio_session)
dd = _get_data_dir()
if _seg_store.load_segment(dd, segment_id) is None:
raise HTTPException(404, "Segment not found")
total = _scan_segment_for_user(dd, user.handle, segment_id)
return JSONResponse({"ok": True, "efforts_found": total}) return JSONResponse({"ok": True, "efforts_found": total})