Files
maptool/lib/maptool/livetrack_store.rb
T
randy 0c4ee97368
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 20s
Flag LiveTrack sessions whose map Garmin no longer serves
Ended sessions past the ~24h Garmin appears to keep a live-track page
around for get verified once at app startup and, once confirmed dead,
flagged so the panel greys them out instead of only failing after a
click. Already-flagged sessions are skipped on later startups so this
doesn't keep re-hitting Garmin for the same dead session.
2026-09-08 14:19:15 +02:00

130 lines
4.2 KiB
Ruby

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 (see
# LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
# 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
FileUtils.mkdir_p(File.dirname(@path))
File.write(@path, "[]") unless File.exist?(@path)
end
# 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 = 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,
"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
# Ended sessions (summary["active"] == false) that ended more than
# `stale_after` seconds ago and aren't already flagged unavailable —
# i.e. ones Garmin has very likely stopped serving the live-track page
# for, but that haven't been confirmed yet (see app.rb's startup
# check). Excludes already-flagged ones so that check doesn't re-hit
# Garmin, on every restart, for a session it already told us is gone.
def stale_ended(stale_after)
cutoff = Time.now.utc - stale_after
list.select { |s| stale_ended?(s, cutoff) }
end
# Flags a session's live-track page as no longer served by Garmin, once
# confirmed (see app.rb's startup check) — the summary itself (small,
# and a record of the activity, see the class comment) is left alone.
def mark_unavailable(session_id)
@mutex.synchronize do
sessions = read
session = sessions.find { |s| s["id"] == session_id }
return unless session
session["map_available"] = false
write(sessions)
end
end
# Most recently received first.
def list
prune(read)
end
def find(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 stale_ended?(session, cutoff)
summary = session["summary"]
return false unless summary && summary["active"] == false && session["map_available"] != false
ended = ended_at(summary)
ended && ended < cutoff
end
def ended_at(summary)
Time.parse(summary["end"])
rescue ArgumentError, TypeError
nil # malformed/missing "end": can't tell how long it's been, so don't guess
end
def write(sessions)
File.write(@path, JSON.pretty_generate(sessions))
end
def read
JSON.parse(File.read(@path))
rescue Errno::ENOENT, JSON::ParserError
[]
end
end
end