mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 23:27:01 +01:00
RunnerUp can now upload to Endurain directly as well as through the WebDAV inbox, and the two paths cannot see each other: the same run landed twice (activities 14 and 15, identical start time, distances differing only by tcx/gpx rounding). The ledger only knows what this importer handled, so it could not catch it. Add a third dedupe check that asks Endurain what it already holds, queried once per run. Endurain exposes no query-by-start-time endpoint, so a page of recent activities is fetched and compared client-side, with a +/-120s tolerance: the file for activity 15 embeds 21:51:04Z while the server stored 21:51:05Z, so exact equality misses the very duplicate that motivated this. A failed or unauthorised query returns None, never an empty list, so it can never be read as "nothing matches" and upload on the strength of it. Export filenames are parsed as LOCAL wall-clock time. RunnerUp_2026-08-14- 00-58-45_Running is 2026-08-13 22:58:45 UTC; reading it as UTC would move a late-evening run onto the wrong day and defeat the comparison. Also fix the adb fallback, which broke the moment a second device attached: phone_attached() returned true if ANY device was in the 'device' state, then every bare adb call failed with "more than one device/emulator" as a warning on a path nobody watches. The serial is now resolved once and threaded into every call as -s. Ambiguity is settled by configuration, never by guessing -- several devices and no ENDURAIN_ADB_SERIAL means the fallback is skipped. The remote check stays inert until the API key is granted activities:read; until then it falls back to the ledger, which is the previous behaviour. 85 tests, 100% branch coverage on all four modules, no suppressions. The oversized test modules are split by concern to stay under the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
"""Content-addressed record of what has already been imported.
|
|
|
|
Endurain has no duplicate detection: uploading the same file twice creates two
|
|
activities. The ledger is therefore the only thing standing between a re-run
|
|
and a duplicated activity history, and it keys on the file's SHA-256 rather
|
|
than its name so that the same run imports once even when it arrives by both
|
|
the WebDAV and adb routes under different filenames.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_CHUNK = 1 << 20
|
|
# "RunnerUp", <timestamp>, <sport> -- the minimum for a parseable export name.
|
|
_MIN_NAME_PARTS = 3
|
|
# <yyyy>-<mm>-<dd>-<HH>-<MM>-<SS> once split on "-".
|
|
_STAMP_FIELDS = 6
|
|
|
|
|
|
class LedgerCorruptError(RuntimeError):
|
|
"""Raised when the on-disk ledger cannot be parsed."""
|
|
|
|
|
|
def activity_key(path: Path) -> str:
|
|
"""Return a stable identity for the *run*, independent of export format.
|
|
|
|
RunnerUp writes the same activity as both .gpx and .tcx
|
|
(RunnerUp_2026-08-22-23-51-04_Running.{gpx,tcx}). Those files have
|
|
different bytes, so a content hash sees two distinct files and Endurain --
|
|
which does no duplicate detection -- happily creates two activities for one
|
|
run. Keying on the stem collapses them.
|
|
|
|
The device model that RunnerUp's WebDAV synchronizer injects
|
|
(RunnerUp_Pixel_6a_<ts>_Running) is stripped too, so the same run arriving
|
|
over WebDAV and over adb resolves to one key.
|
|
"""
|
|
stem = path.stem
|
|
parts = stem.split("_")
|
|
if len(parts) >= _MIN_NAME_PARTS and parts[0] == "RunnerUp":
|
|
# Keep the trailing <timestamp>_<sport>; drop any model in between.
|
|
return f"RunnerUp_{parts[-2]}_{parts[-1]}"
|
|
return stem
|
|
|
|
|
|
def activity_start(path: Path) -> datetime | None:
|
|
"""Parse the run's start time out of a RunnerUp export filename.
|
|
|
|
RunnerUp names exports with LOCAL wall-clock time
|
|
(RunnerUp_2026-08-14-00-58-45_Running is 00:58 local, 22:58 UTC the day
|
|
before), so the value is interpreted in the local zone and returned as
|
|
UTC. Reading it as UTC directly would shift a late-evening run onto the
|
|
wrong day and defeat the comparison it feeds.
|
|
"""
|
|
key = activity_key(path)
|
|
parts = key.split("_")
|
|
if len(parts) < _MIN_NAME_PARTS:
|
|
return None
|
|
fields = parts[-2].split("-")
|
|
if len(fields) != _STAMP_FIELDS:
|
|
return None
|
|
try:
|
|
naive = datetime(
|
|
int(fields[0]),
|
|
int(fields[1]),
|
|
int(fields[2]),
|
|
int(fields[3]),
|
|
int(fields[4]),
|
|
int(fields[5]),
|
|
tzinfo=datetime.now().astimezone().tzinfo,
|
|
)
|
|
except ValueError:
|
|
return None
|
|
return naive.astimezone(timezone.utc)
|
|
|
|
|
|
def file_digest(path: Path) -> str:
|
|
"""Return the SHA-256 of ``path``, read incrementally."""
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
while chunk := handle.read(_CHUNK):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Entry:
|
|
"""One successfully imported file."""
|
|
|
|
sha256: str
|
|
activity_key: str
|
|
filename: str
|
|
activity_id: int | None
|
|
uploaded_at: str
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
"""Return the JSON-serialisable form stored in the ledger."""
|
|
return {
|
|
"activity_key": self.activity_key,
|
|
"filename": self.filename,
|
|
"activity_id": self.activity_id,
|
|
"uploaded_at": self.uploaded_at,
|
|
}
|
|
|
|
|
|
class Ledger:
|
|
"""A JSON-backed set of imported content hashes.
|
|
|
|
Writes are atomic (temp file + replace) because the importer runs from a
|
|
systemd timer: a crash mid-write must not leave a truncated ledger, which
|
|
would look like "nothing has been imported" and duplicate the entire
|
|
history on the next run.
|
|
"""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
"""Open (or create) the ledger stored at ``path``."""
|
|
self._path = path
|
|
self._entries: dict[str, dict[str, Any]] = {}
|
|
self._load()
|
|
|
|
def _load(self) -> None:
|
|
if not self._path.exists():
|
|
return
|
|
try:
|
|
raw = json.loads(self._path.read_text())
|
|
except json.JSONDecodeError as exc:
|
|
# Fail closed: a corrupt ledger must stop the run, not silently
|
|
# re-import everything.
|
|
message = f"corrupt ledger at {self._path}: {exc}"
|
|
raise LedgerCorruptError(message) from exc
|
|
if isinstance(raw, dict):
|
|
self._entries = raw
|
|
|
|
def __contains__(self, sha256: str) -> bool:
|
|
"""True when a file with this digest has already been imported."""
|
|
return sha256 in self._entries
|
|
|
|
def has_activity(self, key: str) -> bool:
|
|
"""True when this run was imported already, in any export format."""
|
|
return any(entry.get("activity_key") == key for entry in self._entries.values())
|
|
|
|
def __len__(self) -> int:
|
|
"""Number of files recorded as imported."""
|
|
return len(self._entries)
|
|
|
|
def record(self, entry: Entry) -> None:
|
|
"""Add ``entry`` and flush to disk immediately."""
|
|
self._entries[entry.sha256] = entry.as_dict()
|
|
self._flush()
|
|
|
|
def _flush(self) -> None:
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self._path.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(self._entries, indent=2, sort_keys=True))
|
|
tmp.replace(self._path)
|
|
|
|
|
|
def now_iso() -> str:
|
|
"""UTC timestamp for ledger entries."""
|
|
return datetime.now(timezone.utc).isoformat()
|