Summarize LiveTrack sessions and prune stale ones
Fetching a session's track now also grabs its summary (name, activity type, distance, duration) from the same page and stores it — sessions the panel has actually shown replace their raw UUID with that summary, and are kept indefinitely regardless of age. A session nobody ever opened (no summary) is more likely test/dead data than something worth keeping, so it's dropped automatically after 48h.
This commit is contained in:
1 parent
f4b5237f01
commit
0ee98d8f07
5 files changed
+175
-47
No files matched your search
@@ -254,11 +254,13 @@ module Maptool
|
||||
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
|
||||
|
||||
begin
|
||||
points = LiveTrackClient.track_points(session["id"], session["token"])
|
||||
result = LiveTrackClient.fetch(session["id"], session["token"])
|
||||
rescue LiveTrackClient::FetchError => e
|
||||
halt 502, json(error: "could not fetch track: #{e.message}")
|
||||
end
|
||||
LIVETRACK_STORE.set_summary(session["id"], LiveTrackClient.summarize(result))
|
||||
|
||||
points = result.points
|
||||
coordinates = points.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
|
||||
halt 502, json(error: "track has no positions") if coordinates.empty?
|
||||
last = points.last
|
||||
|
||||
@@ -2,31 +2,58 @@ require "net/http"
|
||||
require "json"
|
||||
|
||||
module Maptool
|
||||
# Fetches a Garmin LiveTrack session's track points.
|
||||
# Fetches a Garmin LiveTrack session's track and metadata.
|
||||
#
|
||||
# There's no usable REST call for this: livetrack.garmin.com/api/v2/... is
|
||||
# behind Cloudflare bot protection that 403s a plain HTTP client regardless
|
||||
# of headers (checked — not just a missing User-Agent). The session page
|
||||
# itself isn't blocked, though, and Garmin's Next.js frontend renders it
|
||||
# server-side, embedding the full track (as React Server Component data,
|
||||
# via `self.__next_f.push(...)` calls in the HTML) rather than fetching it
|
||||
# client-side. So: fetch the page, and pull the track out of that instead
|
||||
# of the API it would otherwise call.
|
||||
# server-side, embedding both the full track and the session's own details
|
||||
# (as React Server Component data, via `self.__next_f.push(...)` calls in
|
||||
# the HTML) rather than fetching them client-side. So: fetch the page, and
|
||||
# pull both out of that instead of the API it would otherwise call.
|
||||
class LiveTrackClient
|
||||
class FetchError < StandardError; end
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
|
||||
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
|
||||
|
||||
def self.track_points(session_id, token)
|
||||
new.track_points(session_id, token)
|
||||
Result = Struct.new(:points, :session, keyword_init: true)
|
||||
|
||||
def self.fetch(session_id, token)
|
||||
new.fetch(session_id, token)
|
||||
end
|
||||
|
||||
def track_points(session_id, token)
|
||||
points = find_track_points(fetch_html(session_id, token))
|
||||
# `points`: array of track point hashes (position/dateTime/speed/...).
|
||||
# `session`: hash of session details (sessionName/start/end/...), or nil
|
||||
# if that part wasn't found even though points were (seen in practice:
|
||||
# session details are consistently present alongside points, but nothing
|
||||
# here depends on that holding forever).
|
||||
def fetch(session_id, token)
|
||||
payloads = parse_payloads(fetch_html(session_id, token))
|
||||
|
||||
points = search(payloads) { |node| node["trackPoints"] if node["trackPoints"].is_a?(Array) }
|
||||
raise FetchError, "no track points found (session invalid or expired?)" unless points
|
||||
|
||||
points.map { |point| clean(point) }
|
||||
session = search(payloads) { |node| node if node["sessionName"] }
|
||||
|
||||
Result.new(points: clean(points), session: clean(session))
|
||||
end
|
||||
|
||||
# Distance/duration/activity type come from the last track point (its
|
||||
# totals are cumulative for the whole session), name/type from the
|
||||
# session details — nil-safe throughout since either can be missing.
|
||||
def self.summarize(result)
|
||||
last = result.points.last || {}
|
||||
session = result.session || {}
|
||||
{
|
||||
"session_name" => session["sessionName"],
|
||||
"activity_type" => last["activityType"],
|
||||
"distance_meters" => last["totalDistanceMeters"],
|
||||
"duration_secs" => last["totalDurationSecs"],
|
||||
"start" => session["start"],
|
||||
"end" => session["end"],
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
@@ -55,34 +82,33 @@ module Maptool
|
||||
raise FetchError, e.message
|
||||
end
|
||||
|
||||
# Next.js streams several of these RSC payloads per page; the track
|
||||
# points live inside one of them, under some data-fetching library's own
|
||||
# nesting (React Query, at the time this was written) that isn't worth
|
||||
# depending on the exact shape of — so this just walks every payload
|
||||
# looking for a "trackPoints" array, wherever it ends up.
|
||||
def find_track_points(html)
|
||||
html.scan(PUSH_PATTERN).each do |(raw)|
|
||||
# Next.js streams several of these RSC payloads per page, each an
|
||||
# independently-parseable `[lineId, "<json>"]` pair once its line-id
|
||||
# prefix is stripped off.
|
||||
def parse_payloads(html)
|
||||
html.scan(PUSH_PATTERN).filter_map do |(raw)|
|
||||
push = safe_parse(raw)
|
||||
next unless push.is_a?(Array) && push[1].is_a?(String)
|
||||
|
||||
_line_id, json = push[1].split(":", 2)
|
||||
next unless json
|
||||
|
||||
found = search_track_points(safe_parse(json))
|
||||
return found if found
|
||||
json && safe_parse(json)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def search_track_points(node)
|
||||
# Walks the payloads looking for a hash the block matches — the payload
|
||||
# nests under some data-fetching library's own structure (React Query,
|
||||
# at the time this was written) that isn't worth depending on the exact
|
||||
# shape of.
|
||||
def search(node, &block)
|
||||
case node
|
||||
when Hash
|
||||
return node["trackPoints"] if node["trackPoints"].is_a?(Array)
|
||||
found = block.call(node)
|
||||
return found if found
|
||||
|
||||
node.each_value { |value| (found = search_track_points(value)) and return found }
|
||||
node.each_value { |value| (found = search(value, &block)) and return found }
|
||||
nil
|
||||
when Array
|
||||
node.each { |value| (found = search_track_points(value)) and return found }
|
||||
node.each { |value| (found = search(value, &block)) and return found }
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -101,15 +127,27 @@ if __FILE__ == $0
|
||||
fake_page = <<~HTML
|
||||
<html><body>
|
||||
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
|
||||
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\"}]}]}}}"])</script>
|
||||
<script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script>
|
||||
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script>
|
||||
</body></html>
|
||||
HTML
|
||||
|
||||
points = client.send(:find_track_points, fake_page)
|
||||
payloads = client.send(:parse_payloads, fake_page)
|
||||
points = client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
||||
raise "expected 2 points" unless points&.size == 2
|
||||
raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1
|
||||
|
||||
raise "expected nil when no trackPoints present" unless client.send(:find_track_points, "<html></html>").nil?
|
||||
session = client.send(:search, payloads) { |n| n if n["sessionName"] }
|
||||
raise "expected session name" unless session["sessionName"] == "Test Ride"
|
||||
|
||||
result = Maptool::LiveTrackClient::Result.new(points: client.send(:clean, points), session: client.send(:clean, session))
|
||||
summary = Maptool::LiveTrackClient.summarize(result)
|
||||
raise "expected $undefined cleaned to nil" unless result.points[0]["totalDistanceMeters"].nil?
|
||||
raise "expected summary from last point" unless summary["activity_type"] == "CYCLING" && summary["distance_meters"] == 5000
|
||||
raise "expected session name in summary" unless summary["session_name"] == "Test Ride"
|
||||
|
||||
no_points = client.send(:search, client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
|
||||
raise "expected nil when no trackPoints present" unless no_points.nil?
|
||||
|
||||
puts "ok"
|
||||
end
|
||||
@@ -1,14 +1,23 @@
|
||||
require "json"
|
||||
require "fileutils"
|
||||
require "time"
|
||||
|
||||
module Maptool
|
||||
# JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
|
||||
# webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc).
|
||||
# The token is what actually lets us fetch the session's track later (see
|
||||
# The token is what actually lets us fetch the session's track (see
|
||||
# LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
|
||||
# and the id alone isn't enough. Validity/expiry isn't tracked here —
|
||||
# whoever fetches the track finds out then.
|
||||
# and the id alone isn't enough.
|
||||
#
|
||||
# A session's own summary (see LiveTrackClient.summarize), once fetched,
|
||||
# is kept indefinitely — it's small, and worth keeping as a record of the
|
||||
# activity even after Garmin stops serving the live session. A session
|
||||
# nobody ever opened (so has no summary) is just clutter — likely never
|
||||
# even a real one, going by how this webhook has been tested — and is
|
||||
# dropped after UNSUMMARIZED_TTL.
|
||||
class LiveTrackStore
|
||||
UNSUMMARIZED_TTL = 48 * 60 * 60 # seconds
|
||||
|
||||
def initialize(path)
|
||||
@path = path
|
||||
@mutex = Mutex.new
|
||||
@@ -16,27 +25,62 @@ module Maptool
|
||||
File.write(@path, "[]") unless File.exist?(@path)
|
||||
end
|
||||
|
||||
# Records `session_id`/`token`, moving it to the front if already present.
|
||||
# Records `session_id`/`token`, moving it to the front if already
|
||||
# present (its summary, if any, carries over).
|
||||
def add(session_id, token)
|
||||
@mutex.synchronize do
|
||||
sessions = read
|
||||
sessions = prune(read)
|
||||
existing = sessions.find { |s| s["id"] == session_id }
|
||||
sessions.reject! { |s| s["id"] == session_id }
|
||||
sessions.unshift({ "id" => session_id, "token" => token, "received_at" => Time.now.utc.iso8601 })
|
||||
File.write(@path, JSON.pretty_generate(sessions))
|
||||
sessions.unshift(
|
||||
"id" => session_id,
|
||||
"token" => token,
|
||||
"received_at" => Time.now.utc.iso8601,
|
||||
"summary" => existing && existing["summary"]
|
||||
)
|
||||
write(sessions)
|
||||
end
|
||||
end
|
||||
|
||||
# Attaches a summary (see LiveTrackClient.summarize) to a stored session,
|
||||
# exempting it from UNSUMMARIZED_TTL from here on.
|
||||
def set_summary(session_id, summary)
|
||||
@mutex.synchronize do
|
||||
sessions = read
|
||||
session = sessions.find { |s| s["id"] == session_id }
|
||||
return unless session
|
||||
|
||||
session["summary"] = summary
|
||||
write(sessions)
|
||||
end
|
||||
end
|
||||
|
||||
# Most recently received first.
|
||||
def list
|
||||
read
|
||||
prune(read)
|
||||
end
|
||||
|
||||
def find(session_id)
|
||||
read.find { |s| s["id"] == session_id }
|
||||
list.find { |s| s["id"] == session_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prune(sessions)
|
||||
cutoff = Time.now.utc - UNSUMMARIZED_TTL
|
||||
sessions.select { |s| s["summary"] || received_at(s) > cutoff }
|
||||
end
|
||||
|
||||
def received_at(session)
|
||||
Time.parse(session["received_at"])
|
||||
rescue ArgumentError, TypeError
|
||||
Time.now.utc # malformed/missing timestamp: don't let it block pruning
|
||||
end
|
||||
|
||||
def write(sessions)
|
||||
File.write(@path, JSON.pretty_generate(sessions))
|
||||
end
|
||||
|
||||
def read
|
||||
JSON.parse(File.read(@path))
|
||||
rescue Errno::ENOENT, JSON::ParserError
|
||||
@@ -58,14 +102,29 @@ if __FILE__ == $0
|
||||
raise "expected newest first" unless store.list.first["id"] == "def456"
|
||||
raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc"
|
||||
raise "expected unknown id to be nil" unless store.find("nope").nil?
|
||||
raise "expected no summary yet" unless store.find("abc123")["summary"].nil?
|
||||
|
||||
store.add("abc123", "tok-abc-2")
|
||||
raise "expected re-added session moved to front, not duplicated" \
|
||||
unless store.list.map { |s| s["id"] } == %w[abc123 def456]
|
||||
raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2"
|
||||
|
||||
store.set_summary("abc123", { "session_name" => "Test Ride" })
|
||||
raise "expected summary stored" unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" }
|
||||
store.add("abc123", "tok-abc-3")
|
||||
raise "expected summary to carry over across re-add" \
|
||||
unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" }
|
||||
|
||||
# Manually age an unsummarized session past the TTL and confirm it gets
|
||||
# pruned, while a summarized one (however old) survives.
|
||||
raw = JSON.parse(File.read(File.join(dir, "livetrack.json")))
|
||||
raw.find { |s| s["id"] == "def456" }["received_at"] = (Time.now.utc - 49 * 60 * 60).iso8601
|
||||
raw.find { |s| s["id"] == "abc123" }["received_at"] = (Time.now.utc - 1000 * 60 * 60).iso8601
|
||||
File.write(File.join(dir, "livetrack.json"), JSON.generate(raw))
|
||||
|
||||
reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
|
||||
raise "expected data to survive reload" unless reloaded.list.size == 2
|
||||
raise "expected old unsummarized session pruned" if reloaded.list.any? { |s| s["id"] == "def456" }
|
||||
raise "expected old but summarized session kept" unless reloaded.list.any? { |s| s["id"] == "abc123" }
|
||||
end
|
||||
|
||||
puts "ok"
|
||||
|
||||
@@ -471,13 +471,16 @@ html, body {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.livetrack-panel__id {
|
||||
font-family: monospace;
|
||||
.livetrack-panel__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.livetrack-panel__label--id {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.livetrack-panel__time {
|
||||
flex-shrink: 0;
|
||||
color: #888;
|
||||
|
||||
+31
-5
@@ -68,7 +68,7 @@ export class LiveTrackPanelControl {
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.forEach(({ id, received_at }) => {
|
||||
sessions.forEach(({ id, received_at, summary }) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "livetrack-panel__item";
|
||||
|
||||
@@ -76,16 +76,18 @@ export class LiveTrackPanelControl {
|
||||
button.type = "button";
|
||||
button.className = "livetrack-panel__row";
|
||||
button.classList.toggle("active", id === this._activeSessionId);
|
||||
button.title = id;
|
||||
|
||||
const idSpan = document.createElement("span");
|
||||
idSpan.className = "livetrack-panel__id";
|
||||
idSpan.textContent = id;
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "livetrack-panel__label";
|
||||
labelSpan.classList.toggle("livetrack-panel__label--id", !summary);
|
||||
labelSpan.textContent = summary ? summaryLabel(summary) : id;
|
||||
|
||||
const timeSpan = document.createElement("span");
|
||||
timeSpan.className = "livetrack-panel__time";
|
||||
timeSpan.textContent = new Date(received_at).toLocaleString();
|
||||
|
||||
button.append(idSpan, timeSpan);
|
||||
button.append(labelSpan, timeSpan);
|
||||
button.addEventListener("click", () => this._toggleTrack(id, button));
|
||||
li.appendChild(button);
|
||||
this._list.appendChild(li);
|
||||
@@ -110,6 +112,10 @@ export class LiveTrackPanelControl {
|
||||
this._showTrack(this._geojson);
|
||||
this._activeSessionId = id;
|
||||
button.classList.add("active");
|
||||
// The fetch above also computed and stored a summary server-side
|
||||
// (see LiveTrackClient.summarize) — reload so this row picks it up
|
||||
// instead of showing the raw id from here on.
|
||||
this._loadSessions();
|
||||
} finally {
|
||||
button.classList.remove("loading");
|
||||
}
|
||||
@@ -170,3 +176,23 @@ export class LiveTrackPanelControl {
|
||||
if (this._map.getSource(TRACK_SOURCE)) this._map.removeSource(TRACK_SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
function summaryLabel({ session_name, activity_type, distance_meters, duration_secs }) {
|
||||
const parts = [];
|
||||
if (activity_type) parts.push(titleCase(activity_type));
|
||||
if (typeof distance_meters === "number") parts.push(`${(distance_meters / 1000).toFixed(1)} km`);
|
||||
if (typeof duration_secs === "number") parts.push(formatDuration(duration_secs));
|
||||
const stats = parts.join(" · ");
|
||||
if (session_name && stats) return `${session_name} — ${stats}`;
|
||||
return session_name || stats || "Untitled session";
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return text.charAt(0) + text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function formatDuration(totalSeconds) {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
Reference in new issue
Block a user